@adatechnology/conversations-ui 0.10.0 → 0.12.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.
package/README.md CHANGED
@@ -334,6 +334,7 @@ import { ConversationThread } from '@adatechnology/conversations-ui/participant'
334
334
  | `status?` | `'open' \| 'closed'` | `closed` tira o compositor e mostra `labels.closedNotice` |
335
335
  | `counterpartLabel?` | `string` | linha curta sob o título, montada pelo host |
336
336
  | `headerActions?` | `ReactNode` | slot à direita do cabeçalho; ausente não desenha nada. Espera botões curtos ou só ícone, cada um com `aria-label`: com ações, a barra quebra de linha e o título mantém no mínimo `6rem` |
337
+ | `renderComposerLock?` | `(conversation) => ReactNode \| undefined` | devolver um nó troca o compositor por ele (aviso + ações do host), dentro de `cv-p-thread__lock`; `undefined` mantém o compositor. `closed` continua tendo prioridade. Avaliado a cada render; o rascunho fica no controlador e volta ao destravar. Também existe em `ParticipantConversations` |
337
338
  | `onBack?` | `() => void` | ausente = sem seta (o diálogo do host já tem o próprio fechar) |
338
339
  | `quickReplies?` | `QuickReply[]` | chips acima do campo; tocar **preenche**, nunca envia |
339
340
  | `renderSubjectIcon?`, `subjectGroups?` | | ícone do assunto no cabeçalho, como na lista |
@@ -190,6 +190,8 @@ type ParticipantConversationsLabels = {
190
190
  readonly edit: string;
191
191
  /** Name of the menu button on a failed message. Optional; translated hosts should fill it, otherwise the English default shows. */
192
192
  readonly messageOptions?: string;
193
+ /** Toggle that opens the message as received (e.g. the whole email). Optional; the English default shows otherwise. */
194
+ readonly showOriginalText?: string;
193
195
  readonly notFound: string;
194
196
  readonly loading: string;
195
197
  readonly loadError: string;
@@ -244,6 +246,8 @@ type ParticipantConversationsProps = {
244
246
  tail?: boolean;
245
247
  /** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
246
248
  audioRecording?: ParticipantAudioRecordingOptions;
249
+ /** Return a node to replace the composer for this conversation (notice + host actions); undefined keeps the composer. `closed` still wins. Evaluated every render. */
250
+ renderComposerLock?: (conversation: ParticipantConversationSummary) => ReactNode | undefined;
247
251
  };
248
252
  declare function ParticipantConversations(props: ParticipantConversationsProps): react.JSX.Element;
249
253
 
@@ -287,6 +291,8 @@ type ConversationThreadProps = {
287
291
  readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer;
288
292
  /** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
289
293
  readonly audioRecording?: ParticipantAudioRecordingOptions;
294
+ /** Return a node to replace the composer (notice + host actions); undefined keeps the composer. `closed` still wins. Evaluated every render. */
295
+ readonly renderComposerLock?: (conversation: ParticipantConversationSummary) => ReactNode | undefined;
290
296
  };
291
297
  /** One conversation about one subject, drawn like the participant app and seen from either side. */
292
298
  declare function ConversationThread(props: ConversationThreadProps): react.JSX.Element;
@@ -64,6 +64,7 @@ function formatRecordingElapsedLabel(template, elapsed, limit) {
64
64
 
65
65
  // src/participant/participantLabels.ts
66
66
  var DEFAULT_MESSAGE_OPTIONS_LABEL = "Message options";
67
+ var DEFAULT_SHOW_ORIGINAL_TEXT_LABEL = "Show original";
67
68
  var DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS = {
68
69
  inboxTitle: "Conversations",
69
70
  sectionAwaiting: "Waiting for your reply",
@@ -101,6 +102,7 @@ var DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS = {
101
102
  discard: "Discard",
102
103
  edit: "Edit",
103
104
  messageOptions: DEFAULT_MESSAGE_OPTIONS_LABEL,
105
+ showOriginalText: DEFAULT_SHOW_ORIGINAL_TEXT_LABEL,
104
106
  notFound: "Conversation not found",
105
107
  loading: "Loading\u2026",
106
108
  loadError: "Could not load. Check your connection and try again.",
@@ -2577,6 +2579,14 @@ function BubbleHeading({ content, labels }) {
2577
2579
  if (!content.authorName) return null;
2578
2580
  return /* @__PURE__ */ jsx27("span", { className: "cv-p-bubble__author", children: content.authorName });
2579
2581
  }
2582
+ function OriginalText({ content, labels }) {
2583
+ const original = content.originalText?.trim();
2584
+ if (!original || original === content.text?.trim()) return null;
2585
+ return /* @__PURE__ */ jsxs21("details", { className: "cv-p-bubble__original", children: [
2586
+ /* @__PURE__ */ jsx27("summary", { className: "cv-p-bubble__original-toggle", children: labels.showOriginalText ?? DEFAULT_SHOW_ORIGINAL_TEXT_LABEL }),
2587
+ /* @__PURE__ */ jsx27("div", { className: "cv-p-bubble__original-text", children: original })
2588
+ ] });
2589
+ }
2580
2590
  function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl, onRetry, onDiscard, onEdit }) {
2581
2591
  return /* @__PURE__ */ jsxs21(Fragment6, { children: [
2582
2592
  /* @__PURE__ */ jsx27(BubbleHeading, { content, labels }),
@@ -2595,7 +2605,8 @@ function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl,
2595
2605
  /* @__PURE__ */ jsx27("time", { className: "cv-p-bubble__time", dateTime: content.createdAt, children: formatTimestamp(content.createdAt) }),
2596
2606
  ownStatus ? /* @__PURE__ */ jsx27(OwnStatus, { status: ownStatus, labels, onRetry }) : null,
2597
2607
  ownStatus === "failed" ? /* @__PURE__ */ jsx27(ParticipantFailedMenu, { labels, onEdit, onDiscard }) : null
2598
- ] })
2608
+ ] }),
2609
+ /* @__PURE__ */ jsx27(OriginalText, { content, labels })
2599
2610
  ] });
2600
2611
  }
2601
2612
 
@@ -2669,6 +2680,7 @@ function describeItem(item, perspective) {
2669
2680
  isMine: isOwnMessage(message, perspective),
2670
2681
  authorName: message.authorName ?? void 0,
2671
2682
  text: message.text ?? void 0,
2683
+ originalText: message.originalText ?? void 0,
2672
2684
  createdAt: message.createdAt,
2673
2685
  attachments: message.attachments,
2674
2686
  pendingFilenames: [],
@@ -2908,6 +2920,10 @@ function Footer({ props }) {
2908
2920
  if (conversation.status === "closed") {
2909
2921
  return /* @__PURE__ */ jsx33("p", { className: "cv-p-thread__closed", children: labels.closedNotice });
2910
2922
  }
2923
+ const lock = props.renderComposerLock?.(conversation);
2924
+ if (lock !== void 0 && lock !== null) {
2925
+ return /* @__PURE__ */ jsx33("div", { className: "cv-p-thread__lock", role: "region", "aria-live": "polite", children: lock });
2926
+ }
2911
2927
  return /* @__PURE__ */ jsx33(
2912
2928
  ParticipantComposer,
2913
2929
  {
@@ -3675,7 +3691,8 @@ function LoadedThread(props) {
3675
3691
  avatars: props.avatars,
3676
3692
  renderAuthorAvatar: props.renderAuthorAvatar,
3677
3693
  tail: props.tail,
3678
- audioRecording: props.audioRecording
3694
+ audioRecording: props.audioRecording,
3695
+ renderComposerLock: props.renderComposerLock
3679
3696
  }
3680
3697
  );
3681
3698
  }
@@ -4043,7 +4060,8 @@ function ConversationThreadBody(props) {
4043
4060
  tail: props.tail,
4044
4061
  counterpartLabel: props.counterpartLabel,
4045
4062
  headerActions: props.headerActions,
4046
- audioRecording: props.audioRecording
4063
+ audioRecording: props.audioRecording,
4064
+ renderComposerLock: props.renderComposerLock
4047
4065
  }
4048
4066
  ) })
4049
4067
  }
package/dist/styles.css CHANGED
@@ -1812,6 +1812,11 @@
1812
1812
  white-space: nowrap;
1813
1813
  border: 0;
1814
1814
  }
1815
+ .cv-p-thread__lock {
1816
+ padding: 1rem;
1817
+ color: var(--cv-p-i-text-muted);
1818
+ border-top: 1px solid var(--cv-p-i-border);
1819
+ }
1815
1820
  .cv-p-thread__closed {
1816
1821
  margin: 0;
1817
1822
  padding: 1rem;
@@ -1973,7 +1978,41 @@
1973
1978
  .cv-p-bubble > .cv-p-attachment {
1974
1979
  flex: 1 0 100%;
1975
1980
  }
1981
+ .cv-p-bubble__original {
1982
+ flex: 1 0 100%;
1983
+ margin-top: 0.2rem;
1984
+ }
1985
+ .cv-p-bubble__original-toggle {
1986
+ display: inline-block;
1987
+ font-size: 0.7rem;
1988
+ color: var(--cv-p-i-text-muted);
1989
+ cursor: pointer;
1990
+ list-style: none;
1991
+ text-decoration: underline;
1992
+ text-decoration-style: dotted;
1993
+ text-underline-offset: 2px;
1994
+ }
1995
+ .cv-p-bubble__original-toggle::-webkit-details-marker {
1996
+ display: none;
1997
+ }
1998
+ .cv-p-bubble__original-toggle:focus-visible {
1999
+ outline: 2px solid var(--cv-p-i-accent);
2000
+ outline-offset: 2px;
2001
+ border-radius: 2px;
2002
+ }
2003
+ .cv-p-bubble__original-text {
2004
+ margin-top: 0.3rem;
2005
+ padding-inline-start: 0.5rem;
2006
+ border-inline-start: 2px solid var(--cv-p-i-border);
2007
+ max-height: 16rem;
2008
+ overflow: auto;
2009
+ white-space: pre-wrap;
2010
+ overflow-wrap: anywhere;
2011
+ font-size: 0.8rem;
2012
+ color: var(--cv-p-i-text-muted);
2013
+ }
1976
2014
  .cv-p-bubble__meta {
2015
+ order: 1;
1977
2016
  display: flex;
1978
2017
  flex: 0 0 auto;
1979
2018
  justify-content: flex-end;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -35,7 +35,7 @@
35
35
  "clsx": "^2.1.1",
36
36
  "lucide-react": "^1.21.0",
37
37
  "tailwind-merge": "^3.6.0",
38
- "@adatechnology/conversation-contracts": "0.6.0",
38
+ "@adatechnology/conversation-contracts": "0.7.0",
39
39
  "@adatechnology/meta-whatsapp-contracts": "0.6.0"
40
40
  },
41
41
  "peerDependencies": {
@@ -53,6 +53,8 @@ export type ConversationThreadProps = {
53
53
  readonly renderAuthorAvatar?: ParticipantAuthorAvatarRenderer
54
54
  /** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
55
55
  readonly audioRecording?: ParticipantAudioRecordingOptions
56
+ /** Return a node to replace the composer (notice + host actions); undefined keeps the composer. `closed` still wins. Evaluated every render. */
57
+ readonly renderComposerLock?: (conversation: ParticipantConversationSummary) => ReactNode | undefined
56
58
  }
57
59
 
58
60
  function useConversationThreadCore(props: ConversationThreadProps): ParticipantThreadCoreProps {
@@ -109,6 +111,7 @@ function ConversationThreadBody(props: ConversationThreadProps) {
109
111
  counterpartLabel={props.counterpartLabel}
110
112
  headerActions={props.headerActions}
111
113
  audioRecording={props.audioRecording}
114
+ renderComposerLock={props.renderComposerLock}
112
115
  />
113
116
  </div>
114
117
  </div>
@@ -4,7 +4,7 @@ import { OwnStatus } from './ParticipantBubbleStatus'
4
4
  import { ParticipantFailedMenu } from './ParticipantFailedMenu'
5
5
  import type { BubbleContent } from './ParticipantMessageBubble'
6
6
  import { ParticipantMessageText } from './ParticipantMessageText'
7
- import type { ParticipantConversationsLabels } from './participantLabels'
7
+ import { DEFAULT_SHOW_ORIGINAL_TEXT_LABEL, type ParticipantConversationsLabels } from './participantLabels'
8
8
  import type { ParticipantOwnMessageStatus } from './participantMessages'
9
9
 
10
10
  type BubbleBodyProps = {
@@ -24,6 +24,18 @@ function BubbleHeading({ content, labels }: Pick<BubbleBodyProps, 'content' | 'l
24
24
  return <span className="cv-p-bubble__author">{content.authorName}</span>
25
25
  }
26
26
 
27
+ /** Collapsed by default: the trimmed text is what the reader needs, the original is there on demand. */
28
+ function OriginalText({ content, labels }: Pick<BubbleBodyProps, 'content' | 'labels'>) {
29
+ const original = content.originalText?.trim()
30
+ if (!original || original === content.text?.trim()) return null
31
+ return (
32
+ <details className="cv-p-bubble__original">
33
+ <summary className="cv-p-bubble__original-toggle">{labels.showOriginalText ?? DEFAULT_SHOW_ORIGINAL_TEXT_LABEL}</summary>
34
+ <div className="cv-p-bubble__original-text">{original}</div>
35
+ </details>
36
+ )
37
+ }
38
+
27
39
  export function BubbleBody({ content, labels, ownStatus, textId, resolveAttachmentUrl, onRetry, onDiscard, onEdit }: BubbleBodyProps) {
28
40
  return (
29
41
  <>
@@ -49,6 +61,8 @@ export function BubbleBody({ content, labels, ownStatus, textId, resolveAttachme
49
61
  {ownStatus ? <OwnStatus status={ownStatus} labels={labels} onRetry={onRetry} /> : null}
50
62
  {ownStatus === 'failed' ? <ParticipantFailedMenu labels={labels} onEdit={onEdit} onDiscard={onDiscard} /> : null}
51
63
  </span>
64
+ {/* Last child so the ids React derives for the siblings above stay as they were; CSS keeps the meta after it. */}
65
+ <OriginalText content={content} labels={labels} />
52
66
  </>
53
67
  )
54
68
  }
@@ -44,6 +44,8 @@ export type ParticipantConversationsProps = {
44
44
  tail?: boolean
45
45
  /** Absent draws no microphone. Present lets the user record and send an audio as a message of its own. */
46
46
  audioRecording?: ParticipantAudioRecordingOptions
47
+ /** Return a node to replace the composer for this conversation (notice + host actions); undefined keeps the composer. `closed` still wins. Evaluated every render. */
48
+ renderComposerLock?: (conversation: ParticipantConversationSummary) => ReactNode | undefined
47
49
  }
48
50
 
49
51
  export function ParticipantConversations(props: ParticipantConversationsProps) {
@@ -70,6 +70,41 @@ describe('ParticipantMessageBubble', () => {
70
70
  expect(mine).not.toContain('Marina')
71
71
  })
72
72
 
73
+ it('keeps the original text collapsed behind a discreet toggle inside the bubble', () => {
74
+ const original = 'Pode descarregar.\n\nEm ter., 20 de out. de 2026, Equipe escreveu:\n> Autorizam?'
75
+ const markup = renderServer(buildMessage({ id: 'a', text: 'Pode descarregar.', originalText: original }))
76
+
77
+ expect(markup).toContain('<details class="cv-p-bubble__original">')
78
+ expect(markup).not.toContain('<details class="cv-p-bubble__original" open')
79
+ expect(markup).toContain('<summary class="cv-p-bubble__original-toggle">Show original</summary>')
80
+ expect(markup).toContain('Em ter., 20 de out. de 2026, Equipe escreveu:')
81
+ expect(findUtilityClassTokens(markup)).toEqual([])
82
+ })
83
+
84
+ it('uses the host label for the original-text toggle', () => {
85
+ const item: ParticipantTimelineItem = {
86
+ kind: 'server',
87
+ message: buildMessage({ id: 'a', text: 'Ok', originalText: 'Ok\n> antes' }),
88
+ }
89
+ const markup = renderToStaticMarkup(
90
+ <ParticipantMessageBubble
91
+ item={item}
92
+ labels={{ ...LABELS, showOriginalText: 'ver histórico' }}
93
+ confirmsRead
94
+ resolveAttachmentUrl={resolveAttachmentUrl}
95
+ />,
96
+ )
97
+
98
+ expect(markup).toContain('>ver histórico</summary>')
99
+ })
100
+
101
+ it('shows no toggle when the original is absent, blank or the same as the text', () => {
102
+ expect(renderServer(buildMessage({ id: 'a', text: 'Ok' }))).not.toContain('<details')
103
+ expect(renderServer(buildMessage({ id: 'b', text: 'Ok', originalText: null }))).not.toContain('<details')
104
+ expect(renderServer(buildMessage({ id: 'c', text: 'Ok', originalText: ' ' }))).not.toContain('<details')
105
+ expect(renderServer(buildMessage({ id: 'd', text: 'Ok', originalText: ' Ok\n' }))).not.toContain('<details')
106
+ })
107
+
73
108
  it('renders a time element with the ISO dateTime', () => {
74
109
  expect(renderServer()).toMatch(/<time[^>]*dateTime="2026-10-01T10:00:00.000Z"/)
75
110
  })
@@ -37,6 +37,7 @@ export type BubbleContent = {
37
37
  readonly isMine: boolean
38
38
  readonly authorName?: string
39
39
  readonly text?: string
40
+ readonly originalText?: string
40
41
  readonly createdAt: string
41
42
  readonly attachments: readonly ParticipantAttachment[]
42
43
  readonly pendingFilenames: readonly string[]
@@ -51,6 +52,7 @@ function describeItem(item: ParticipantTimelineItem, perspective?: ParticipantPe
51
52
  isMine: isOwnMessage(message, perspective),
52
53
  authorName: message.authorName ?? undefined,
53
54
  text: message.text ?? undefined,
55
+ originalText: message.originalText ?? undefined,
54
56
  createdAt: message.createdAt,
55
57
  attachments: message.attachments,
56
58
  pendingFilenames: [],
@@ -128,6 +128,52 @@ describe('ParticipantThread', () => {
128
128
  expect(markup).not.toContain('<textarea')
129
129
  })
130
130
 
131
+ it('replaces the composer with the lock node inside the lock region', () => {
132
+ const markup = render({ renderComposerLock: () => <p>Assumed by Ana</p> })
133
+
134
+ expect(markup).toContain(
135
+ '<div class="cv-p-thread__lock" role="region" aria-live="polite"><p>Assumed by Ana</p></div>',
136
+ )
137
+ expect(markup).not.toContain('<form')
138
+ expect(markup).not.toContain('<textarea')
139
+ })
140
+
141
+ it('keeps the composer when renderComposerLock returns undefined or null', () => {
142
+ expect(render({ renderComposerLock: () => undefined })).toContain('<form')
143
+ expect(render({ renderComposerLock: () => null })).toContain('<form')
144
+ expect(render({ renderComposerLock: () => undefined })).not.toContain('cv-p-thread__lock')
145
+ })
146
+
147
+ it('lets the closed notice win over the lock', () => {
148
+ const markup = render({
149
+ conversation: buildConversation({ subjectId: '1', status: 'closed' }),
150
+ renderComposerLock: () => <p>Assumed by Ana</p>,
151
+ })
152
+
153
+ expect(markup).toContain('This conversation is closed')
154
+ expect(markup).not.toContain('Assumed by Ana')
155
+ })
156
+
157
+ it('passes the conversation to renderComposerLock and brings the draft back when it unlocks', () => {
158
+ const draft = { value: 'half typed', onChange: () => undefined, files: [], onFilesChange: () => undefined }
159
+ const conversation = buildConversation({ subjectId: '7' })
160
+ const seen: string[] = []
161
+
162
+ const locked = render({
163
+ draft,
164
+ conversation,
165
+ renderComposerLock: (received) => {
166
+ seen.push(received.subjectId)
167
+ return <p>Locked</p>
168
+ },
169
+ })
170
+ const unlocked = render({ draft, conversation, renderComposerLock: () => undefined })
171
+
172
+ expect(seen).toEqual(['7'])
173
+ expect(locked).not.toContain('half typed')
174
+ expect(unlocked).toContain('half typed')
175
+ })
176
+
131
177
  it('shows the composer when the conversation is open', () => {
132
178
  const markup = render()
133
179
 
@@ -76,6 +76,8 @@ export type ParticipantThreadProps = {
76
76
  readonly audioRecording?: ParticipantAudioRecordingOptions
77
77
  /** Receives the recorded audio; the controller sends it as a message of its own. */
78
78
  readonly onSendAudio?: (file: File) => void
79
+ /** Return a node to replace the composer for this conversation (notice + host actions); undefined keeps the composer. Evaluated every render. */
80
+ readonly renderComposerLock?: (conversation: ParticipantConversationSummary) => ReactNode | undefined
79
81
  }
80
82
 
81
83
  function Footer({ props }: { readonly props: ParticipantThreadProps }) {
@@ -83,6 +85,14 @@ function Footer({ props }: { readonly props: ParticipantThreadProps }) {
83
85
  if (conversation.status === 'closed') {
84
86
  return <p className="cv-p-thread__closed">{labels.closedNotice}</p>
85
87
  }
88
+ const lock = props.renderComposerLock?.(conversation)
89
+ if (lock !== undefined && lock !== null) {
90
+ return (
91
+ <div className="cv-p-thread__lock" role="region" aria-live="polite">
92
+ {lock}
93
+ </div>
94
+ )
95
+ }
86
96
  return (
87
97
  <ParticipantComposer
88
98
  value={draft.value}
@@ -105,6 +105,7 @@ function LoadedThread(props: LoadedThreadProps) {
105
105
  renderAuthorAvatar={props.renderAuthorAvatar}
106
106
  tail={props.tail}
107
107
  audioRecording={props.audioRecording}
108
+ renderComposerLock={props.renderComposerLock}
108
109
  />
109
110
  )
110
111
  }
@@ -1,6 +1,7 @@
1
1
  import { DEFAULT_PARTICIPANT_AUDIO_LABELS, type ParticipantAudioLabels } from './participantAudioLabels'
2
2
 
3
3
  export const DEFAULT_MESSAGE_OPTIONS_LABEL = 'Message options'
4
+ export const DEFAULT_SHOW_ORIGINAL_TEXT_LABEL = 'Show original'
4
5
 
5
6
  export type ParticipantConversationsLabels = {
6
7
  readonly inboxTitle: string
@@ -43,6 +44,8 @@ export type ParticipantConversationsLabels = {
43
44
  readonly edit: string
44
45
  /** Name of the menu button on a failed message. Optional; translated hosts should fill it, otherwise the English default shows. */
45
46
  readonly messageOptions?: string
47
+ /** Toggle that opens the message as received (e.g. the whole email). Optional; the English default shows otherwise. */
48
+ readonly showOriginalText?: string
46
49
  readonly notFound: string
47
50
  readonly loading: string
48
51
  readonly loadError: string
@@ -106,6 +109,7 @@ export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsL
106
109
  discard: 'Discard',
107
110
  edit: 'Edit',
108
111
  messageOptions: DEFAULT_MESSAGE_OPTIONS_LABEL,
112
+ showOriginalText: DEFAULT_SHOW_ORIGINAL_TEXT_LABEL,
109
113
  notFound: 'Conversation not found',
110
114
  loading: 'Loading…',
111
115
  loadError: 'Could not load. Check your connection and try again.',
package/src/styles.css CHANGED
@@ -1275,6 +1275,7 @@
1275
1275
  .cv-p-thread__messages { display: flex; flex-direction: column; gap: 0.5rem; }
1276
1276
  .cv-p-thread__live { padding: 0 1rem; font-size: 0.75rem; color: var(--cv-p-i-accent); background: var(--cv-p-i-surface-raised); }
1277
1277
  .cv-p-thread__live:empty { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
1278
+ .cv-p-thread__lock { padding: 1rem; color: var(--cv-p-i-text-muted); border-top: 1px solid var(--cv-p-i-border); }
1278
1279
  .cv-p-thread__closed { margin: 0; padding: 1rem; text-align: center; color: var(--cv-p-i-text-muted); border-top: 1px solid var(--cv-p-i-border); }
1279
1280
  .cv-p-day { align-self: center; margin: 0.5rem 0; padding: 0.25rem 0.75rem; background: var(--cv-p-i-surface-raised); border: 1px solid var(--cv-p-i-border); border-radius: var(--cv-p-i-radius); font: 0.65rem 'SFMono-Regular', Consolas, monospace; letter-spacing: 0.06em; text-transform: uppercase; color: var(--cv-p-i-text-muted); }
1280
1281
 
@@ -1301,7 +1302,12 @@
1301
1302
  .cv-p-bubble__author { display: block; flex: 1 0 100%; margin-bottom: 0.15rem; font: 0.65rem 'SFMono-Regular', Consolas, monospace; text-transform: uppercase; color: var(--cv-p-i-accent); }
1302
1303
  .cv-p-bubble > .cv-p-text { flex: 0 1 auto; min-width: 0; margin-inline-end: auto; }
1303
1304
  .cv-p-bubble > .cv-p-attachment { flex: 1 0 100%; }
1304
- .cv-p-bubble__meta { display: flex; flex: 0 0 auto; justify-content: flex-end; align-items: center; gap: 0.35rem; margin-inline-start: auto; margin-top: 0.25rem; font: 0.65rem 'SFMono-Regular', Consolas, monospace; color: var(--cv-p-i-text-muted); }
1305
+ .cv-p-bubble__original { flex: 1 0 100%; margin-top: 0.2rem; }
1306
+ .cv-p-bubble__original-toggle { display: inline-block; font-size: 0.7rem; color: var(--cv-p-i-text-muted); cursor: pointer; list-style: none; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 2px; }
1307
+ .cv-p-bubble__original-toggle::-webkit-details-marker { display: none; }
1308
+ .cv-p-bubble__original-toggle:focus-visible { outline: 2px solid var(--cv-p-i-accent); outline-offset: 2px; border-radius: 2px; }
1309
+ .cv-p-bubble__original-text { margin-top: 0.3rem; padding-inline-start: 0.5rem; border-inline-start: 2px solid var(--cv-p-i-border); max-height: 16rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 0.8rem; color: var(--cv-p-i-text-muted); }
1310
+ .cv-p-bubble__meta { order: 1; display: flex; flex: 0 0 auto; justify-content: flex-end; align-items: center; gap: 0.35rem; margin-inline-start: auto; margin-top: 0.25rem; font: 0.65rem 'SFMono-Regular', Consolas, monospace; color: var(--cv-p-i-text-muted); }
1305
1311
  .cv-p-bubble__status { display: inline-flex; align-items: center; gap: 0.25rem; }
1306
1312
  .cv-p-bubble .cv-status-ticks--queued, .cv-p-bubble .cv-status-ticks--sent, .cv-p-bubble .cv-status-ticks--delivered { color: var(--cv-p-i-tick); }
1307
1313
  .cv-p-bubble .cv-status-ticks--read { color: var(--cv-p-i-tick-read); }