@adatechnology/conversations-ui 0.1.0-rc.26 → 0.1.0-rc.27

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 (75) hide show
  1. package/dist/chunk-DXPSPUWF.js +110 -0
  2. package/dist/{chunk-TV4OQRGH.js → chunk-UZJBYD5O.js} +123 -45
  3. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  4. package/dist/flows/index.d.ts +2 -0
  5. package/dist/flows/index.js +61 -18
  6. package/dist/index.d.ts +37 -10
  7. package/dist/index.js +694 -297
  8. package/dist/preview/index.js +3 -3
  9. package/dist/styles.css +119 -6
  10. package/package.json +1 -1
  11. package/src/AudioPlayer.tsx +8 -0
  12. package/src/AudioRecorderButton.test.tsx +3 -3
  13. package/src/AudioRecorderButton.tsx +3 -3
  14. package/src/AudioTranscription.tsx +4 -1
  15. package/src/Avatar.tsx +1 -1
  16. package/src/ConversationContextPanel.tsx +2 -2
  17. package/src/ConversationDocumentsPanel.tsx +11 -6
  18. package/src/ConversationHeader.test.tsx +66 -0
  19. package/src/ConversationHeader.tsx +147 -47
  20. package/src/ConversationListItem.tsx +3 -2
  21. package/src/ConversationRow.tsx +31 -7
  22. package/src/DocumentsLibrary.tsx +9 -4
  23. package/src/EmojiPicker.tsx +2 -1
  24. package/src/InteractiveMessage.tsx +3 -0
  25. package/src/Lightbox.tsx +1 -1
  26. package/src/MediaRenderer.tsx +2 -0
  27. package/src/MessageBubble.test.tsx +41 -0
  28. package/src/MessageBubble.tsx +27 -8
  29. package/src/MessageComposer.tsx +11 -8
  30. package/src/RichMessageComposer.test.tsx +42 -12
  31. package/src/RichMessageComposer.tsx +216 -45
  32. package/src/SimpleEmojiPicker.tsx +5 -3
  33. package/src/StatusTicks.tsx +1 -1
  34. package/src/Toast.tsx +4 -0
  35. package/src/Tooltip.test.ts +42 -0
  36. package/src/Tooltip.tsx +164 -0
  37. package/src/WhatsAppMessageEditor.tsx +4 -4
  38. package/src/WindowExpiredNotice.tsx +12 -4
  39. package/src/composer.constant.ts +33 -0
  40. package/src/conversationWindow.ts +7 -5
  41. package/src/documents/DocumentsWorkspace.tsx +10 -6
  42. package/src/flows/FlowGroupHeader.tsx +2 -2
  43. package/src/flows/FlowMapNode.tsx +2 -1
  44. package/src/flows/FlowNodeCard.tsx +3 -3
  45. package/src/flows/FlowNodePanel.tsx +6 -5
  46. package/src/flows/FlowPalette.tsx +7 -1
  47. package/src/flows/FlowPortalNode.tsx +1 -1
  48. package/src/flows/FlowsWorkspace.tsx +13 -4
  49. package/src/flows/labels.ts +4 -0
  50. package/src/hooks/useContainerWidth.ts +35 -0
  51. package/src/hooks/useConversationRealtime.ts +10 -8
  52. package/src/icon.constant.ts +12 -0
  53. package/src/index.ts +1 -0
  54. package/src/lib/composer-formatting.test.ts +78 -0
  55. package/src/lib/composer-formatting.ts +145 -0
  56. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  57. package/src/lib/whatsapp-formatting.tsx +28 -3
  58. package/src/listing/index.tsx +5 -1
  59. package/src/pagination.constant.ts +10 -0
  60. package/src/preview/ConversationSimulatorPanel.tsx +1 -1
  61. package/src/providers/ConversationsProvider.tsx +8 -6
  62. package/src/settings/MessagesWorkspace.tsx +3 -0
  63. package/src/settings/TopicsForm.tsx +2 -0
  64. package/src/settings/TranscriptionSettingsForm.test.tsx +1 -1
  65. package/src/settings/TranscriptionSettingsForm.tsx +1 -0
  66. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  67. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  68. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  69. package/src/settings/WhatsAppTemplatesSettings.tsx +2 -0
  70. package/src/styles.css +100 -4
  71. package/src/workspace/BulkTemplateModal.tsx +4 -2
  72. package/src/workspace/ConversationPane.tsx +49 -19
  73. package/src/workspace/ConversationsInboxList.tsx +17 -8
  74. package/src/workspace/ConversationsWorkspace.tsx +24 -16
  75. package/src/workspace/useConversationsInbox.ts +5 -2
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Camada única de dicas do pacote.
3
+ *
4
+ * O `title` nativo existia em todos os botões e mesmo assim a dica "não aparecia": o navegador
5
+ * espera de 1 a 2 segundos antes de desenhar, o balão sai fora do tema e some ao menor movimento do
6
+ * mouse. Aqui a dica é do produto — aparece em 120ms, com o mesmo contraste do resto da interface.
7
+ *
8
+ * Um só ouvinte no documento atende a interface inteira: cada botão declara `data-cv-tooltip` e não
9
+ * precisa de estado, ref ou wrapper próprio. O balão é impresso em `document.body` via portal
10
+ * porque `overflow` de container (a régua de respostas rápidas rola na horizontal) recortaria um
11
+ * balão desenhado dentro do próprio botão.
12
+ */
13
+
14
+ import { useEffect, useRef, useState } from 'react'
15
+ import { createPortal } from 'react-dom'
16
+
17
+ /** Atributo que marca um elemento como portador de dica. */
18
+ export const TOOLTIP_ATTRIBUTE = 'data-cv-tooltip'
19
+
20
+ const TOOLTIP_DELAY_MS = 120
21
+ const TOOLTIP_GAP_PX = 8
22
+ /** Margem para o balão não colar na borda da janela quando o alvo está no canto. */
23
+ const TOOLTIP_EDGE_PX = 72
24
+
25
+ export type TooltipPlacement = 'top' | 'bottom'
26
+
27
+ export interface TooltipPositionParams {
28
+ readonly targetRect: { top: number; bottom: number; left: number; width: number }
29
+ readonly viewportWidth: number
30
+ }
31
+
32
+ export interface TooltipPosition {
33
+ left: number
34
+ top: number
35
+ placement: TooltipPlacement
36
+ }
37
+
38
+ interface TooltipState extends TooltipPosition {
39
+ text: string
40
+ }
41
+
42
+ /** Separado do componente por ser a única parte com regra: o resto é ouvir evento e desenhar. */
43
+ export function tooltipPositionOf({ targetRect, viewportWidth }: TooltipPositionParams): TooltipPosition {
44
+ // Sem espaço acima (botão no topo da tela), o balão desce em vez de sair da janela.
45
+ const placement: TooltipPlacement = targetRect.top < TOOLTIP_EDGE_PX ? 'bottom' : 'top'
46
+ const centerX = targetRect.left + targetRect.width / 2
47
+
48
+ return {
49
+ left: Math.min(Math.max(centerX, TOOLTIP_EDGE_PX), viewportWidth - TOOLTIP_EDGE_PX),
50
+ top: placement === 'top' ? targetRect.top - TOOLTIP_GAP_PX : targetRect.bottom + TOOLTIP_GAP_PX,
51
+ placement,
52
+ }
53
+ }
54
+
55
+ function tooltipStateFor(target: HTMLElement, text: string): TooltipState {
56
+ const position = tooltipPositionOf({
57
+ targetRect: target.getBoundingClientRect(),
58
+ viewportWidth: window.innerWidth,
59
+ })
60
+
61
+ return { text, ...position }
62
+ }
63
+
64
+ /**
65
+ * Só uma camada desenha por vez. A camada é montada por cada superfície do pacote (inbox, fluxos,
66
+ * documentos) para o host não precisar montá-la à mão; duas superfícies na mesma tela renderizariam
67
+ * dois balões sobrepostos sem essa trava.
68
+ */
69
+ let layerOwner: object | undefined
70
+
71
+ export function TooltipLayer() {
72
+ const identity = useRef({})
73
+ const [isOwner, setIsOwner] = useState(false)
74
+ const [tooltip, setTooltip] = useState<TooltipState | undefined>(undefined)
75
+
76
+ useEffect(() => {
77
+ const owned = identity.current
78
+ if (layerOwner && layerOwner !== owned) return undefined
79
+ layerOwner = owned
80
+ setIsOwner(true)
81
+
82
+ return () => {
83
+ if (layerOwner === owned) layerOwner = undefined
84
+ }
85
+ }, [])
86
+
87
+ useEffect(() => {
88
+ if (!isOwner) return undefined
89
+
90
+ let timer: ReturnType<typeof setTimeout> | undefined
91
+
92
+ function cancelPending() {
93
+ if (timer) clearTimeout(timer)
94
+ timer = undefined
95
+ }
96
+
97
+ function hide() {
98
+ cancelPending()
99
+ setTooltip(undefined)
100
+ }
101
+
102
+ function handleEnter(event: Event) {
103
+ const origin = event.target
104
+ if (!(origin instanceof Element)) return
105
+ const target = origin.closest(`[${TOOLTIP_ATTRIBUTE}]`)
106
+ if (!(target instanceof HTMLElement)) {
107
+ hide()
108
+ return
109
+ }
110
+ const text = target.getAttribute(TOOLTIP_ATTRIBUTE)
111
+ if (!text) {
112
+ hide()
113
+ return
114
+ }
115
+ cancelPending()
116
+ timer = setTimeout(() => setTooltip(tooltipStateFor(target, text)), TOOLTIP_DELAY_MS)
117
+ }
118
+
119
+ // Sair da janela não dispara `pointerover` em elemento nenhum: sem isto, o balão ficaria aceso
120
+ // depois que o ponteiro já saiu da página.
121
+ function handleLeaveWindow(event: PointerEvent) {
122
+ if (!event.relatedTarget) hide()
123
+ }
124
+
125
+ function handleKeyDown(event: KeyboardEvent) {
126
+ if (event.key === 'Escape') hide()
127
+ }
128
+
129
+ document.addEventListener('pointerover', handleEnter)
130
+ document.addEventListener('pointerout', handleLeaveWindow)
131
+ document.addEventListener('focusin', handleEnter)
132
+ document.addEventListener('pointerdown', hide)
133
+ document.addEventListener('focusout', hide)
134
+ document.addEventListener('keydown', handleKeyDown)
135
+ // Captura: rolagem em qualquer container interno invalida a posição já medida.
136
+ window.addEventListener('scroll', hide, true)
137
+ window.addEventListener('resize', hide)
138
+
139
+ return () => {
140
+ cancelPending()
141
+ document.removeEventListener('pointerover', handleEnter)
142
+ document.removeEventListener('pointerout', handleLeaveWindow)
143
+ document.removeEventListener('focusin', handleEnter)
144
+ document.removeEventListener('pointerdown', hide)
145
+ document.removeEventListener('focusout', hide)
146
+ document.removeEventListener('keydown', handleKeyDown)
147
+ window.removeEventListener('scroll', hide, true)
148
+ window.removeEventListener('resize', hide)
149
+ }
150
+ }, [isOwner])
151
+
152
+ if (!tooltip) return null
153
+
154
+ return createPortal(
155
+ <div
156
+ role="tooltip"
157
+ className={`cv-tooltip cv-tooltip--${tooltip.placement}`}
158
+ style={{ left: tooltip.left, top: tooltip.top }}
159
+ >
160
+ {tooltip.text}
161
+ </div>,
162
+ document.body,
163
+ )
164
+ }
@@ -117,13 +117,13 @@ export function WhatsAppMessageEditor({
117
117
  return (
118
118
  <div>
119
119
  <div className="flex flex-wrap items-center gap-1.5 mb-2">
120
- <button type="button" onClick={() => wrapSelection('*')} className={toolbarButtonClass} title={editorLabels.boldHint} aria-label={editorLabels.bold}>
120
+ <button type="button" onClick={() => wrapSelection('*')} className={toolbarButtonClass} data-cv-tooltip={editorLabels.boldHint} aria-label={editorLabels.bold}>
121
121
  <Bold size={15} />
122
122
  </button>
123
- <button type="button" onClick={() => wrapSelection('_')} className={toolbarButtonClass} title={editorLabels.italicHint} aria-label={editorLabels.italic}>
123
+ <button type="button" onClick={() => wrapSelection('_')} className={toolbarButtonClass} data-cv-tooltip={editorLabels.italicHint} aria-label={editorLabels.italic}>
124
124
  <Italic size={15} />
125
125
  </button>
126
- <button type="button" onClick={() => wrapSelection('~')} className={toolbarButtonClass} title={editorLabels.strikethroughHint} aria-label={editorLabels.strikethrough}>
126
+ <button type="button" onClick={() => wrapSelection('~')} className={toolbarButtonClass} data-cv-tooltip={editorLabels.strikethroughHint} aria-label={editorLabels.strikethrough}>
127
127
  <Strikethrough size={15} />
128
128
  </button>
129
129
  {placeholders.length > 0 && (
@@ -135,7 +135,7 @@ export function WhatsAppMessageEditor({
135
135
  type="button"
136
136
  onClick={() => insertAtCursor(token)}
137
137
  className="inline-flex items-center h-8 px-2 rounded-lg border border-gray-200 dark:border-gray-600 text-xs text-blue-600 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/40 transition-colors"
138
- title={editorLabels.insertPlaceholder(token)}
138
+ data-cv-tooltip={editorLabels.insertPlaceholder(token)} aria-label={editorLabels.insertPlaceholder(token)}
139
139
  >
140
140
  {token}
141
141
  </button>
@@ -6,7 +6,10 @@
6
6
  * cumpre. O caminho que resta — template — fica visível na mesma altura da tela.
7
7
  */
8
8
 
9
+ import { AlarmClock, Send } from 'lucide-react'
10
+
9
11
  import { CONVERSATION_WINDOW, type ConversationWindow } from './conversationWindow'
12
+ import { ICON_SIZE_ACTION, ICON_SIZE_INLINE } from './icon.constant'
10
13
  import { cn } from './lib/cn'
11
14
 
12
15
  export interface WindowExpiredNoticeLabels {
@@ -15,10 +18,11 @@ export interface WindowExpiredNoticeLabels {
15
18
  sendTemplate: string
16
19
  }
17
20
 
21
+ // Sem emoji no rótulo: o ícone vem ao lado, da mesma biblioteca do resto da tela.
18
22
  export const DEFAULT_WINDOW_EXPIRED_LABELS: WindowExpiredNoticeLabels = {
19
- title: '⏰ Janela de 24h expirada — o WhatsApp não aceita mensagens livres.',
23
+ title: 'Janela de 24h expirada — o WhatsApp não aceita mensagens livres.',
20
24
  description: 'Envie uma mensagem de template para reabrir a conversa com o cliente.',
21
- sendTemplate: '📨 Enviar Template (HSM)',
25
+ sendTemplate: 'Enviar Template (HSM)',
22
26
  }
23
27
 
24
28
  export interface WindowExpiredNoticeProps {
@@ -38,10 +42,14 @@ export function WindowExpiredNotice({
38
42
 
39
43
  return (
40
44
  <div role="status" className={cn('border-t bg-yellow-50 px-4 py-3 text-sm dark:bg-yellow-950', className)}>
41
- <p className="font-medium text-yellow-900 dark:text-yellow-200">{labels.title}</p>
45
+ <p className="flex items-center gap-2 font-medium text-yellow-900 dark:text-yellow-200">
46
+ <AlarmClock size={ICON_SIZE_INLINE} aria-hidden="true" className="shrink-0" />
47
+ {labels.title}
48
+ </p>
42
49
  <p className="text-yellow-800 dark:text-yellow-300">{labels.description}</p>
43
50
  {onSendTemplate ? (
44
- <button type="button" onClick={onSendTemplate} disabled={disabled} className="cv-header-action mt-2">
51
+ <button data-cv-tooltip={labels.sendTemplate} aria-label={labels.sendTemplate} type="button" onClick={onSendTemplate} disabled={disabled} className="cv-header-action mt-2">
52
+ <Send size={ICON_SIZE_ACTION} aria-hidden="true" />
45
53
  {labels.sendTemplate}
46
54
  </button>
47
55
  ) : null}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Aparência da barra de composição, compartilhada pelo `MessageComposer` e pelo
3
+ * `RichMessageComposer`. São a mesma superfície na tela: divergir aqui é o que fazia a barra rica
4
+ * aparecer branca sobre o fundo branco da página, com os atalhos flutuando sem faixa própria.
5
+ */
6
+
7
+ /** Faixa cinza de largura cheia; o campo de texto dentro dela é que arredonda — ordem do WhatsApp. */
8
+ export const COMPOSER_BAR_CLASS = 'cv-composer-bar'
9
+
10
+ /** Botão redondo da barra: formatação, emoji, variáveis e anexo têm o mesmo peso visual. */
11
+ export const COMPOSER_TOOL_BUTTON_CLASS =
12
+ 'flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full transition-colors hover:bg-teal-50 hover:text-teal-600 dark:hover:bg-teal-900/30 dark:hover:text-teal-400'
13
+
14
+ /** Formatação em vigor no cursor. Sem isto o operador não sabe se o próximo caractere sai em negrito. */
15
+ export const COMPOSER_TOOL_BUTTON_ACTIVE_CLASS = 'bg-teal-50 text-teal-600 dark:bg-teal-900/40 dark:text-teal-400'
16
+
17
+ export const COMPOSER_TOOL_BUTTON_IDLE_CLASS = 'text-gray-400 dark:text-gray-500'
18
+
19
+ /** Marcação do monoespaçado dentro do campo — a mesma que o `waToHTML` escreve, para o trecho não
20
+ * mudar de cara ao recarregar um rascunho. */
21
+ export const COMPOSER_MONOSPACE_CLASS = 'bg-black/5 dark:bg-white/10 rounded px-0.5 font-mono text-sm'
22
+
23
+ /**
24
+ * Abaixo desta largura de barra (em px) os quatro botões de formatação recolhem num único botão com
25
+ * popover. Com todos abertos, sobram ~200px de barra para os oito controles e o campo de texto fica
26
+ * mais estreito que o próprio placeholder. É a largura da barra, não da janela: quem estreita a
27
+ * coluna é a prévia do simulador ao lado, com a janela intacta.
28
+ */
29
+ export const COMPOSER_COMPACT_WIDTH = 480
30
+
31
+ /** Atalho de resposta rápida: pílula branca, que só tem contraste porque a faixa é cinza. */
32
+ export const QUICK_REPLY_PILL_CLASS =
33
+ 'whitespace-nowrap rounded-full border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 shadow-sm transition-colors hover:border-teal-200 hover:bg-teal-50 hover:text-teal-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:border-teal-800 dark:hover:bg-teal-950/40 dark:hover:text-teal-400'
@@ -22,12 +22,14 @@ export const CONVERSATION_WINDOW = {
22
22
  } as const
23
23
  export type ConversationWindow = (typeof CONVERSATION_WINDOW)[keyof typeof CONVERSATION_WINDOW]
24
24
 
25
+ // O tom pinta o próprio chip, em vez de uma bolinha ao lado do rótulo: a bolinha somava ~14px por
26
+ // filtro e era o que fazia a fila de cinco quebrar em duas linhas na coluna da lista.
25
27
  export const WINDOW_FILTERS = [
26
- { value: CONVERSATION_WINDOW.ALL, label: 'Todas', dotClass: '' },
27
- { value: CONVERSATION_WINDOW.FRESH, label: '<12h', dotClass: 'bg-green-500' },
28
- { value: CONVERSATION_WINDOW.WARNING, label: '12-21h', dotClass: 'bg-yellow-500' },
29
- { value: CONVERSATION_WINDOW.CRITICAL, label: '21-24h', dotClass: 'bg-red-500' },
30
- { value: CONVERSATION_WINDOW.EXPIRED, label: '>24h', dotClass: 'bg-gray-400' },
28
+ { value: CONVERSATION_WINDOW.ALL, label: 'Todas', tone: '' },
29
+ { value: CONVERSATION_WINDOW.FRESH, label: '<12h', tone: 'fresh' },
30
+ { value: CONVERSATION_WINDOW.WARNING, label: '12-21h', tone: 'warning' },
31
+ { value: CONVERSATION_WINDOW.CRITICAL, label: '21-24h', tone: 'critical' },
32
+ { value: CONVERSATION_WINDOW.EXPIRED, label: '>24h', tone: 'expired' },
31
33
  ] as const
32
34
 
33
35
  // Proporções da janela: 12h e 21h numa janela de 24h, mas expressas como fração para acompanharem
@@ -28,6 +28,7 @@ import {
28
28
  import { useDebouncedValue, useUrlArrayState, useUrlNumberState, useUrlStringState } from '../hooks/useUrlFilterState'
29
29
  import type { CompanyDocument } from '../providers/types'
30
30
  import { DEFAULT_DOCUMENTS_WORKSPACE_LABELS, type DocumentsWorkspaceLabels } from './labels'
31
+ import { TooltipLayer } from '../Tooltip'
31
32
 
32
33
  const DEFAULT_PER_PAGE = 20
33
34
  const DEFAULT_PER_PAGE_OPTIONS = [10, 20, 50, 100] as const
@@ -249,6 +250,7 @@ export function DocumentsWorkspace({
249
250
 
250
251
  return (
251
252
  <div className={cn('space-y-4', classNames?.root, className)}>
253
+ <TooltipLayer />
252
254
  <header className={cn('space-y-0.5', classNames?.header)}>
253
255
  <h2 className="text-lg font-semibold">{labels.title}</h2>
254
256
  <p className="text-sm text-gray-500 dark:text-gray-400">{labels.subtitle(total)}</p>
@@ -297,7 +299,7 @@ export function DocumentsWorkspace({
297
299
  {renderFilters?.({ extra, setExtra })}
298
300
 
299
301
  {hasFilters ? (
300
- <button type="button" onClick={clearFilters} className="cv-header-action inline-flex items-center gap-1">
302
+ <button data-cv-tooltip={labels.clearFilters} aria-label={labels.clearFilters} type="button" onClick={clearFilters} className="cv-header-action inline-flex items-center gap-1">
301
303
  <X size={12} aria-hidden="true" />
302
304
  {labels.clearFilters}
303
305
  </button>
@@ -313,6 +315,7 @@ export function DocumentsWorkspace({
313
315
  >
314
316
  {downloadArchive ? (
315
317
  <button
318
+ data-cv-tooltip={labels.bulkDownloadZip} aria-label={labels.bulkDownloadZip}
316
319
  type="button"
317
320
  onClick={() => void handleDownloadArchive()}
318
321
  disabled={busy}
@@ -324,6 +327,7 @@ export function DocumentsWorkspace({
324
327
  ) : null}
325
328
  {removeDocument ? (
326
329
  <button
330
+ data-cv-tooltip={labels.bulkRemove(selectedIds.size)} aria-label={labels.bulkRemove(selectedIds.size)}
327
331
  type="button"
328
332
  onClick={() => {
329
333
  if (window.confirm(labels.bulkRemoveConfirm(selectedIds.size))) void handleRemove(Array.from(selectedIds))
@@ -389,7 +393,7 @@ export function DocumentsWorkspace({
389
393
  <td className="max-w-xs px-3 py-2">
390
394
  <div className="flex min-w-0 items-center gap-2">
391
395
  <FileIcon filename={document.filename} mimeType={document.mimeType} />
392
- <span className="truncate font-medium" title={document.filename}>
396
+ <span className="truncate font-medium" data-cv-tooltip={document.filename}>
393
397
  {document.filename}
394
398
  </span>
395
399
  </div>
@@ -402,7 +406,7 @@ export function DocumentsWorkspace({
402
406
  <button
403
407
  type="button"
404
408
  onClick={() => onOpenConversation(document.conversationId)}
405
- title={labels.openConversation}
409
+ data-cv-tooltip={labels.openConversation} aria-label={labels.openConversation}
406
410
  className="cv-header-action inline-flex items-center gap-1"
407
411
  >
408
412
  <MessageSquare size={12} aria-hidden="true" />
@@ -425,7 +429,7 @@ export function DocumentsWorkspace({
425
429
  <button
426
430
  type="button"
427
431
  onClick={() => void handleOpen(document.id, 'inline')}
428
- title={labels.view}
432
+ data-cv-tooltip={labels.view}
429
433
  aria-label={`${labels.view}: ${document.filename}`}
430
434
  className="cv-header-icon"
431
435
  >
@@ -434,7 +438,7 @@ export function DocumentsWorkspace({
434
438
  <button
435
439
  type="button"
436
440
  onClick={() => void handleOpen(document.id, 'attachment')}
437
- title={labels.download}
441
+ data-cv-tooltip={labels.download}
438
442
  aria-label={`${labels.download}: ${document.filename}`}
439
443
  className="cv-header-icon"
440
444
  >
@@ -447,7 +451,7 @@ export function DocumentsWorkspace({
447
451
  if (window.confirm(labels.removeConfirm(document.filename))) void handleRemove([document.id])
448
452
  }}
449
453
  disabled={busy}
450
- title={labels.remove}
454
+ data-cv-tooltip={labels.remove}
451
455
  aria-label={`${labels.remove}: ${document.filename}`}
452
456
  className="cv-header-icon disabled:opacity-40"
453
457
  >
@@ -21,7 +21,7 @@ export function FlowGroupHeader({ data }: NodeProps) {
21
21
  <button
22
22
  type="button"
23
23
  onClick={onFocus}
24
- title={labels.flowGroup.focus}
24
+ data-cv-tooltip={labels.flowGroup.focus} aria-label={labels.flowGroup.focus}
25
25
  className="hover:text-blue-600 dark:hover:text-blue-400"
26
26
  >
27
27
  <Maximize2 size={12} />
@@ -29,7 +29,7 @@ export function FlowGroupHeader({ data }: NodeProps) {
29
29
  <button
30
30
  type="button"
31
31
  onClick={onClose}
32
- title={labels.flowGroup.close}
32
+ data-cv-tooltip={labels.flowGroup.close} aria-label={labels.flowGroup.close}
33
33
  className="hover:text-red-600 dark:hover:text-red-400"
34
34
  >
35
35
  <X size={12} />
@@ -22,11 +22,12 @@ export function FlowMapNode({ data }: NodeProps) {
22
22
  <GitBranch size={14} />
23
23
  <span className="text-sm font-semibold truncate">{label}</span>
24
24
  {isRoot && (
25
- <span className="h-1.5 w-1.5 rounded-full bg-emerald-500 shrink-0" title={labels.startNodeTooltip} />
25
+ <span className="h-1.5 w-1.5 rounded-full bg-emerald-500 shrink-0" data-cv-tooltip={labels.startNodeTooltip} />
26
26
  )}
27
27
  </div>
28
28
  <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{labels.flowMap.nodeCount(nodeCount)}</p>
29
29
  <button
30
+ data-cv-tooltip={labels.flowMap.openFlow} aria-label={labels.flowMap.openFlow}
30
31
  type="button"
31
32
  onClick={onOpen}
32
33
  className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-blue-600 dark:text-blue-400 hover:underline"
@@ -135,7 +135,7 @@ export function FlowNodeCard({ data }: NodeProps) {
135
135
 
136
136
  return (
137
137
  <div
138
- title={isDetached ? labels.detachedNodeTooltip : label}
138
+ data-cv-tooltip={isDetached ? labels.detachedNodeTooltip : label}
139
139
  className={`relative rounded-lg border-2 px-3 py-2 w-60 cursor-pointer shadow-sm hover:shadow-md transition-shadow ${NODE_TYPE_COLOR[node.type]} ${isSelected ? 'ring-2 ring-blue-500 ring-offset-2 dark:ring-offset-gray-900' : ''} ${isDetached ? 'border-dashed !border-amber-400 animate-pulse' : ''}`}
140
140
  onClick={() => onSelect(node.id)}
141
141
  >
@@ -144,7 +144,7 @@ export function FlowNodeCard({ data }: NodeProps) {
144
144
  <div className="flex items-center justify-between gap-2">
145
145
  <span className="flex items-center gap-1.5 uppercase tracking-wide font-semibold text-gray-500 dark:text-gray-400 text-xs">
146
146
  <Icon size={12} strokeWidth={2.5} />
147
- {isStart && <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" title={labels.startNodeTooltip} />}
147
+ {isStart && <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" data-cv-tooltip={labels.startNodeTooltip} />}
148
148
  {labels.legend[node.type]}
149
149
  </span>
150
150
  <div className="flex items-center gap-1">
@@ -152,7 +152,7 @@ export function FlowNodeCard({ data }: NodeProps) {
152
152
  {hasWarning && <AlertTriangle size={13} className="text-amber-500 dark:text-amber-400" />}
153
153
  {liveCount > 0 && (
154
154
  <span
155
- title={labels.liveCountTooltip(liveCount)}
155
+ data-cv-tooltip={labels.liveCountTooltip(liveCount)}
156
156
  className="font-bold bg-blue-600 text-white rounded-full px-1.5 py-0.5 text-xs"
157
157
  >
158
158
  {liveCount}
@@ -181,7 +181,7 @@ export function FlowNodePanel({
181
181
  <div className="fixed inset-y-0 right-0 w-96 bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 shadow-xl z-50 flex flex-col">
182
182
  <div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-700">
183
183
  <h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{labels.nodePanel.title}</h3>
184
- <button onClick={onClose} className="text-gray-400 hover:text-gray-600">
184
+ <button data-cv-tooltip={labels.nodePanel.close} aria-label={labels.nodePanel.close} onClick={onClose} className="text-gray-400 hover:text-gray-600">
185
185
  <X size={18} />
186
186
  </button>
187
187
  </div>
@@ -358,7 +358,7 @@ export function FlowNodePanel({
358
358
  <div>
359
359
  <div className="flex items-center justify-between mb-1.5">
360
360
  <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.nodePanel.options}</label>
361
- <button onClick={addOption} className="text-xs text-blue-600 hover:underline flex items-center gap-1">
361
+ <button data-cv-tooltip={labels.nodePanel.addOption} aria-label={labels.nodePanel.addOption} onClick={addOption} className="text-xs text-blue-600 hover:underline flex items-center gap-1">
362
362
  <Plus size={12} /> {labels.nodePanel.addOption}
363
363
  </button>
364
364
  </div>
@@ -377,7 +377,7 @@ export function FlowNodePanel({
377
377
  placeholder={labels.nodePanel.optionLabel}
378
378
  className={`flex-1 ${INPUT_CLASSNAME}`}
379
379
  />
380
- <button onClick={() => removeOption(i)} className="text-gray-400 hover:text-red-600">
380
+ <button data-cv-tooltip={labels.nodePanel.removeOption} aria-label={labels.nodePanel.removeOption} onClick={() => removeOption(i)} className="text-gray-400 hover:text-red-600">
381
381
  <Trash2 size={14} />
382
382
  </button>
383
383
  </div>
@@ -441,6 +441,7 @@ export function FlowNodePanel({
441
441
 
442
442
  <div className="p-4 border-t border-gray-100 dark:border-gray-700 flex gap-2">
443
443
  <button
444
+ data-cv-tooltip={labels.nodePanel.save} aria-label={labels.nodePanel.save}
444
445
  onClick={() => onChange(draft)}
445
446
  className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
446
447
  >
@@ -451,13 +452,13 @@ export function FlowNodePanel({
451
452
  onClick={() => {
452
453
  if (window.confirm(labels.nodePanel.deleteConfirm)) onDelete(node.id)
453
454
  }}
454
- title={labels.nodePanel.delete}
455
+ data-cv-tooltip={labels.nodePanel.delete} aria-label={labels.nodePanel.delete}
455
456
  className="px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg"
456
457
  >
457
458
  <Trash2 size={14} />
458
459
  </button>
459
460
  )}
460
- <button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 dark:text-gray-300 hover:underline">
461
+ <button data-cv-tooltip={labels.nodePanel.cancel} aria-label={labels.nodePanel.cancel} onClick={onClose} className="px-4 py-2 text-sm text-gray-600 dark:text-gray-300 hover:underline">
461
462
  {labels.nodePanel.cancel}
462
463
  </button>
463
464
  </div>
@@ -58,6 +58,7 @@ export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: Fl
58
58
  return (
59
59
  <div ref={containerRef} className="relative">
60
60
  <button
61
+ data-cv-tooltip={labels.palette.title} aria-label={labels.palette.title}
61
62
  onClick={() => setOpen((v) => !v)}
62
63
  className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
63
64
  >
@@ -68,6 +69,7 @@ export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: Fl
68
69
  <div className="absolute left-0 top-full mt-1.5 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg z-50 py-1">
69
70
  <div className="relative">
70
71
  <button
72
+ data-cv-tooltip={labels.palette.question} aria-label={labels.palette.question}
71
73
  onMouseEnter={() => setSubmenu('question')}
72
74
  onClick={() => setSubmenu(submenu === 'question' ? null : 'question')}
73
75
  className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
@@ -81,6 +83,7 @@ export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: Fl
81
83
  <div className="absolute left-full top-0 ml-1 w-56 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">
82
84
  {QUESTION_TYPES.map((qt) => (
83
85
  <button
86
+ data-cv-tooltip={labels.questionTypeLabels[qt]} aria-label={labels.questionTypeLabels[qt]}
84
87
  key={qt}
85
88
  onClick={() => select({ kind: 'question', questionType: qt })}
86
89
  className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
@@ -93,6 +96,7 @@ export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: Fl
93
96
  </div>
94
97
 
95
98
  <button
99
+ data-cv-tooltip={labels.palette.decision} aria-label={labels.palette.decision}
96
100
  onMouseEnter={() => setSubmenu(null)}
97
101
  onClick={() => select({ kind: 'decision' })}
98
102
  className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
@@ -104,13 +108,14 @@ export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: Fl
104
108
  onMouseEnter={() => setSubmenu(null)}
105
109
  onClick={() => select({ kind: 'condition' })}
106
110
  className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
107
- title={labels.palette.conditionHint}
111
+ data-cv-tooltip={labels.palette.conditionHint} aria-label={labels.palette.conditionHint}
108
112
  >
109
113
  <Diamond size={15} className="text-cyan-500" /> {labels.palette.condition}
110
114
  </button>
111
115
 
112
116
  <div className="relative">
113
117
  <button
118
+ data-cv-tooltip={labels.palette.action} aria-label={labels.palette.action}
114
119
  onMouseEnter={() => setSubmenu('action')}
115
120
  onClick={() => setSubmenu(submenu === 'action' ? null : 'action')}
116
121
  className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
@@ -124,6 +129,7 @@ export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: Fl
124
129
  <div className="absolute left-full top-0 ml-1 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">
125
130
  {resolvedActionOptions.map((option) => (
126
131
  <button
132
+ data-cv-tooltip={option.label} aria-label={option.label}
127
133
  key={option.actionKind}
128
134
  onClick={() => select({ kind: 'action', actionKind: option.actionKind })}
129
135
  className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
@@ -16,7 +16,7 @@ export function FlowPortalNode({ data }: NodeProps) {
16
16
  return (
17
17
  <button
18
18
  type="button"
19
- title={labels.crossFlowPortal.tooltip}
19
+ data-cv-tooltip={labels.crossFlowPortal.tooltip} aria-label={labels.crossFlowPortal.tooltip}
20
20
  onClick={onNavigate}
21
21
  className="relative flex items-center gap-1.5 rounded-full border-2 border-dashed border-cyan-400 dark:border-cyan-600 bg-cyan-50 dark:bg-cyan-950/40 px-3 py-1.5 text-xs font-medium text-cyan-700 dark:text-cyan-300 hover:bg-cyan-100 dark:hover:bg-cyan-950/70 transition-colors cursor-pointer"
22
22
  >
@@ -40,6 +40,7 @@ import {
40
40
  type FlowNodeData,
41
41
  type GraphIssue,
42
42
  } from './flowGraph'
43
+ import { TooltipLayer } from '../Tooltip'
43
44
 
44
45
  const RF_NODE_TYPES = {
45
46
  ...flowNodeTypes,
@@ -966,6 +967,7 @@ export function FlowsWorkspace({
966
967
 
967
968
  return (
968
969
  <div className={`space-y-4 h-full flex flex-col ${className ?? ''}`}>
970
+ <TooltipLayer />
969
971
  <div className="flex items-center justify-between flex-wrap gap-3">
970
972
  <div>
971
973
  <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{labels.workspace.title}</h2>
@@ -977,6 +979,7 @@ export function FlowsWorkspace({
977
979
  <span className="text-sm text-red-600">{saveErrorMessage ?? labels.workspace.saveError}</span>
978
980
  )}
979
981
  <button
982
+ data-cv-tooltip={viewMode === 'map' ? labels.flowMap.toggleToDetail : labels.flowMap.toggleToMap} aria-label={viewMode === 'map' ? labels.flowMap.toggleToDetail : labels.flowMap.toggleToMap}
980
983
  type="button"
981
984
  onClick={() => setViewMode((mode) => (mode === 'map' ? 'detail' : 'map'))}
982
985
  className={OUTLINE_BUTTON}
@@ -990,7 +993,7 @@ export function FlowsWorkspace({
990
993
  type="button"
991
994
  onClick={handleOrganize}
992
995
  className={OUTLINE_BUTTON}
993
- title={labels.workspace.organizeTooltip}
996
+ data-cv-tooltip={labels.workspace.organizeTooltip} aria-label={labels.workspace.organizeTooltip}
994
997
  disabled={!primaryGraph}
995
998
  >
996
999
  <LayoutGrid size={14} aria-hidden="true" /> {labels.workspace.organize}
@@ -999,12 +1002,13 @@ export function FlowsWorkspace({
999
1002
  type="button"
1000
1003
  onClick={handleDiscardChanges}
1001
1004
  className={OUTLINE_BUTTON}
1002
- title={labels.workspace.discardTooltip}
1005
+ data-cv-tooltip={labels.workspace.discardTooltip} aria-label={labels.workspace.discardTooltip}
1003
1006
  disabled={!isDirty || saveState === 'saving'}
1004
1007
  >
1005
1008
  <Undo2 size={14} aria-hidden="true" /> {labels.workspace.discardChanges}
1006
1009
  </button>
1007
1010
  <button
1011
+ data-cv-tooltip={labels.workspace.saveGraph} aria-label={labels.workspace.saveGraph}
1008
1012
  type="button"
1009
1013
  onClick={() => void handlePublish()}
1010
1014
  className={PRIMARY_BUTTON}
@@ -1024,6 +1028,7 @@ export function FlowsWorkspace({
1024
1028
  {graphs &&
1025
1029
  Object.values(graphs).map((graph) => (
1026
1030
  <button
1031
+ data-cv-tooltip={graph.label} aria-label={graph.label}
1027
1032
  key={graph.key}
1028
1033
  type="button"
1029
1034
  onClick={() => focusFlow(graph.key)}
@@ -1038,6 +1043,7 @@ export function FlowsWorkspace({
1038
1043
  ))}
1039
1044
  {canCreateFlow && (
1040
1045
  <button
1046
+ data-cv-tooltip={labels.flowManager.newFlow} aria-label={labels.flowManager.newFlow}
1041
1047
  type="button"
1042
1048
  onClick={() => {
1043
1049
  setFlowMutationState({ pending: false })
@@ -1059,6 +1065,7 @@ export function FlowsWorkspace({
1059
1065
  )}
1060
1066
  {primaryGraph && canDeleteFlow && (
1061
1067
  <button
1068
+ data-cv-tooltip={labels.flowManager.deleteFlow} aria-label={labels.flowManager.deleteFlow}
1062
1069
  type="button"
1063
1070
  onClick={() => {
1064
1071
  setFlowMutationState({ pending: false })
@@ -1186,10 +1193,11 @@ export function FlowsWorkspace({
1186
1193
  {flowMutationState.error && <p className="text-xs text-red-600">{flowMutationState.error}</p>}
1187
1194
  </div>
1188
1195
  <div className="flex justify-end gap-2 mt-4">
1189
- <button type="button" className={OUTLINE_BUTTON} onClick={() => setShowCreateDialog(false)}>
1196
+ <button data-cv-tooltip={labels.nodePanel.cancel} aria-label={labels.nodePanel.cancel} type="button" className={OUTLINE_BUTTON} onClick={() => setShowCreateDialog(false)}>
1190
1197
  {labels.nodePanel.cancel}
1191
1198
  </button>
1192
1199
  <button
1200
+ data-cv-tooltip={labels.flowManager.create} aria-label={labels.flowManager.create}
1193
1201
  type="button"
1194
1202
  className={PRIMARY_BUTTON}
1195
1203
  onClick={() => void handleCreateFlow()}
@@ -1208,10 +1216,11 @@ export function FlowsWorkspace({
1208
1216
  </p>
1209
1217
  {flowMutationState.error && <p className="text-xs text-red-600 mt-2">{flowMutationState.error}</p>}
1210
1218
  <div className="flex justify-end gap-2 mt-4">
1211
- <button type="button" className={OUTLINE_BUTTON} onClick={() => setShowDeleteDialog(false)}>
1219
+ <button data-cv-tooltip={labels.nodePanel.cancel} aria-label={labels.nodePanel.cancel} type="button" className={OUTLINE_BUTTON} onClick={() => setShowDeleteDialog(false)}>
1212
1220
  {labels.nodePanel.cancel}
1213
1221
  </button>
1214
1222
  <button
1223
+ data-cv-tooltip={labels.flowManager.deleteFlow} aria-label={labels.flowManager.deleteFlow}
1215
1224
  type="button"
1216
1225
  className="inline-flex items-center gap-1.5 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-700 disabled:opacity-40"
1217
1226
  onClick={() => void handleDeleteFlow()}