@joewinke/jatui 0.55.0 → 0.57.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/CLAUDE.md +5 -4
- package/dist/components/AmbienceToggle.svelte +160 -4
- package/dist/components/AmbienceToggle.svelte.d.ts +36 -0
- package/dist/components/BottomSheet.svelte +21 -0
- package/dist/components/BottomSheet.svelte.d.ts +9 -0
- package/dist/components/ConfirmModal.svelte +11 -5
- package/dist/components/ConfirmModal.svelte.d.ts +9 -0
- package/dist/components/Drawer.svelte +23 -0
- package/dist/components/Drawer.svelte.d.ts +10 -0
- package/dist/components/InputDialog.svelte +6 -1
- package/dist/components/Modal.svelte +23 -8
- package/dist/components/Modal.svelte.d.ts +10 -0
- package/dist/components/messaging/ChatTranscript.svelte +414 -0
- package/dist/components/messaging/ChatTranscript.svelte.d.ts +36 -0
- package/dist/components/messaging/chatTranscript.d.ts +68 -0
- package/dist/components/messaging/chatTranscript.js +72 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/utils/dialogFocus.d.ts +54 -0
- package/dist/utils/dialogFocus.js +153 -0
- package/package.json +1 -1
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
ChatTranscript — a read-only, two-sided transcript of NAMED speakers: a live
|
|
3
|
+
phone call (caller vs. the assistant's persona), a Scribe dictation, a
|
|
4
|
+
meeting recording. Not a chat client: no composer, no reactions, no edit or
|
|
5
|
+
delete, no HTML in the words. For a Slack-style channel use MessageThread /
|
|
6
|
+
MessageItem; for an agent's tool log use AgentLog.
|
|
7
|
+
|
|
8
|
+
- Speakers sit on opposite sides (the first listed at `start`, the second at
|
|
9
|
+
`end`; set `side` to choose). Consecutive turns by one speaker group into a
|
|
10
|
+
run: the name and avatar show once, the tail on the last bubble.
|
|
11
|
+
- Each speaker's name is the label. Pass real names ("Dana Testwell",
|
|
12
|
+
"Ruby"), never role words ("Patient", "AI assistant").
|
|
13
|
+
- `streaming`: the LAST turn is still being spoken; its words reveal per line
|
|
14
|
+
through StreamedText, with a caret.
|
|
15
|
+
- New turns slide in from their own side on the motion tokens; turns present
|
|
16
|
+
on first render don't animate; reduced motion / data-motion="calm" is
|
|
17
|
+
honoured by resolveMotionTiming.
|
|
18
|
+
- Follows the live edge with createStickToBottom only while the reader is at
|
|
19
|
+
it; scrolled away, a JumpToLatest pill counts what arrived.
|
|
20
|
+
- A11y: the scroller is a polite role="log" (set `live={false}` for a
|
|
21
|
+
finished recording), keyboard-scrollable (tabindex 0); every bubble's
|
|
22
|
+
speaker is in its text (a grouped bubble carries it sr-only); timestamps
|
|
23
|
+
are <time> elements; a slow or flagged turn belongs in `meta` as words, not
|
|
24
|
+
only colour.
|
|
25
|
+
- Optional per-turn `meta` (e.g. "1.2 s" reply latency) and `actions` (a
|
|
26
|
+
hover / focus toolbar the host fills — none by default) snippets.
|
|
27
|
+
`onTurnClick` makes each bubble a button (e.g. seek a recording);
|
|
28
|
+
`activeId` marks the turn playing now.
|
|
29
|
+
|
|
30
|
+
Token-only colour (bubble tints are color-mix of a token family over
|
|
31
|
+
base-100), logical properties so RTL mirrors.
|
|
32
|
+
-->
|
|
33
|
+
<script lang="ts">
|
|
34
|
+
import type { Snippet } from 'svelte';
|
|
35
|
+
import Avatar from './Avatar.svelte';
|
|
36
|
+
import StreamedText from '../agent/StreamedText.svelte';
|
|
37
|
+
import JumpToLatest from '../basic/JumpToLatest.svelte';
|
|
38
|
+
import { createStickToBottom, type StickState } from '../../actions/stickToBottom';
|
|
39
|
+
import { resolveMotionTiming } from '../../motion/transitions';
|
|
40
|
+
import {
|
|
41
|
+
buildTranscriptRows,
|
|
42
|
+
formatTurnTime,
|
|
43
|
+
turnDatetime,
|
|
44
|
+
type TranscriptSide,
|
|
45
|
+
type TranscriptSpeaker,
|
|
46
|
+
type TranscriptTurn
|
|
47
|
+
} from './chatTranscript';
|
|
48
|
+
|
|
49
|
+
interface Props {
|
|
50
|
+
turns: TranscriptTurn[];
|
|
51
|
+
speakers: TranscriptSpeaker[];
|
|
52
|
+
/** The last turn is still arriving: reveal it per line with a caret. */
|
|
53
|
+
streaming?: boolean;
|
|
54
|
+
/** Group consecutive turns by one speaker (name once per run). Default true. */
|
|
55
|
+
group?: boolean;
|
|
56
|
+
/** Show speaker avatars beside the first bubble of a run. Default true. */
|
|
57
|
+
avatars?: boolean;
|
|
58
|
+
/** Polite live region (new turns are announced). Default true. */
|
|
59
|
+
live?: boolean;
|
|
60
|
+
/** Follow the live edge + JumpToLatest. Default true. */
|
|
61
|
+
follow?: boolean;
|
|
62
|
+
/** Accessible name of the log. */
|
|
63
|
+
ariaLabel?: string;
|
|
64
|
+
/** Turn timestamp text; default formatTurnTime (offset "1:05" / clock "10:42 AM"). */
|
|
65
|
+
formatTime?: (at: TranscriptTurn['at']) => string | null;
|
|
66
|
+
/** Shown when there are no turns. */
|
|
67
|
+
emptyText?: string;
|
|
68
|
+
/** Extra line under a bubble (latency, a flag). Words, not colour alone. */
|
|
69
|
+
meta?: Snippet<[TranscriptTurn]>;
|
|
70
|
+
/** Hover / focus toolbar for a bubble. None by default. */
|
|
71
|
+
actions?: Snippet<[TranscriptTurn]>;
|
|
72
|
+
/** Makes every bubble a button (e.g. seek a recording to `turn.at`). */
|
|
73
|
+
onTurnClick?: (turn: TranscriptTurn) => void;
|
|
74
|
+
/** The turn playing now (recordings): marked with aria-current. */
|
|
75
|
+
activeId?: string | number | null;
|
|
76
|
+
/** Height bound of the scroller (CSS). Default: fill the parent. */
|
|
77
|
+
maxHeight?: string;
|
|
78
|
+
class?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let {
|
|
82
|
+
turns,
|
|
83
|
+
speakers,
|
|
84
|
+
streaming = false,
|
|
85
|
+
group = true,
|
|
86
|
+
avatars = true,
|
|
87
|
+
live = true,
|
|
88
|
+
follow = true,
|
|
89
|
+
ariaLabel = 'Transcript',
|
|
90
|
+
formatTime = (at) => formatTurnTime(at),
|
|
91
|
+
emptyText = 'Waiting for the first words.',
|
|
92
|
+
meta,
|
|
93
|
+
actions,
|
|
94
|
+
onTurnClick,
|
|
95
|
+
activeId = null,
|
|
96
|
+
maxHeight,
|
|
97
|
+
class: className = ''
|
|
98
|
+
}: Props = $props();
|
|
99
|
+
|
|
100
|
+
const rows = $derived(buildTranscriptRows(turns, speakers, group));
|
|
101
|
+
|
|
102
|
+
let stick = $state<StickState>({ atBottom: true, pinnedUp: false, unread: 0 });
|
|
103
|
+
const scroller = createStickToBottom({ onState: (s) => (stick = s) });
|
|
104
|
+
|
|
105
|
+
// Follow new turns and a growing live line (gated: never against the reader).
|
|
106
|
+
$effect(() => {
|
|
107
|
+
const last = turns[turns.length - 1];
|
|
108
|
+
void turns.length;
|
|
109
|
+
void last?.text?.length;
|
|
110
|
+
if (follow) scroller.requestStick();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/** A new bubble slides in from its own side: opacity + 8px, on the motion tokens. */
|
|
114
|
+
function bubbleIn(node: Element, { side }: { side: TranscriptSide }) {
|
|
115
|
+
// Svelte runs `css` transitions through Element.animate; plain jsdom
|
|
116
|
+
// (consumers' component tests) has none, so skip the motion there.
|
|
117
|
+
if (typeof node.animate !== 'function') return { duration: 0 };
|
|
118
|
+
const { delay, duration, easing } = resolveMotionTiming(node, {}, {
|
|
119
|
+
duration: 'enter',
|
|
120
|
+
easing: 'glide'
|
|
121
|
+
});
|
|
122
|
+
const rtl = typeof getComputedStyle === 'function' && getComputedStyle(node).direction === 'rtl';
|
|
123
|
+
const sign = (side === 'end' ? 1 : -1) * (rtl ? -1 : 1);
|
|
124
|
+
return {
|
|
125
|
+
delay,
|
|
126
|
+
duration,
|
|
127
|
+
easing,
|
|
128
|
+
css: (t: number, u: number) =>
|
|
129
|
+
`opacity: ${t}; transform: translate(${(u * 8 * sign).toFixed(2)}px, ${(u * 4).toFixed(2)}px);`
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function placeholderFor(turn: TranscriptTurn, name: string): string {
|
|
134
|
+
return turn.placeholder ?? `${name} spoke`;
|
|
135
|
+
}
|
|
136
|
+
</script>
|
|
137
|
+
|
|
138
|
+
{#snippet bubbleBody(row: (typeof rows)[number], isLast: boolean)}
|
|
139
|
+
{@const name = row.speaker.name}
|
|
140
|
+
{@const empty = !row.turn.text || !row.turn.text.trim()}
|
|
141
|
+
{#if !row.first}<span class="sr-only">{name}:</span>{/if}
|
|
142
|
+
<!-- Phrasing content only (a bubble may be a <button>): spans, and
|
|
143
|
+
StreamedText's block markup only on a non-button bubble. -->
|
|
144
|
+
{#if streaming && isLast && !empty && !onTurnClick}
|
|
145
|
+
<StreamedText text={row.turn.text ?? ''} streaming split="line" class="ct-words" />
|
|
146
|
+
{:else if empty}
|
|
147
|
+
<span class="ct-words ct-placeholder">{placeholderFor(row.turn, name)}</span>
|
|
148
|
+
{:else}
|
|
149
|
+
<span class="ct-words">{row.turn.text}</span>
|
|
150
|
+
{/if}
|
|
151
|
+
{/snippet}
|
|
152
|
+
|
|
153
|
+
<div class="ct relative flex min-h-0 flex-col {className}" style:max-height={maxHeight}>
|
|
154
|
+
<!-- A scrollable region must be reachable by keyboard to scroll it. -->
|
|
155
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
156
|
+
<div
|
|
157
|
+
use:scroller.action
|
|
158
|
+
class="ct-scroll min-h-0 flex-1 overflow-y-auto"
|
|
159
|
+
role="log"
|
|
160
|
+
aria-live={live ? 'polite' : 'off'}
|
|
161
|
+
aria-label={ariaLabel}
|
|
162
|
+
tabindex="0"
|
|
163
|
+
>
|
|
164
|
+
{#if rows.length === 0}
|
|
165
|
+
<p class="ct-empty">{emptyText}</p>
|
|
166
|
+
{:else}
|
|
167
|
+
<ol class="ct-list">
|
|
168
|
+
{#each rows as row, i (row.turn.id)}
|
|
169
|
+
{@const isLast = i === rows.length - 1}
|
|
170
|
+
{@const time = formatTime(row.turn.at)}
|
|
171
|
+
<li
|
|
172
|
+
class="ct-row"
|
|
173
|
+
class:ct-end={row.side === 'end'}
|
|
174
|
+
class:ct-first={row.first}
|
|
175
|
+
data-tone={row.tone}
|
|
176
|
+
aria-current={activeId != null && row.turn.id === activeId ? 'true' : undefined}
|
|
177
|
+
in:bubbleIn={{ side: row.side }}
|
|
178
|
+
>
|
|
179
|
+
{#if avatars}
|
|
180
|
+
<div class="ct-avatar" aria-hidden="true">
|
|
181
|
+
{#if row.first}
|
|
182
|
+
<Avatar name={row.speaker.name} avatarUrl={row.speaker.avatarUrl} size="small" />
|
|
183
|
+
{/if}
|
|
184
|
+
</div>
|
|
185
|
+
{/if}
|
|
186
|
+
<div class="ct-stack">
|
|
187
|
+
{#if row.first}
|
|
188
|
+
<div class="ct-head">
|
|
189
|
+
<span class="ct-name">{row.speaker.name}</span>
|
|
190
|
+
{#if time}
|
|
191
|
+
<time class="ct-time" datetime={turnDatetime(row.turn.at)}>{time}</time>
|
|
192
|
+
{/if}
|
|
193
|
+
</div>
|
|
194
|
+
{/if}
|
|
195
|
+
<div class="ct-bubble-wrap">
|
|
196
|
+
{#if onTurnClick}
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
class="ct-bubble ct-bubble-btn"
|
|
200
|
+
class:ct-tail={row.last}
|
|
201
|
+
onclick={() => onTurnClick(row.turn)}
|
|
202
|
+
>
|
|
203
|
+
{@render bubbleBody(row, isLast)}
|
|
204
|
+
</button>
|
|
205
|
+
{:else}
|
|
206
|
+
<div class="ct-bubble" class:ct-tail={row.last}>
|
|
207
|
+
{@render bubbleBody(row, isLast)}
|
|
208
|
+
</div>
|
|
209
|
+
{/if}
|
|
210
|
+
{#if !row.first && time}
|
|
211
|
+
<time class="ct-time ct-time-hover" datetime={turnDatetime(row.turn.at)}>{time}</time>
|
|
212
|
+
{/if}
|
|
213
|
+
{#if actions}
|
|
214
|
+
<div class="ct-actions">{@render actions(row.turn)}</div>
|
|
215
|
+
{/if}
|
|
216
|
+
</div>
|
|
217
|
+
{#if meta}
|
|
218
|
+
<div class="ct-meta">{@render meta(row.turn)}</div>
|
|
219
|
+
{/if}
|
|
220
|
+
</div>
|
|
221
|
+
</li>
|
|
222
|
+
{/each}
|
|
223
|
+
</ol>
|
|
224
|
+
{/if}
|
|
225
|
+
</div>
|
|
226
|
+
{#if follow}
|
|
227
|
+
<JumpToLatest
|
|
228
|
+
visible={!stick.atBottom}
|
|
229
|
+
unreadCount={stick.unread}
|
|
230
|
+
label="Jump to the latest turn"
|
|
231
|
+
onclick={scroller.jumpToLatest}
|
|
232
|
+
/>
|
|
233
|
+
{/if}
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<style>
|
|
237
|
+
.ct-scroll {
|
|
238
|
+
padding: 0.5rem;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.ct-empty {
|
|
242
|
+
margin: 0;
|
|
243
|
+
padding: 0.5rem;
|
|
244
|
+
font-size: 0.875rem;
|
|
245
|
+
color: color-mix(in oklch, var(--color-base-content) 80%, transparent);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.ct-list {
|
|
249
|
+
display: flex;
|
|
250
|
+
flex-direction: column;
|
|
251
|
+
gap: 0.125rem;
|
|
252
|
+
margin: 0;
|
|
253
|
+
padding: 0;
|
|
254
|
+
list-style: none;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.ct-row {
|
|
258
|
+
display: flex;
|
|
259
|
+
align-items: flex-start;
|
|
260
|
+
gap: 0.5rem;
|
|
261
|
+
max-width: 100%;
|
|
262
|
+
}
|
|
263
|
+
.ct-row.ct-first {
|
|
264
|
+
margin-block-start: 0.5rem;
|
|
265
|
+
}
|
|
266
|
+
.ct-row.ct-first:first-child {
|
|
267
|
+
margin-block-start: 0;
|
|
268
|
+
}
|
|
269
|
+
.ct-row.ct-end {
|
|
270
|
+
flex-direction: row-reverse;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.ct-avatar {
|
|
274
|
+
flex: none;
|
|
275
|
+
width: 2rem;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.ct-stack {
|
|
279
|
+
display: flex;
|
|
280
|
+
min-width: 0;
|
|
281
|
+
max-width: min(80%, 36rem);
|
|
282
|
+
flex-direction: column;
|
|
283
|
+
align-items: flex-start;
|
|
284
|
+
gap: 0.125rem;
|
|
285
|
+
}
|
|
286
|
+
.ct-end .ct-stack {
|
|
287
|
+
align-items: flex-end;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.ct-head {
|
|
291
|
+
display: flex;
|
|
292
|
+
align-items: baseline;
|
|
293
|
+
gap: 0.5rem;
|
|
294
|
+
padding-inline: 0.25rem;
|
|
295
|
+
font-size: 0.75rem;
|
|
296
|
+
}
|
|
297
|
+
.ct-end .ct-head {
|
|
298
|
+
flex-direction: row-reverse;
|
|
299
|
+
}
|
|
300
|
+
.ct-name {
|
|
301
|
+
font-weight: 600;
|
|
302
|
+
color: var(--color-base-content);
|
|
303
|
+
}
|
|
304
|
+
.ct-time {
|
|
305
|
+
font-variant-numeric: tabular-nums;
|
|
306
|
+
font-size: 0.75rem;
|
|
307
|
+
color: color-mix(in oklch, var(--color-base-content) 75%, transparent);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.ct-bubble-wrap {
|
|
311
|
+
position: relative;
|
|
312
|
+
display: flex;
|
|
313
|
+
max-width: 100%;
|
|
314
|
+
align-items: center;
|
|
315
|
+
gap: 0.375rem;
|
|
316
|
+
}
|
|
317
|
+
.ct-end .ct-bubble-wrap {
|
|
318
|
+
flex-direction: row-reverse;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.ct-bubble {
|
|
322
|
+
--ct-tint: var(--color-base-200);
|
|
323
|
+
min-width: 0;
|
|
324
|
+
border-radius: var(--radius-box, 0.75rem);
|
|
325
|
+
background: var(--ct-tint);
|
|
326
|
+
color: var(--color-base-content);
|
|
327
|
+
padding: 0.375rem 0.75rem;
|
|
328
|
+
font-size: 0.875rem;
|
|
329
|
+
line-height: 1.4;
|
|
330
|
+
text-align: start;
|
|
331
|
+
overflow-wrap: anywhere;
|
|
332
|
+
}
|
|
333
|
+
[data-tone='primary'] .ct-bubble {
|
|
334
|
+
--ct-tint: color-mix(in oklch, var(--color-primary) 16%, var(--color-base-100));
|
|
335
|
+
}
|
|
336
|
+
[data-tone='secondary'] .ct-bubble {
|
|
337
|
+
--ct-tint: color-mix(in oklch, var(--color-secondary) 16%, var(--color-base-100));
|
|
338
|
+
}
|
|
339
|
+
[data-tone='accent'] .ct-bubble {
|
|
340
|
+
--ct-tint: color-mix(in oklch, var(--color-accent) 16%, var(--color-base-100));
|
|
341
|
+
}
|
|
342
|
+
[data-tone='info'] .ct-bubble {
|
|
343
|
+
--ct-tint: color-mix(in oklch, var(--color-info) 16%, var(--color-base-100));
|
|
344
|
+
}
|
|
345
|
+
/* The run's last bubble points at its speaker: a tighter corner on their side. */
|
|
346
|
+
.ct-bubble.ct-tail {
|
|
347
|
+
border-end-start-radius: 0.25rem;
|
|
348
|
+
}
|
|
349
|
+
.ct-end .ct-bubble.ct-tail {
|
|
350
|
+
border-end-start-radius: var(--radius-box, 0.75rem);
|
|
351
|
+
border-end-end-radius: 0.25rem;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.ct-bubble-btn {
|
|
355
|
+
cursor: pointer;
|
|
356
|
+
border: 0;
|
|
357
|
+
font: inherit;
|
|
358
|
+
transition: background-color var(--motion-fast, 120ms) ease;
|
|
359
|
+
}
|
|
360
|
+
.ct-bubble-btn:hover {
|
|
361
|
+
--ct-tint: color-mix(in oklch, var(--color-base-content) 10%, var(--color-base-100));
|
|
362
|
+
}
|
|
363
|
+
.ct-bubble-btn:focus-visible {
|
|
364
|
+
outline: 2px solid var(--color-primary);
|
|
365
|
+
outline-offset: 2px;
|
|
366
|
+
}
|
|
367
|
+
.ct-row[aria-current='true'] .ct-bubble {
|
|
368
|
+
box-shadow: inset 0 0 0 2px var(--color-primary);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.ct-bubble :global(.ct-words) {
|
|
372
|
+
display: block;
|
|
373
|
+
margin: 0;
|
|
374
|
+
white-space: pre-wrap;
|
|
375
|
+
}
|
|
376
|
+
.ct-placeholder {
|
|
377
|
+
font-style: italic;
|
|
378
|
+
color: color-mix(in oklch, var(--color-base-content) 80%, transparent);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/* Grouped bubbles: the time and the host's actions appear on hover / focus. */
|
|
382
|
+
.ct-time-hover,
|
|
383
|
+
.ct-actions {
|
|
384
|
+
flex: none;
|
|
385
|
+
opacity: 0;
|
|
386
|
+
transition: opacity var(--motion-fast, 120ms) ease;
|
|
387
|
+
}
|
|
388
|
+
.ct-row:hover .ct-time-hover,
|
|
389
|
+
.ct-row:focus-within .ct-time-hover,
|
|
390
|
+
.ct-row:hover .ct-actions,
|
|
391
|
+
.ct-row:focus-within .ct-actions {
|
|
392
|
+
opacity: 1;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
.ct-meta {
|
|
396
|
+
padding-inline: 0.25rem;
|
|
397
|
+
font-size: 0.75rem;
|
|
398
|
+
color: color-mix(in oklch, var(--color-base-content) 80%, transparent);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
@media (hover: none) {
|
|
402
|
+
.ct-time-hover,
|
|
403
|
+
.ct-actions {
|
|
404
|
+
opacity: 1;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
@media (prefers-reduced-motion: reduce) {
|
|
408
|
+
.ct-time-hover,
|
|
409
|
+
.ct-actions,
|
|
410
|
+
.ct-bubble-btn {
|
|
411
|
+
transition: none;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
</style>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type TranscriptSpeaker, type TranscriptTurn } from './chatTranscript';
|
|
3
|
+
interface Props {
|
|
4
|
+
turns: TranscriptTurn[];
|
|
5
|
+
speakers: TranscriptSpeaker[];
|
|
6
|
+
/** The last turn is still arriving: reveal it per line with a caret. */
|
|
7
|
+
streaming?: boolean;
|
|
8
|
+
/** Group consecutive turns by one speaker (name once per run). Default true. */
|
|
9
|
+
group?: boolean;
|
|
10
|
+
/** Show speaker avatars beside the first bubble of a run. Default true. */
|
|
11
|
+
avatars?: boolean;
|
|
12
|
+
/** Polite live region (new turns are announced). Default true. */
|
|
13
|
+
live?: boolean;
|
|
14
|
+
/** Follow the live edge + JumpToLatest. Default true. */
|
|
15
|
+
follow?: boolean;
|
|
16
|
+
/** Accessible name of the log. */
|
|
17
|
+
ariaLabel?: string;
|
|
18
|
+
/** Turn timestamp text; default formatTurnTime (offset "1:05" / clock "10:42 AM"). */
|
|
19
|
+
formatTime?: (at: TranscriptTurn['at']) => string | null;
|
|
20
|
+
/** Shown when there are no turns. */
|
|
21
|
+
emptyText?: string;
|
|
22
|
+
/** Extra line under a bubble (latency, a flag). Words, not colour alone. */
|
|
23
|
+
meta?: Snippet<[TranscriptTurn]>;
|
|
24
|
+
/** Hover / focus toolbar for a bubble. None by default. */
|
|
25
|
+
actions?: Snippet<[TranscriptTurn]>;
|
|
26
|
+
/** Makes every bubble a button (e.g. seek a recording to `turn.at`). */
|
|
27
|
+
onTurnClick?: (turn: TranscriptTurn) => void;
|
|
28
|
+
/** The turn playing now (recordings): marked with aria-current. */
|
|
29
|
+
activeId?: string | number | null;
|
|
30
|
+
/** Height bound of the scroller (CSS). Default: fill the parent. */
|
|
31
|
+
maxHeight?: string;
|
|
32
|
+
class?: string;
|
|
33
|
+
}
|
|
34
|
+
declare const ChatTranscript: import("svelte").Component<Props, {}, "">;
|
|
35
|
+
type ChatTranscript = ReturnType<typeof ChatTranscript>;
|
|
36
|
+
export default ChatTranscript;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure logic for `ChatTranscript` (a read-only, two-sided transcript of named
|
|
3
|
+
* speakers: a live phone call, a Scribe dictation, a meeting recording).
|
|
4
|
+
* Kept out of the component so it runs under `npm run test:unit` with no DOM.
|
|
5
|
+
*/
|
|
6
|
+
/** Which side of the thread a speaker's bubbles sit on (logical: RTL mirrors). */
|
|
7
|
+
export type TranscriptSide = 'start' | 'end';
|
|
8
|
+
export interface TranscriptSpeaker {
|
|
9
|
+
/** Matches `TranscriptTurn.speaker`. */
|
|
10
|
+
id: string;
|
|
11
|
+
/** Shown above the speaker's first bubble in a run: "Dana Testwell", "Ruby". */
|
|
12
|
+
name: string;
|
|
13
|
+
/** Default: the first speaker listed sits at `start`, the second at `end`, the rest at `start`. */
|
|
14
|
+
side?: TranscriptSide;
|
|
15
|
+
/** Portrait URL; initials from `name` otherwise. */
|
|
16
|
+
avatarUrl?: string | null;
|
|
17
|
+
/**
|
|
18
|
+
* Bubble colour, as a token family. Default: `neutral` (base-200) on the
|
|
19
|
+
* start side, `primary` (a primary tint) on the end side.
|
|
20
|
+
*/
|
|
21
|
+
tone?: TranscriptTone;
|
|
22
|
+
}
|
|
23
|
+
export type TranscriptTone = 'neutral' | 'primary' | 'secondary' | 'accent' | 'info';
|
|
24
|
+
export interface TranscriptTurn {
|
|
25
|
+
/** Stable key: never reused for a different turn. */
|
|
26
|
+
id: string | number;
|
|
27
|
+
/** A `TranscriptSpeaker.id`. An unknown id renders at `start` under its own id. */
|
|
28
|
+
speaker: string;
|
|
29
|
+
/**
|
|
30
|
+
* The words. `null` or blank renders `placeholder` in italics (a line that
|
|
31
|
+
* keeps no words: "Ruby spoke").
|
|
32
|
+
*/
|
|
33
|
+
text: string | null;
|
|
34
|
+
/**
|
|
35
|
+
* When it was said: a number is ms into the recording ("1:05"); a string
|
|
36
|
+
* is an ISO instant (local clock time). Omit for none.
|
|
37
|
+
*/
|
|
38
|
+
at?: number | string | null;
|
|
39
|
+
/** Shown when `text` is empty. Default: "<name> spoke". */
|
|
40
|
+
placeholder?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface TranscriptRow {
|
|
43
|
+
turn: TranscriptTurn;
|
|
44
|
+
speaker: TranscriptSpeaker;
|
|
45
|
+
side: TranscriptSide;
|
|
46
|
+
tone: TranscriptTone;
|
|
47
|
+
/** First bubble of a run by one speaker: carries the name (and avatar). */
|
|
48
|
+
first: boolean;
|
|
49
|
+
/** Last bubble of a run: carries the bubble tail. */
|
|
50
|
+
last: boolean;
|
|
51
|
+
}
|
|
52
|
+
/** Speaker id → side, by the rule documented on `TranscriptSpeaker.side`. */
|
|
53
|
+
export declare function resolveSpeakerSides(speakers: readonly TranscriptSpeaker[]): Map<string, TranscriptSide>;
|
|
54
|
+
/**
|
|
55
|
+
* The rows the component draws: each turn with its speaker, side and tone,
|
|
56
|
+
* and whether it opens or closes a run. `group: false` makes every turn its
|
|
57
|
+
* own run (a name on every bubble).
|
|
58
|
+
*/
|
|
59
|
+
export declare function buildTranscriptRows(turns: readonly TranscriptTurn[], speakers: readonly TranscriptSpeaker[], group?: boolean): TranscriptRow[];
|
|
60
|
+
/** "m:ss" (or "h:mm:ss") for an offset in ms; negative or non-finite → null. */
|
|
61
|
+
export declare function formatOffset(ms: number): string | null;
|
|
62
|
+
/**
|
|
63
|
+
* The default timestamp: an offset as "1:05", an ISO instant as the local
|
|
64
|
+
* clock ("10:42 AM"), anything unreadable as null (nothing is shown).
|
|
65
|
+
*/
|
|
66
|
+
export declare function formatTurnTime(at: number | string | null | undefined, locale?: string): string | null;
|
|
67
|
+
/** The machine-readable `datetime` for a `<time>`: ISO stays ISO, an offset becomes a duration. */
|
|
68
|
+
export declare function turnDatetime(at: number | string | null | undefined): string | undefined;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure logic for `ChatTranscript` (a read-only, two-sided transcript of named
|
|
3
|
+
* speakers: a live phone call, a Scribe dictation, a meeting recording).
|
|
4
|
+
* Kept out of the component so it runs under `npm run test:unit` with no DOM.
|
|
5
|
+
*/
|
|
6
|
+
/** Speaker id → side, by the rule documented on `TranscriptSpeaker.side`. */
|
|
7
|
+
export function resolveSpeakerSides(speakers) {
|
|
8
|
+
const sides = new Map();
|
|
9
|
+
speakers.forEach((s, i) => {
|
|
10
|
+
sides.set(s.id, s.side ?? (i === 1 ? 'end' : 'start'));
|
|
11
|
+
});
|
|
12
|
+
return sides;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The rows the component draws: each turn with its speaker, side and tone,
|
|
16
|
+
* and whether it opens or closes a run. `group: false` makes every turn its
|
|
17
|
+
* own run (a name on every bubble).
|
|
18
|
+
*/
|
|
19
|
+
export function buildTranscriptRows(turns, speakers, group = true) {
|
|
20
|
+
const byId = new Map(speakers.map((s) => [s.id, s]));
|
|
21
|
+
const sides = resolveSpeakerSides(speakers);
|
|
22
|
+
const rows = turns.map((turn) => {
|
|
23
|
+
const speaker = byId.get(turn.speaker) ?? { id: turn.speaker, name: turn.speaker };
|
|
24
|
+
const side = sides.get(turn.speaker) ?? 'start';
|
|
25
|
+
const tone = speaker.tone ?? (side === 'end' ? 'primary' : 'neutral');
|
|
26
|
+
return { turn, speaker, side, tone, first: true, last: true };
|
|
27
|
+
});
|
|
28
|
+
if (!group)
|
|
29
|
+
return rows;
|
|
30
|
+
for (let i = 1; i < rows.length; i++) {
|
|
31
|
+
if (rows[i].turn.speaker === rows[i - 1].turn.speaker) {
|
|
32
|
+
rows[i].first = false;
|
|
33
|
+
rows[i - 1].last = false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return rows;
|
|
37
|
+
}
|
|
38
|
+
/** "m:ss" (or "h:mm:ss") for an offset in ms; negative or non-finite → null. */
|
|
39
|
+
export function formatOffset(ms) {
|
|
40
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
41
|
+
return null;
|
|
42
|
+
const total = Math.floor(ms / 1000);
|
|
43
|
+
const h = Math.floor(total / 3600);
|
|
44
|
+
const m = Math.floor((total % 3600) / 60);
|
|
45
|
+
const s = String(total % 60).padStart(2, '0');
|
|
46
|
+
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The default timestamp: an offset as "1:05", an ISO instant as the local
|
|
50
|
+
* clock ("10:42 AM"), anything unreadable as null (nothing is shown).
|
|
51
|
+
*/
|
|
52
|
+
export function formatTurnTime(at, locale) {
|
|
53
|
+
if (at == null || at === '')
|
|
54
|
+
return null;
|
|
55
|
+
if (typeof at === 'number')
|
|
56
|
+
return formatOffset(at);
|
|
57
|
+
const d = new Date(at);
|
|
58
|
+
if (Number.isNaN(d.getTime()))
|
|
59
|
+
return null;
|
|
60
|
+
return d.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' });
|
|
61
|
+
}
|
|
62
|
+
/** The machine-readable `datetime` for a `<time>`: ISO stays ISO, an offset becomes a duration. */
|
|
63
|
+
export function turnDatetime(at) {
|
|
64
|
+
if (at == null || at === '')
|
|
65
|
+
return undefined;
|
|
66
|
+
if (typeof at === 'number') {
|
|
67
|
+
if (!Number.isFinite(at) || at < 0)
|
|
68
|
+
return undefined;
|
|
69
|
+
return `PT${(at / 1000).toFixed(3).replace(/\.?0+$/, '')}S`;
|
|
70
|
+
}
|
|
71
|
+
return Number.isNaN(new Date(at).getTime()) ? undefined : at;
|
|
72
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -191,6 +191,9 @@ export { default as MessageItem } from './components/messaging/MessageItem.svelt
|
|
|
191
191
|
export { default as MessageThread } from './components/messaging/MessageThread.svelte';
|
|
192
192
|
export { default as QuotedMessageDisplay } from './components/messaging/QuotedMessageDisplay.svelte';
|
|
193
193
|
export { default as ThreadPanel } from './components/messaging/ThreadPanel.svelte';
|
|
194
|
+
export { default as ChatTranscript } from './components/messaging/ChatTranscript.svelte';
|
|
195
|
+
export { buildTranscriptRows, resolveSpeakerSides, formatTurnTime, formatOffset, turnDatetime } from './components/messaging/chatTranscript';
|
|
196
|
+
export type { TranscriptSpeaker, TranscriptTurn, TranscriptRow, TranscriptSide, TranscriptTone } from './components/messaging/chatTranscript';
|
|
194
197
|
export { default as CreateChannelModal } from './components/messaging/CreateChannelModal.svelte';
|
|
195
198
|
export { default as StartDMModal } from './components/messaging/StartDMModal.svelte';
|
|
196
199
|
export { default as ChannelInfoModal } from './components/messaging/ChannelInfoModal.svelte';
|
package/dist/index.js
CHANGED
|
@@ -229,6 +229,9 @@ export { default as MessageItem } from './components/messaging/MessageItem.svelt
|
|
|
229
229
|
export { default as MessageThread } from './components/messaging/MessageThread.svelte';
|
|
230
230
|
export { default as QuotedMessageDisplay } from './components/messaging/QuotedMessageDisplay.svelte';
|
|
231
231
|
export { default as ThreadPanel } from './components/messaging/ThreadPanel.svelte';
|
|
232
|
+
// Read-only, two-sided transcript of named speakers (calls, dictation, meetings).
|
|
233
|
+
export { default as ChatTranscript } from './components/messaging/ChatTranscript.svelte';
|
|
234
|
+
export { buildTranscriptRows, resolveSpeakerSides, formatTurnTime, formatOffset, turnDatetime } from './components/messaging/chatTranscript';
|
|
232
235
|
export { default as CreateChannelModal } from './components/messaging/CreateChannelModal.svelte';
|
|
233
236
|
export { default as StartDMModal } from './components/messaging/StartDMModal.svelte';
|
|
234
237
|
export { default as ChannelInfoModal } from './components/messaging/ChannelInfoModal.svelte';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dialog focus resolution (jatui-fo6o8)
|
|
3
|
+
*
|
|
4
|
+
* Shared rule for every surface that opens an overlay dialog/panel
|
|
5
|
+
* (Modal, Drawer, BottomSheet, Confirm*, InputDialog): on open, focus in
|
|
6
|
+
* this order —
|
|
7
|
+
*
|
|
8
|
+
* 1. an element with [data-autofocus] or the `autofocus` attribute inside
|
|
9
|
+
* the container (a consumer's explicit opt-in);
|
|
10
|
+
* 2. else the first ENABLED, RENDERED text-entry control in the body
|
|
11
|
+
* (not the header close button — buttons never qualify anyway): a
|
|
12
|
+
* text-like <input>, an enabled <textarea>, or a contenteditable
|
|
13
|
+
* element;
|
|
14
|
+
* 3. else the container element itself (today's behaviour — keeps Escape
|
|
15
|
+
* working for read-only / confirm dialogs, see Modal's Escape note).
|
|
16
|
+
*
|
|
17
|
+
* "Rendered" matters: a focus() on an element that is not actually visible
|
|
18
|
+
* (display:none, inside a collapsed <details>, a [hidden]/[inert] ancestor,
|
|
19
|
+
* or visibility:hidden) is a silent no-op that leaves focus on <body>, which
|
|
20
|
+
* breaks the dialog's own Escape handling (Modal owns Escape on the dialog
|
|
21
|
+
* element). Unrendered candidates are skipped, and after focusing the target
|
|
22
|
+
* is verified to actually hold focus — else the fallback/container is
|
|
23
|
+
* focused instead.
|
|
24
|
+
*
|
|
25
|
+
* WAI-ARIA APG dialog pattern: focus the first field the user is expected
|
|
26
|
+
* to fill. `initialFocus` overrides the whole order —
|
|
27
|
+
*
|
|
28
|
+
* - `HTMLElement` / `string` (a CSS selector, resolved inside the
|
|
29
|
+
* container): focus exactly that; falls through to the default order
|
|
30
|
+
* when the selector matches nothing.
|
|
31
|
+
* - `'dialog'`: always focus the container (old behaviour — right for
|
|
32
|
+
* read-only / confirm dialogs).
|
|
33
|
+
* - `'first-input'` (default): the 1→2→3 order above.
|
|
34
|
+
*/
|
|
35
|
+
export type InitialFocus = HTMLElement | string | 'dialog' | 'first-input';
|
|
36
|
+
export interface FocusOptions {
|
|
37
|
+
/** Explicit target. Default `'first-input'`. */
|
|
38
|
+
initialFocus?: InitialFocus | null;
|
|
39
|
+
/** Element to focus when nothing matches. Default the container itself. */
|
|
40
|
+
fallback?: HTMLElement | null;
|
|
41
|
+
}
|
|
42
|
+
/** True for an element a user can type into: text-like input, enabled textarea, contenteditable. */
|
|
43
|
+
export declare function isTextEntryControl(el: Element): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the element a dialog should focus when it opens, per the order
|
|
46
|
+
* above. Returns null when nothing matches and there is no fallback.
|
|
47
|
+
*/
|
|
48
|
+
export declare function resolveDialogFocusTarget(container: HTMLElement | null, opts?: FocusOptions): HTMLElement | null;
|
|
49
|
+
/**
|
|
50
|
+
* Focus a resolved target on the next frame (after the open transition has
|
|
51
|
+
* mounted the content). Mirrors the previous `requestAnimationFrame` timing
|
|
52
|
+
* so focus lands on real DOM, not a mounting tree.
|
|
53
|
+
*/
|
|
54
|
+
export declare function focusDialogTarget(container: HTMLElement | null, opts?: FocusOptions, rq?: typeof requestAnimationFrame): void;
|