@adatechnology/conversations-ui 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +221 -19
  2. package/dist/{ConversationSimulatorPanel-CrfFjiWh.d.ts → ConversationSimulatorPanel-q-wVgBCN.d.ts} +1 -1
  3. package/dist/{chunk-HOKNKXIT.js → chunk-HMCOTZAX.js} +1 -1
  4. package/dist/{chunk-FAQLC3TX.js → chunk-L3OFAQPS.js} +2 -4
  5. package/dist/{chunk-CBYEHPX6.js → chunk-PJNR6UMN.js} +63 -1
  6. package/dist/flows/index.js +3 -5
  7. package/dist/index.d.ts +4 -4
  8. package/dist/index.js +834 -764
  9. package/dist/participant/index.d.ts +62 -3
  10. package/dist/participant/index.js +1444 -394
  11. package/dist/preview/index.d.ts +3 -3
  12. package/dist/preview/index.js +2 -3
  13. package/dist/{quickReply.types-B4gmZsSr.d.ts → quickReply.types-DzWH3NYV.d.ts} +1 -1
  14. package/dist/styles.css +629 -20
  15. package/package.json +3 -3
  16. package/src/participant/ConversationThread.test.tsx +165 -0
  17. package/src/participant/ConversationThread.tsx +117 -0
  18. package/src/participant/ParticipantAuthorAvatar.test.tsx +30 -0
  19. package/src/participant/ParticipantAuthorAvatar.tsx +24 -0
  20. package/src/participant/ParticipantBackButton.tsx +14 -0
  21. package/src/participant/ParticipantBubbleStatus.tsx +67 -0
  22. package/src/participant/ParticipantChannelBadges.tsx +13 -4
  23. package/src/participant/ParticipantComposer.tsx +8 -40
  24. package/src/participant/ParticipantComposerControls.tsx +83 -0
  25. package/src/participant/ParticipantConversationAvatar.tsx +13 -0
  26. package/src/participant/ParticipantConversations.tsx +7 -0
  27. package/src/participant/ParticipantConversationsOptions.test.tsx +87 -0
  28. package/src/participant/ParticipantCopyAnnouncer.tsx +48 -0
  29. package/src/participant/ParticipantCopyableToken.tsx +51 -0
  30. package/src/participant/ParticipantDayDivider.tsx +18 -0
  31. package/src/participant/ParticipantInbox.tsx +3 -1
  32. package/src/participant/ParticipantInboxRow.tsx +6 -4
  33. package/src/participant/ParticipantMessageBubble.test.tsx +52 -5
  34. package/src/participant/ParticipantMessageBubble.tsx +32 -84
  35. package/src/participant/ParticipantMessageNodes.tsx +54 -0
  36. package/src/participant/ParticipantMessageText.test.tsx +106 -0
  37. package/src/participant/ParticipantMessageText.tsx +52 -0
  38. package/src/participant/ParticipantProtocolBadge.tsx +15 -7
  39. package/src/participant/ParticipantProtocolView.test.tsx +4 -6
  40. package/src/participant/ParticipantScrollToLatest.test.tsx +153 -0
  41. package/src/participant/ParticipantScrollToLatest.tsx +40 -0
  42. package/src/participant/ParticipantThread.test.tsx +37 -0
  43. package/src/participant/ParticipantThread.tsx +54 -95
  44. package/src/participant/ParticipantThreadAvatarSequence.test.tsx +65 -0
  45. package/src/participant/ParticipantThreadHeader.test.tsx +181 -0
  46. package/src/participant/ParticipantThreadHeader.tsx +83 -0
  47. package/src/participant/ParticipantThreadScreen.tsx +30 -56
  48. package/src/participant/ParticipantTimeline.tsx +106 -0
  49. package/src/participant/conversationThreadApi.test.ts +27 -0
  50. package/src/participant/conversationThreadApi.ts +24 -0
  51. package/src/participant/conversationThreadCss.test.ts +116 -0
  52. package/src/participant/conversationThreadSummary.ts +34 -0
  53. package/src/participant/index.ts +4 -0
  54. package/src/participant/participantAutoGrow.test.ts +43 -0
  55. package/src/participant/participantAutoGrow.ts +12 -0
  56. package/src/participant/participantAvatar.test.ts +74 -0
  57. package/src/participant/participantAvatar.ts +48 -0
  58. package/src/participant/participantBubbleTail.test.tsx +107 -0
  59. package/src/participant/participantChatDesign.test.tsx +198 -0
  60. package/src/participant/participantContrast.test-helper.ts +116 -0
  61. package/src/participant/participantCopyAnnouncement.test.ts +52 -0
  62. package/src/participant/participantCopyAnnouncement.ts +37 -0
  63. package/src/participant/participantCss.test-helper.ts +34 -0
  64. package/src/participant/participantDay.test.ts +22 -0
  65. package/src/participant/participantDay.ts +8 -0
  66. package/src/participant/participantDocuments.ts +45 -0
  67. package/src/participant/participantGolden.test.tsx +46 -0
  68. package/src/participant/participantGoldenScenarios.test-helper.tsx +57 -0
  69. package/src/participant/participantHeaderA11y.test.tsx +85 -0
  70. package/src/participant/participantHeaderDesign.test.tsx +111 -0
  71. package/src/participant/participantLabels.ts +11 -0
  72. package/src/participant/participantMessageAtoms.ts +53 -0
  73. package/src/participant/participantMessageCopyables.test.ts +141 -0
  74. package/src/participant/participantMessageCopyables.ts +76 -0
  75. package/src/participant/participantMessageFormat.test-helper.ts +21 -0
  76. package/src/participant/participantMessageFormat.test.ts +177 -0
  77. package/src/participant/participantMessageFormat.ts +135 -0
  78. package/src/participant/participantMessageFormat.types.ts +22 -0
  79. package/src/participant/participantMessageInline.test.ts +82 -0
  80. package/src/participant/participantMessageInline.ts +99 -0
  81. package/src/participant/participantMessageLinks.test.ts +100 -0
  82. package/src/participant/participantMessageLinks.ts +54 -0
  83. package/src/participant/participantMessageTextStyle.test.ts +137 -0
  84. package/src/participant/participantMessages.ts +4 -3
  85. package/src/participant/participantNewMessages.test.ts +21 -0
  86. package/src/participant/participantNewMessages.ts +8 -3
  87. package/src/participant/participantPerspective.test.ts +67 -0
  88. package/src/participant/participantPerspective.ts +50 -0
  89. package/src/participant/participantPerspectiveRender.test.tsx +94 -0
  90. package/src/participant/participantScrollFocus.test.ts +22 -0
  91. package/src/participant/participantScrollFocus.ts +10 -0
  92. package/src/participant/participantScrollView.test.ts +74 -0
  93. package/src/participant/participantScrollView.ts +38 -0
  94. package/src/participant/participantScrollbars.test.ts +156 -0
  95. package/src/participant/participantSubjectIcon.test.tsx +27 -0
  96. package/src/participant/participantSubjectIcon.ts +26 -0
  97. package/src/participant/participantThemeContrast.test.ts +98 -0
  98. package/src/participant/test-fixtures/conversations-list.golden.html +1 -0
  99. package/src/participant/test-fixtures/conversations-selected.golden.html +1 -0
  100. package/src/participant/test-fixtures/thread-avatars-initials.golden.html +1 -0
  101. package/src/participant/test-fixtures/thread-closed.golden.html +1 -0
  102. package/src/participant/test-fixtures/thread-default.golden.html +1 -0
  103. package/src/participant/test-fixtures/thread-error.golden.html +1 -0
  104. package/src/participant/test-fixtures/thread-loading.golden.html +1 -0
  105. package/src/participant/test-fixtures/thread-plain.golden.html +1 -0
  106. package/src/participant/test-fixtures/thread-tail-off.golden.html +1 -0
  107. package/src/participant/useParticipantConversationView.ts +3 -0
  108. package/src/participant/useParticipantCopyFeedback.ts +33 -0
  109. package/src/participant/useParticipantThread.ts +13 -2
  110. package/src/participant/useParticipantThreadController.ts +112 -0
  111. package/src/participant/useParticipantThreadScroll.ts +13 -3
  112. package/src/participant/useParticipantUnread.test.tsx +40 -0
  113. package/src/participant/useParticipantUnread.ts +38 -0
  114. package/src/participant/useRevealActiveChip.ts +20 -0
  115. package/src/participant/useScrollAwayState.ts +24 -0
  116. package/src/participant/useStickToBottom.ts +16 -2
  117. package/src/styles.css +151 -23
  118. package/dist/chunk-MRVFFJEH.js +0 -64
  119. package/dist/chunk-OVFCLRQQ.js +0 -84
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { buildConversation, buildMessage } from './participantFixtures.test-helper'
5
+ import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
6
+ import type { ParticipantTimelineItem } from './participantMessages'
7
+ import { ParticipantThread, type ParticipantThreadProps } from './ParticipantThread'
8
+
9
+ const ITEMS: readonly ParticipantTimelineItem[] = [
10
+ {
11
+ kind: 'server',
12
+ message: buildMessage({
13
+ id: 'a',
14
+ direction: 'inbound',
15
+ authorName: 'Ana Souza',
16
+ createdAt: '2026-10-01T10:00:00.000Z',
17
+ }),
18
+ },
19
+ {
20
+ kind: 'server',
21
+ message: buildMessage({ id: 'b', direction: 'outbound', status: 'read', createdAt: '2026-10-01T10:05:00.000Z' }),
22
+ },
23
+ {
24
+ kind: 'pending',
25
+ origin: 'local',
26
+ displayState: 'queued',
27
+ pending: {
28
+ clientMessageId: 'c',
29
+ subject: { subjectType: 'invoice', subjectId: '1' },
30
+ text: 'Waiting',
31
+ createdAt: '2026-10-01T10:06:00.000Z',
32
+ state: 'queued',
33
+ },
34
+ },
35
+ ]
36
+
37
+ function render(overrides: Partial<ParticipantThreadProps> = {}): string {
38
+ return renderToStaticMarkup(
39
+ <ParticipantThread
40
+ conversation={buildConversation({ subjectId: '1', subjectLabel: 'Invoice 4521' })}
41
+ items={ITEMS}
42
+ hasMore={false}
43
+ labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
44
+ resolveAttachmentUrl={async () => 'https://files.example/a'}
45
+ draft={{ value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined }}
46
+ onSend={() => undefined}
47
+ status="ready"
48
+ refresh={() => undefined}
49
+ scroll={{ ref: { current: null }, onScroll: () => undefined }}
50
+ newMessagesCount={0}
51
+ isSending={false}
52
+ {...overrides}
53
+ />,
54
+ )
55
+ }
56
+
57
+ function mineFlags(markup: string): boolean[] {
58
+ return markup
59
+ .split('<div class="cv-p-bubble')
60
+ .slice(1)
61
+ .map((bubble) => bubble.startsWith(' cv-p-bubble--mine'))
62
+ }
63
+
64
+ describe('ParticipantThread perspective', () => {
65
+ it('is untouched without the new props: the participant owns the inbound messages', () => {
66
+ expect(mineFlags(render())).toEqual([true, false, true])
67
+ expect(render({ perspective: 'participant' })).toBe(render())
68
+ })
69
+
70
+ it('draws none of the new header parts without the new props', () => {
71
+ const markup = render()
72
+ expect(markup).not.toContain('cv-p-thread__actions')
73
+ expect(markup).not.toContain('cv-p-thread__counterpart')
74
+ })
75
+
76
+ it('inverts which bubble is mine for the operator, keeping the own local pending message', () => {
77
+ expect(mineFlags(render({ perspective: 'operator' }))).toEqual([false, true, true])
78
+ })
79
+
80
+ it('moves the ticks to the operator side', () => {
81
+ const participant = render()
82
+ const operator = render({ perspective: 'operator' })
83
+ expect(participant).not.toContain('cv-status-ticks--read')
84
+ expect(operator).toContain('cv-status-ticks--read')
85
+ expect(operator).toContain('cv-status-ticks--queued')
86
+ })
87
+
88
+ it('puts the avatar on the counterpart, not on the operator', () => {
89
+ const participant = render({ avatars: 'initials' })
90
+ const operator = render({ perspective: 'operator', avatars: 'initials' })
91
+ expect(participant).not.toContain('>AS<')
92
+ expect(operator).toContain('class="cv-p-avatar" aria-hidden="true">AS<')
93
+ })
94
+ })
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { SCROLLER_SELECTOR, focusScrollerOf } from './participantScrollFocus'
4
+
5
+ describe('focusScrollerOf', () => {
6
+ it('moves focus to the scroller ancestor without scrolling it', () => {
7
+ const calls: unknown[] = []
8
+ const selectors: string[] = []
9
+ focusScrollerOf({
10
+ closest: (selector) => {
11
+ selectors.push(selector)
12
+ return { focus: (options) => void calls.push(options) }
13
+ },
14
+ })
15
+ expect(selectors).toEqual([SCROLLER_SELECTOR])
16
+ expect(calls).toEqual([{ preventScroll: true }])
17
+ })
18
+
19
+ it('does nothing when there is no scroller', () => {
20
+ expect(() => focusScrollerOf({ closest: () => null })).not.toThrow()
21
+ })
22
+ })
@@ -0,0 +1,10 @@
1
+ export type FocusableAncestorSource = {
2
+ readonly closest: (selector: string) => { readonly focus: (options: { preventScroll: boolean }) => void } | null
3
+ }
4
+
5
+ export const SCROLLER_SELECTOR = '.cv-p-thread__scroll'
6
+
7
+ /** The button unmounts on click; moving focus first keeps it from falling to the body. */
8
+ export function focusScrollerOf(button: FocusableAncestorSource): void {
9
+ button.closest(SCROLLER_SELECTOR)?.focus({ preventScroll: true })
10
+ }
@@ -0,0 +1,74 @@
1
+ import { readFileSync, readdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { describe, expect, it } from 'bun:test'
4
+
5
+ import { resolveScrollToLatestView, scrollIntoViewOptions, scrollToLatestOptions } from './participantScrollView'
6
+
7
+ describe('resolveScrollToLatestView', () => {
8
+ it('is hidden while the reader is near the end', () => {
9
+ expect(resolveScrollToLatestView({ isAwayFromBottom: false, newMessagesCount: 0, hasMessages: true })).toEqual({
10
+ isVisible: false,
11
+ badge: undefined,
12
+ })
13
+ })
14
+
15
+ it('is hidden when there is nothing to scroll to', () => {
16
+ expect(
17
+ resolveScrollToLatestView({ isAwayFromBottom: true, newMessagesCount: 0, hasMessages: false }).isVisible,
18
+ ).toBe(false)
19
+ })
20
+
21
+ it('is visible without a badge when away and nothing new arrived', () => {
22
+ expect(resolveScrollToLatestView({ isAwayFromBottom: true, newMessagesCount: 0, hasMessages: true })).toEqual({
23
+ isVisible: true,
24
+ badge: undefined,
25
+ })
26
+ })
27
+
28
+ it('carries the count of new incoming messages as the badge', () => {
29
+ expect(resolveScrollToLatestView({ isAwayFromBottom: true, newMessagesCount: 3, hasMessages: true }).badge).toBe(
30
+ '3',
31
+ )
32
+ })
33
+
34
+ it('caps a large count', () => {
35
+ expect(resolveScrollToLatestView({ isAwayFromBottom: true, newMessagesCount: 100, hasMessages: true }).badge).toBe(
36
+ '99+',
37
+ )
38
+ expect(resolveScrollToLatestView({ isAwayFromBottom: true, newMessagesCount: 99, hasMessages: true }).badge).toBe(
39
+ '99',
40
+ )
41
+ })
42
+ })
43
+
44
+ describe('scroll options', () => {
45
+ it('scrolls to the end of the list, smooth only without reduced motion', () => {
46
+ expect(scrollToLatestOptions({ scrollHeight: 900, isReducedMotion: false })).toEqual({
47
+ top: 900,
48
+ behavior: 'smooth',
49
+ })
50
+ expect(scrollToLatestOptions({ scrollHeight: 900, isReducedMotion: true })).toEqual({ top: 900, behavior: 'auto' })
51
+ })
52
+
53
+ it('reveals the active chip with the nearest rule and no animation under reduced motion', () => {
54
+ expect(scrollIntoViewOptions(false)).toEqual({ block: 'nearest', inline: 'nearest', behavior: 'smooth' })
55
+ expect(scrollIntoViewOptions(true)).toEqual({ block: 'nearest', inline: 'nearest', behavior: 'auto' })
56
+ })
57
+ })
58
+
59
+ describe('the near-the-end threshold has one owner', () => {
60
+ it('is declared only in participantScroll.ts', () => {
61
+ const sources = readdirSync(import.meta.dir).filter(
62
+ (name) => /\.tsx?$/.test(name) && !/\.test(-helper)?\.tsx?$/.test(name),
63
+ )
64
+ const declaring = sources.filter((name) =>
65
+ /NEAR_BOTTOM_THRESHOLD_PX\s*=/.test(readFileSync(join(import.meta.dir, name), 'utf8')),
66
+ )
67
+ expect(declaring).toEqual(['participantScroll.ts'])
68
+ })
69
+
70
+ it('is not re-derived by the scroll-to-latest rule', () => {
71
+ const source = readFileSync(join(import.meta.dir, 'participantScrollView.ts'), 'utf8')
72
+ expect(source).not.toMatch(/scrollTop|clientHeight|threshold/i)
73
+ })
74
+ })
@@ -0,0 +1,38 @@
1
+ const BADGE_CAP = 99
2
+
3
+ export type ScrollToLatestViewParams = {
4
+ /** Already decided by shouldStickToBottom; this rule never looks at pixels. */
5
+ readonly isAwayFromBottom: boolean
6
+ readonly newMessagesCount: number
7
+ readonly hasMessages: boolean
8
+ }
9
+
10
+ export type ScrollToLatestView = {
11
+ readonly isVisible: boolean
12
+ readonly badge: string | undefined
13
+ }
14
+
15
+ export type ScrollToLatestOptionsParams = {
16
+ readonly scrollHeight: number
17
+ readonly isReducedMotion: boolean
18
+ }
19
+
20
+ export function resolveScrollToLatestView(params: ScrollToLatestViewParams): ScrollToLatestView {
21
+ const { isAwayFromBottom, newMessagesCount, hasMessages } = params
22
+ if (!isAwayFromBottom || !hasMessages) return { isVisible: false, badge: undefined }
23
+ if (newMessagesCount <= 0) return { isVisible: true, badge: undefined }
24
+ return { isVisible: true, badge: newMessagesCount > BADGE_CAP ? `${BADGE_CAP}+` : String(newMessagesCount) }
25
+ }
26
+
27
+ export function scrollToLatestOptions(params: ScrollToLatestOptionsParams): ScrollToOptions {
28
+ return { top: params.scrollHeight, behavior: params.isReducedMotion ? 'auto' : 'smooth' }
29
+ }
30
+
31
+ export function scrollIntoViewOptions(isReducedMotion: boolean): ScrollIntoViewOptions {
32
+ return { block: 'nearest', inline: 'nearest', behavior: isReducedMotion ? 'auto' : 'smooth' }
33
+ }
34
+
35
+ export function readPrefersReducedMotion(): boolean {
36
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
37
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches
38
+ }
@@ -0,0 +1,156 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { PARTICIPANT_CSS, declarationsIn, mediaBody, toRem } from './participantCss.test-helper'
4
+
5
+ const VERTICAL_AREAS = ['.cv-p-thread__scroll', '.cv-p-inbox', '.cv-p-composer__input']
6
+ const HORIZONTAL_STRIPS = ['.cv-p-filters', '.cv-p-quick']
7
+ const NO_PREFERENCE = '(prefers-reduced-motion: no-preference)'
8
+ const FORCED_COLORS = '(forced-colors: active)'
9
+ const WEBKIT_GUARD = 'not (forced-colors: active)'
10
+
11
+ function webkitRules(selector: string): Map<string, string> {
12
+ return declarationsIn(`${selector}::-webkit-scrollbar`, mediaBody(WEBKIT_GUARD))
13
+ }
14
+
15
+ describe.each(VERTICAL_AREAS)('vertical scroller %s', (selector) => {
16
+ const declarations = declarationsIn(selector)
17
+
18
+ it('has a thin themed scrollbar from the tokens', () => {
19
+ expect(declarations.get('scrollbar-width')).toBe('thin')
20
+ expect(declarations.get('scrollbar-color')).toBe('var(--cv-p-i-scrollbar-thumb) transparent')
21
+ })
22
+
23
+ it('does not chain the scroll to the page', () => {
24
+ expect(declarations.get('overscroll-behavior-y')).toBe('contain')
25
+ })
26
+
27
+ it('has a 6px track-less webkit scrollbar with a rounded thumb and no arrows', () => {
28
+ expect(webkitRules(selector).get('width')).toBe('6px')
29
+ expect(webkitRules(selector).get('height')).toBe('6px')
30
+ const track = declarationsIn(`${selector}::-webkit-scrollbar-track`, mediaBody(WEBKIT_GUARD))
31
+ expect(track.get('background')).toBe('transparent')
32
+ const thumb = declarationsIn(`${selector}::-webkit-scrollbar-thumb`, mediaBody(WEBKIT_GUARD))
33
+ expect(thumb.get('background-color')).toBe('var(--cv-p-i-scrollbar-thumb)')
34
+ expect(thumb.get('border-radius')).toBe('var(--cv-p-i-radius)')
35
+ const buttons = declarationsIn(`${selector}::-webkit-scrollbar-button`, mediaBody(WEBKIT_GUARD))
36
+ expect(buttons.get('display')).toBe('none')
37
+ })
38
+
39
+ it('highlights the thumb with the accent on hover and drag', () => {
40
+ const hover = declarationsIn(`${selector}::-webkit-scrollbar-thumb:hover`, mediaBody(WEBKIT_GUARD))
41
+ const active = declarationsIn(`${selector}::-webkit-scrollbar-thumb:active`, mediaBody(WEBKIT_GUARD))
42
+ expect(hover.get('background-color')).toBe('var(--cv-p-i-scrollbar-thumb-hover)')
43
+ expect(active.get('background-color')).toBe('var(--cv-p-i-scrollbar-thumb-hover)')
44
+ })
45
+
46
+ it('goes back to the system scrollbar under forced colors', () => {
47
+ const forced = declarationsIn(selector, mediaBody(FORCED_COLORS))
48
+ expect(forced.get('scrollbar-color')).toBe('auto')
49
+ expect(forced.get('scrollbar-width')).toBe('auto')
50
+ })
51
+ })
52
+
53
+ describe('scrollbar tokens', () => {
54
+ const root = declarationsIn('.cv-p')
55
+
56
+ it('derive from the existing tokens and are overridable', () => {
57
+ expect(root.get('--cv-p-i-scrollbar-thumb')).toBe('var(--cv-p-scrollbar-thumb, var(--cv-p-i-border))')
58
+ expect(root.get('--cv-p-i-scrollbar-thumb-hover')).toBe('var(--cv-p-scrollbar-thumb-hover, var(--cv-p-i-accent))')
59
+ })
60
+ })
61
+
62
+ describe('thread scrollbar does not move the bubbles', () => {
63
+ it('reserves no gutter', () => {
64
+ expect(declarationsIn('.cv-p-thread__scroll').get('scrollbar-gutter')).toBeUndefined()
65
+ expect(declarationsIn('.cv-p-inbox').get('scrollbar-gutter')).toBeUndefined()
66
+ })
67
+ })
68
+
69
+ describe.each(HORIZONTAL_STRIPS)('horizontal strip %s', (selector) => {
70
+ const declarations = declarationsIn(selector)
71
+
72
+ it('hides the scrollbar everywhere', () => {
73
+ expect(declarations.get('scrollbar-width')).toBe('none')
74
+ expect(declarationsIn(`${selector}::-webkit-scrollbar`).get('display')).toBe('none')
75
+ })
76
+
77
+ it('fades both edges to hint that it scrolls', () => {
78
+ const mask = declarations.get('mask-image') ?? ''
79
+ expect(mask).toContain('linear-gradient(to right')
80
+ expect(mask).toContain('transparent')
81
+ expect(declarations.get('-webkit-mask-image')).toBe(mask)
82
+ })
83
+
84
+ it('snaps with proximity and does not chain sideways', () => {
85
+ expect(declarations.get('scroll-snap-type')).toBe('x proximity')
86
+ expect(declarations.get('overscroll-behavior-x')).toBe('contain')
87
+ expect(declarations.get('-webkit-overflow-scrolling')).toBe('touch')
88
+ })
89
+
90
+ it('keeps the first chip clear of the fade', () => {
91
+ expect(declarations.get('scroll-padding-inline')).toBe('var(--cv-p-i-fade)')
92
+ expect(declarations.get('padding-inline')).toBe('var(--cv-p-i-fade)')
93
+ expect(toRem(declarationsIn(selector).get('--cv-p-i-fade'))).toBeGreaterThanOrEqual(0.75)
94
+ expect(declarationsIn(`${selector} > .cv-p-chip`).get('scroll-snap-align')).toBe('start')
95
+ })
96
+
97
+ it('drops the mask under forced colors', () => {
98
+ const forced = declarationsIn(selector, mediaBody(FORCED_COLORS))
99
+ expect(forced.get('mask-image')).toBe('none')
100
+ expect(forced.get('-webkit-mask-image')).toBe('none')
101
+ })
102
+ })
103
+
104
+ describe('smooth scrolling', () => {
105
+ const SMOOTH_AREAS = ['.cv-p-inbox', '.cv-p-filters', '.cv-p-quick']
106
+
107
+ it.each(SMOOTH_AREAS)('%s is smooth only when motion is welcome', (selector) => {
108
+ expect(declarationsIn(selector, mediaBody(NO_PREFERENCE)).get('scroll-behavior')).toBe('smooth')
109
+ expect(declarationsIn(selector).get('scroll-behavior')).toBeUndefined()
110
+ })
111
+
112
+ it('never makes the thread scroller smooth: its programmatic positioning must stay instant', () => {
113
+ expect(declarationsIn('.cv-p-thread__scroll').get('scroll-behavior')).toBeUndefined()
114
+ expect(declarationsIn('.cv-p-thread__scroll', mediaBody(NO_PREFERENCE)).get('scroll-behavior')).toBeUndefined()
115
+ })
116
+
117
+ it('has no scroll-behavior outside the no-preference block', () => {
118
+ const outside = PARTICIPANT_CSS.replace(mediaBody(NO_PREFERENCE) ?? '', '')
119
+ expect(outside).not.toMatch(/(?<!over)scroll-behavior/)
120
+ })
121
+ })
122
+
123
+ describe('scroll-to-latest button styles', () => {
124
+ const button = declarationsIn('.cv-p-thread__latest-button')
125
+
126
+ it('is a 44px touch target', () => {
127
+ expect(button.get('width')).toBe('var(--cv-p-i-touch)')
128
+ expect(button.get('height')).toBe('var(--cv-p-i-touch)')
129
+ expect(toRem(declarationsIn('.cv-p').get('--cv-p-i-touch'))).toBeGreaterThanOrEqual(2.75)
130
+ })
131
+
132
+ it('floats without taking room in the scroller', () => {
133
+ const anchor = declarationsIn('.cv-p-thread__latest')
134
+ expect(anchor.get('position')).toBe('sticky')
135
+ expect(anchor.get('bottom')).toBe('0')
136
+ expect(anchor.get('height')).toBe('0')
137
+ expect(button.get('position')).toBe('absolute')
138
+ })
139
+
140
+ it('animates its entrance only when motion is welcome', () => {
141
+ expect(declarationsIn('.cv-p-thread__latest-button', mediaBody(NO_PREFERENCE)).get('animation')).toContain(
142
+ 'cv-p-latest-in',
143
+ )
144
+ expect(button.get('animation')).toBeUndefined()
145
+ })
146
+
147
+ it('outlines the badge under forced colors', () => {
148
+ expect(declarationsIn('.cv-p-thread__latest-badge', mediaBody(FORCED_COLORS)).get('border')).toContain('ButtonText')
149
+ })
150
+ })
151
+
152
+ describe('no leftovers', () => {
153
+ it('uses no Tailwind and no host vocabulary in the new rules', () => {
154
+ expect(PARTICIPANT_CSS).not.toMatch(/@apply|@tailwind/)
155
+ })
156
+ })
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { buildConversation } from './participantFixtures.test-helper'
4
+ import { resolveConversationIcon } from './participantSubjectIcon'
5
+
6
+ const conversation = buildConversation({ subjectId: '1' })
7
+ const group = { subjectType: 'invoice', label: 'Invoices', icon: <i>group</i> }
8
+
9
+ describe('resolveConversationIcon', () => {
10
+ it('prefers the host icon, then the group icon, then nothing', () => {
11
+ const host = <b>host</b>
12
+ expect(resolveConversationIcon({ conversation, group, renderSubjectIcon: () => host })).toBe(host)
13
+ expect(resolveConversationIcon({ conversation, group })).toBe(group.icon)
14
+ expect(resolveConversationIcon({ conversation, group: { ...group, icon: undefined } })).toBeUndefined()
15
+ expect(resolveConversationIcon({ conversation, group: undefined })).toBeUndefined()
16
+ })
17
+
18
+ it.each([null, undefined, false, ''])('treats %p from the host as no icon, like the list row', (empty) => {
19
+ expect(resolveConversationIcon({ conversation, group, renderSubjectIcon: () => empty })).toBe(group.icon)
20
+ })
21
+
22
+ it('hands the conversation to the host renderer', () => {
23
+ const seen: string[] = []
24
+ resolveConversationIcon({ conversation, group, renderSubjectIcon: (item) => seen.push(item.subjectId) })
25
+ expect(seen).toEqual(['1'])
26
+ })
27
+ })
@@ -0,0 +1,26 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import type { ParticipantConversationSummary } from '@adatechnology/conversation-contracts'
4
+
5
+ import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
6
+
7
+ export type ResolveConversationIconParams = {
8
+ readonly conversation: ParticipantConversationSummary
9
+ readonly group: ParticipantSubjectGroup | undefined
10
+ readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
11
+ }
12
+
13
+ export function isDrawable(node: ReactNode): boolean {
14
+ return node !== null && node !== undefined && node !== false && node !== ''
15
+ }
16
+
17
+ /** Host icon, then group icon, then nothing; the list row adds its own text fallback, the header draws no tile. */
18
+ export function resolveConversationIcon({
19
+ conversation,
20
+ group,
21
+ renderSubjectIcon,
22
+ }: ResolveConversationIconParams): ReactNode | undefined {
23
+ const custom = renderSubjectIcon?.(conversation)
24
+ if (isDrawable(custom)) return custom
25
+ return isDrawable(group?.icon) ? group?.icon : undefined
26
+ }
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import {
4
+ contrastRatio,
5
+ declaredToken,
6
+ defaultPalette,
7
+ fallbackOf,
8
+ flatten,
9
+ mappedPalette,
10
+ over,
11
+ parseColor,
12
+ tint,
13
+ type Palette,
14
+ type Rgba,
15
+ } from './participantContrast.test-helper'
16
+
17
+ const TEXT_MINIMUM = 4.5
18
+ const NON_TEXT_MINIMUM = 3
19
+ const WALLPAPER_SUBTLETY = 2
20
+
21
+ /** The light mapping a host panel can set on `.cv-p`; every other token keeps the package default. */
22
+ const PANEL_LIGHT_MAPPING = {
23
+ surface: '#f2efe9',
24
+ raised: '#ffffff',
25
+ text: '#1f2a30',
26
+ muted: '#59636a',
27
+ border: '#cfc8bb',
28
+ accent: '#a85a1c',
29
+ highlight: '#fbf3ea',
30
+ } as const
31
+
32
+ type Scenario = { readonly name: string; readonly palette: Palette; readonly tick: Rgba; readonly tickRead: Rgba }
33
+
34
+ function scenario(name: string, palette: Palette, theme: 'light' | 'dark'): Scenario {
35
+ const flat = flatten(palette)
36
+ const tick = flat.muted
37
+ const tickRead = parseColor(fallbackOf(declaredToken('--cv-p-i-tick-read', theme)))
38
+ return { name, palette: flat, tick, tickRead }
39
+ }
40
+
41
+ const SCENARIOS: readonly Scenario[] = [
42
+ scenario('light defaults', defaultPalette('light'), 'light'),
43
+ scenario('dark defaults', defaultPalette('dark'), 'dark'),
44
+ scenario('panel light mapping', mappedPalette(PANEL_LIGHT_MAPPING), 'light'),
45
+ ]
46
+
47
+ function fillOf(palette: Palette, surface: 'received' | 'mine'): Rgba {
48
+ return surface === 'received' ? palette.raised : over(palette.highlight, palette.surface)
49
+ }
50
+
51
+ describe.each(SCENARIOS.map((entry) => [entry.name, entry] as const))(
52
+ 'contrast: %s',
53
+ (_name, { palette, tick, tickRead }) => {
54
+ it('keeps the text of both bubbles at 4.5:1 or more', () => {
55
+ for (const side of ['received', 'mine'] as const) {
56
+ const fill = fillOf(palette, side)
57
+ expect(contrastRatio(palette.text, fill)).toBeGreaterThanOrEqual(TEXT_MINIMUM)
58
+ expect(contrastRatio(palette.muted, fill)).toBeGreaterThanOrEqual(TEXT_MINIMUM)
59
+ expect(contrastRatio(palette.accent, fill)).toBeGreaterThanOrEqual(TEXT_MINIMUM)
60
+ expect(contrastRatio(palette.danger, fill)).toBeGreaterThanOrEqual(TEXT_MINIMUM)
61
+ }
62
+ })
63
+
64
+ it('keeps every tick at 3:1 or more on both bubbles', () => {
65
+ for (const side of ['received', 'mine'] as const) {
66
+ const fill = fillOf(palette, side)
67
+ expect(contrastRatio(tick, fill)).toBeGreaterThanOrEqual(NON_TEXT_MINIMUM)
68
+ expect(contrastRatio(tickRead, fill)).toBeGreaterThanOrEqual(NON_TEXT_MINIMUM)
69
+ expect(contrastRatio(palette.danger, fill)).toBeGreaterThanOrEqual(NON_TEXT_MINIMUM)
70
+ }
71
+ })
72
+
73
+ it('tells the own bubble from the page by an accent edge of 3:1 or more', () => {
74
+ expect(contrastRatio(palette.accent, palette.surface)).toBeGreaterThanOrEqual(NON_TEXT_MINIMUM)
75
+ expect(contrastRatio(palette.accent, fillOf(palette, 'mine'))).toBeGreaterThanOrEqual(NON_TEXT_MINIMUM)
76
+ })
77
+
78
+ it('reads copyable tokens: text at 4.5:1 on the tint, the dotted underline at 3:1', () => {
79
+ for (const side of ['received', 'mine'] as const) {
80
+ const tinted = tint(palette.accent, 14, fillOf(palette, side))
81
+ expect(contrastRatio(palette.text, tinted)).toBeGreaterThanOrEqual(TEXT_MINIMUM)
82
+ expect(contrastRatio(palette.accent, fillOf(palette, side))).toBeGreaterThanOrEqual(NON_TEXT_MINIMUM)
83
+ }
84
+ })
85
+
86
+ it('lets text sit on the wallpaper dots and keeps the wallpaper itself subtle', () => {
87
+ const ink = palette.border
88
+ expect(contrastRatio(palette.text, ink)).toBeGreaterThanOrEqual(TEXT_MINIMUM)
89
+ expect(contrastRatio(palette.danger, palette.surface)).toBeGreaterThanOrEqual(TEXT_MINIMUM)
90
+ expect(contrastRatio(ink, palette.surface)).toBeLessThanOrEqual(WALLPAPER_SUBTLETY)
91
+ })
92
+
93
+ it('keeps the strip above the composer and the unread badge readable', () => {
94
+ expect(contrastRatio(palette.accent, palette.raised)).toBeGreaterThanOrEqual(TEXT_MINIMUM)
95
+ expect(contrastRatio(palette.accentContrast, palette.accent)).toBeGreaterThanOrEqual(TEXT_MINIMUM)
96
+ })
97
+ },
98
+ )
@@ -0,0 +1 @@
1
+ <div class="cv-p-root"><div class="cv-p-root__screen"><div class="cv-p cv-p-inbox" aria-busy="true"><h2 class="cv-p-inbox__title">Conversations</h2><div class="cv-p-loading"><span class="cv-p-skeleton" aria-hidden="true"></span><span class="cv-p-skeleton" aria-hidden="true"></span><span class="cv-p-sr-only">Loading…</span></div></div></div></div>
@@ -0,0 +1 @@
1
+ <div class="cv-p-root"><div class="cv-p-root__screen"><div class="cv-p cv-p-thread" aria-busy="true"></div></div></div>
@@ -0,0 +1 @@
1
+ <div class="cv-p cv-p-thread" aria-busy="false"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2><div class="cv-p-thread__meta"><span class="cv-p-protocol cv-p-protocol--header"><span class="cv-p-protocol__code"><span class="cv-p-sr-only">Protocol </span>P-1</span><button type="button" class="cv-p-protocol__copy" aria-label="Copy protocol P-1"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span><div class="cv-p-sr-only" role="status" aria-live="polite"></div><span class="cv-p-channels"><span class="cv-p-sr-only">Channels in this conversation: </span><span class="cv-p-channel cv-p-channel--app"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-smartphone" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"></rect><path d="M12 18h.01"></path></svg><span class="cv-p-sr-only">App, </span></span><span class="cv-p-channel cv-p-channel--whatsapp"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle" aria-hidden="true"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"></path></svg><span class="cv-p-sr-only">WhatsApp</span></span></span></div></div></header><div class="cv-p-thread__scroll" tabindex="-1"><button type="button" class="cv-p-thread__older">Load older messages</button><div class="cv-p-thread__messages"><div class="cv-p-day" role="separator" aria-label="Thu, 10/01"><time class="cv-p-day__label" dateTime="2026-10-01">Thu, 10/01</time></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">*<strong>oi</strong>* <button type="button" class="cv-p-copyable cv-p-copyable--phone" aria-label="Copy 11 98888-7777">11 98888-7777</button> <button type="button" class="cv-p-copyable cv-p-copyable--email" aria-label="Copy a@b.com">a@b.com</button> <span class="cv-p-text__link-group"><a class="cv-p-text__link" href="https://x.com" rel="noopener noreferrer nofollow ugc" target="_blank">https://x.com<span class="cv-p-sr-only"> (opens in a new tab)</span></a><button type="button" class="cv-p-text__link-copy" aria-label="Copy https://x.com"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span></div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:00:00.000Z">12:00</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--sent"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L5 10.5L14.5 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Sent</span></span></span></div><div class="cv-p-bubble-row"><span class="cv-p-bubble-row__avatar"><span class="cv-p-avatar" aria-hidden="true">O</span></span><div class="cv-p-bubble"><span class="cv-p-bubble__author">Op</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:05:00.000Z">12:05</time></span></div></div><div class="cv-p-day" role="separator" aria-label="Fri, 10/02"><time class="cv-p-day__label" dateTime="2026-10-02">Fri, 10/02</time></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:05:00.000Z">12:05</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--delivered"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L4.5 10L11 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6 6.5L9.5 10L19 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Delivered</span></span></span></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">W</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:06:00.000Z">12:06</time><button type="button" class="cv-p-bubble__retry"><span class="cv-status-ticks cv-status-ticks--failed"><svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert lucide-alert-triangle" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg></span><span>Failed — tap to retry</span></button></span><span class="cv-p-bubble__actions"><button type="button" class="cv-p-bubble__action">Edit</button><button type="button" class="cv-p-bubble__action">Discard</button></span></div></div><div class="cv-p-thread__latest"><button type="button" class="cv-p-thread__latest-button" aria-label="Scroll to latest message"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg><span class="cv-p-thread__latest-badge" aria-hidden="true">2</span></button></div></div><div class="cv-p-thread__live" role="status" aria-live="polite">New messages</div><form class="cv-p-composer"><div class="cv-p-quick" role="group" aria-label="Quick replies"><button type="button" class="cv-p-chip">T</button></div><div class="cv-p-composer__row"><input class="cv-p-composer__file-input" type="file" multiple="" hidden=""/><button type="button" class="cv-p-composer__attach" aria-label="Attach"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-paperclip" aria-hidden="true" focusable="false"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path></svg></button><textarea class="cv-p-composer__input" aria-label="Message" placeholder="Write a message…" rows="1">abc</textarea><button type="submit" class="cv-p-button cv-p-button--primary cv-p-composer__send" aria-label="Send"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send" aria-hidden="true" focusable="false"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg></button></div></form></div>
@@ -0,0 +1 @@
1
+ <div class="cv-p cv-p-thread" aria-busy="false"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2></div></header><div class="cv-p-thread__scroll" tabindex="-1"><button type="button" class="cv-p-thread__older">Load older messages</button><div class="cv-p-thread__messages"><div class="cv-p-day" role="separator" aria-label="Thu, 10/01"><time class="cv-p-day__label" dateTime="2026-10-01">Thu, 10/01</time></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">*<strong>oi</strong>* <button type="button" class="cv-p-copyable cv-p-copyable--phone" aria-label="Copy 11 98888-7777">11 98888-7777</button> <button type="button" class="cv-p-copyable cv-p-copyable--email" aria-label="Copy a@b.com">a@b.com</button> <span class="cv-p-text__link-group"><a class="cv-p-text__link" href="https://x.com" rel="noopener noreferrer nofollow ugc" target="_blank">https://x.com<span class="cv-p-sr-only"> (opens in a new tab)</span></a><button type="button" class="cv-p-text__link-copy" aria-label="Copy https://x.com"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span></div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:00:00.000Z">12:00</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--sent"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L5 10.5L14.5 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Sent</span></span></span></div><div class="cv-p-bubble"><span class="cv-p-bubble__author">Op</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:05:00.000Z">12:05</time></span></div><div class="cv-p-day" role="separator" aria-label="Fri, 10/02"><time class="cv-p-day__label" dateTime="2026-10-02">Fri, 10/02</time></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:05:00.000Z">12:05</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--delivered"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L4.5 10L11 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6 6.5L9.5 10L19 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Delivered</span></span></span></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">W</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:06:00.000Z">12:06</time><button type="button" class="cv-p-bubble__retry"><span class="cv-status-ticks cv-status-ticks--failed"><svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert lucide-alert-triangle" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg></span><span>Failed — tap to retry</span></button></span><span class="cv-p-bubble__actions"><button type="button" class="cv-p-bubble__action">Edit</button><button type="button" class="cv-p-bubble__action">Discard</button></span></div></div><div class="cv-p-thread__latest"><button type="button" class="cv-p-thread__latest-button" aria-label="Scroll to latest message"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg><span class="cv-p-thread__latest-badge" aria-hidden="true">2</span></button></div></div><div class="cv-p-thread__live" role="status" aria-live="polite">New messages</div><p class="cv-p-thread__closed">This conversation is closed</p></div>
@@ -0,0 +1 @@
1
+ <div class="cv-p cv-p-thread" aria-busy="false"><header class="cv-p-thread__header"><button type="button" class="cv-p-thread__back" aria-label="Back"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left" aria-hidden="true" focusable="false"><path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg></button><div class="cv-p-thread__heading"><h2 class="cv-p-thread__title"><span class="cv-p-thread__title-text">Invoice 4521</span></h2><div class="cv-p-thread__meta"><span class="cv-p-protocol cv-p-protocol--header"><span class="cv-p-protocol__code"><span class="cv-p-sr-only">Protocol </span>P-1</span><button type="button" class="cv-p-protocol__copy" aria-label="Copy protocol P-1"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span><div class="cv-p-sr-only" role="status" aria-live="polite"></div><span class="cv-p-channels"><span class="cv-p-sr-only">Channels in this conversation: </span><span class="cv-p-channel cv-p-channel--app"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-smartphone" aria-hidden="true"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"></rect><path d="M12 18h.01"></path></svg><span class="cv-p-sr-only">App, </span></span><span class="cv-p-channel cv-p-channel--whatsapp"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle" aria-hidden="true"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"></path></svg><span class="cv-p-sr-only">WhatsApp</span></span></span></div></div></header><div class="cv-p-thread__scroll" tabindex="-1"><button type="button" class="cv-p-thread__older">Load older messages</button><div class="cv-p-thread__messages"><div class="cv-p-day" role="separator" aria-label="Thu, 10/01"><time class="cv-p-day__label" dateTime="2026-10-01">Thu, 10/01</time></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">*<strong>oi</strong>* <button type="button" class="cv-p-copyable cv-p-copyable--phone" aria-label="Copy 11 98888-7777">11 98888-7777</button> <button type="button" class="cv-p-copyable cv-p-copyable--email" aria-label="Copy a@b.com">a@b.com</button> <span class="cv-p-text__link-group"><a class="cv-p-text__link" href="https://x.com" rel="noopener noreferrer nofollow ugc" target="_blank">https://x.com<span class="cv-p-sr-only"> (opens in a new tab)</span></a><button type="button" class="cv-p-text__link-copy" aria-label="Copy https://x.com"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg></button></span></div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:00:00.000Z">12:00</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--sent"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L5 10.5L14.5 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Sent</span></span></span></div><div class="cv-p-bubble"><span class="cv-p-bubble__author">Op</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-01T12:05:00.000Z">12:05</time></span></div><div class="cv-p-day" role="separator" aria-label="Fri, 10/02"><time class="cv-p-day__label" dateTime="2026-10-02">Fri, 10/02</time></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">Hello</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:05:00.000Z">12:05</time><span class="cv-p-bubble__status"><span class="cv-status-ticks cv-status-ticks--delivered"><svg viewBox="0 0 20 12" width="15" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 6.5L4.5 10L11 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6 6.5L9.5 10L19 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span><span class="cv-p-sr-only">Delivered</span></span></span></div><div class="cv-p-bubble cv-p-bubble--mine"><span class="cv-p-sr-only">Me</span><div class="cv-p-text">W</div><span class="cv-p-bubble__meta"><time class="cv-p-bubble__time" dateTime="2026-10-02T12:06:00.000Z">12:06</time><button type="button" class="cv-p-bubble__retry"><span class="cv-status-ticks cv-status-ticks--failed"><svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert lucide-alert-triangle" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg></span><span>Failed — tap to retry</span></button></span><span class="cv-p-bubble__actions"><button type="button" class="cv-p-bubble__action">Edit</button><button type="button" class="cv-p-bubble__action">Discard</button></span></div></div><div class="cv-p-thread__latest"><button type="button" class="cv-p-thread__latest-button" aria-label="Scroll to latest message"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"></path></svg><span class="cv-p-thread__latest-badge" aria-hidden="true">2</span></button></div></div><div class="cv-p-thread__live" role="status" aria-live="polite">New messages</div><form class="cv-p-composer"><div class="cv-p-quick" role="group" aria-label="Quick replies"><button type="button" class="cv-p-chip">T</button></div><div class="cv-p-composer__row"><input class="cv-p-composer__file-input" type="file" multiple="" hidden=""/><button type="button" class="cv-p-composer__attach" aria-label="Attach"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-paperclip" aria-hidden="true" focusable="false"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"></path></svg></button><textarea class="cv-p-composer__input" aria-label="Message" placeholder="Write a message…" rows="1">abc</textarea><button type="submit" class="cv-p-button cv-p-button--primary cv-p-composer__send" aria-label="Send"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send" aria-hidden="true" focusable="false"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg></button></div></form></div>