@joewinke/jatui 0.56.0 → 0.58.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 CHANGED
@@ -83,7 +83,7 @@ Then import:
83
83
  | `StatGroup` | Lays out StatCards: `cards` (responsive auto-fit grid) or `joined` (one bordered panel with hairline dividers) — the replacement for raw DaisyUI `stats` markup. | `columns?`, `minWidth?` ('12rem'), `variant?`, `ariaLabel?`, `children` |
84
84
  | `ChartCard` | "Headline + period switch + chart" shell that draws NO chart (bring layerchart / d3 / SVG). The `chart` snippet gets `{ period, hover, hovered }`; call `hover({value, label, delta?})` as the pointer moves and the headline tweens to that point and the caption shows its label; `hover(null)` (or pointer leave) restores the total. Periods use SegmentedControl. | `label`, `value`, `format?`, `delta?`, `invertDelta?`, `caption?`, `periods?`, `period` (bindable), `onPeriodChange?`, `chartHeight?`, `loading?`, `chart` (snippet), `actions?`/`footer?` (snippets) |
85
85
  | `BarList` | Ranked horizontal bars ("top pages / top callers"): label + value as text, a decorative token-tinted bar sized against the max, optional `href` or `onSelect` per row, `hint`, per-row `tone`, `limit` + "Show N more". Pure HTML/CSS, no chart lib. | `items: BarListItem[]`, `sort?` (`desc`), `limit?`, `format?`, `formatValue?(item, total)`, `labelHeader?`/`valueHeader?`, `tone?`, `size?`, `onSelect?`, `label?` (snippet) |
86
- | `ActivityHeatmap` | GitHub-style activity-per-day calendar (calls/day, tasks closed/day): 7 weekday rows × week columns, default range = last 12 months, `weekStart`, 4-step `color-mix` ramp from one theme `tone` (no hex), one floating tooltip, legend + summary ("1,204 calls in the last year"). `role="grid"` with ONE tab stop: arrows move by day (↑↓) / week (←→), PgUp/PgDn 4 weeks, Home/End row ends, mod+Home/End range ends, Enter/Space select. Pure layout helpers exported (`buildHeatmap`, `moveHeatFocus`). | `data` (`{date, count}[]` or `getDate`/`getCount`), `start?`/`end?`, `weekStart?`, `unit?` ('call' or ['call','calls']), `formatLabel?`, `tone?`, `levels?`, `levelOf?`, `compact?`, `showLegend?`/`showWeekdays?`/`showMonths?`, `summary?`, `selected` (bindable), `onSelectDay?(date, count)`, `locale?` |
86
+ | `ActivityHeatmap` | GitHub-style activity-per-day calendar (calls/day, tasks closed/day): 7 weekday rows × week columns, default range = last 12 months, `weekStart`, 4-step `color-mix` ramp from one theme `tone` (no hex), one floating tooltip, legend + summary ("1,204 calls in the last year"). `role="grid"` with ONE tab stop: arrows move by day (↑↓) / week (←→), PgUp/PgDn 4 weeks, Home/End row ends, mod+Home/End range ends, Enter/Space select. `fill` (default `true`) sizes the cells to the container — pure CSS (`container-type: inline-size` + cqi), clamped 8–20px (`--hm-cell-min`/`--hm-cell-max`), scrolling only below the floor and the legend parked under the grid's right edge; `fill={false}` is the legacy fixed 12px grid (and the one to pass in a shrink-to-fit host, where an inline-size container collapses). Pure layout helpers exported (`buildHeatmap`, `moveHeatFocus`). | `data` (`{date, count}[]` or `getDate`/`getCount`), `start?`/`end?`, `weekStart?`, `unit?` ('call' or ['call','calls']), `formatLabel?`, `tone?`, `levels?`, `levelOf?`, `compact?`, `fill?` (true), `showLegend?`/`showWeekdays?`/`showMonths?`, `summary?`, `selected` (bindable), `onSelectDay?(date, count)`, `locale?` |
87
87
  | `StatusBadge` | Status indicator with dot | `status: StatusValue`, `label` |
88
88
  | `Card` | Content card container | `title`, `children` (snippet) |
89
89
  | `Modal` | Dialog overlay. `zIndex` accepts `number` OR any CSS `z-index` string, including `"var(--z-lightbox)"`. **On open, focus goes to the first fillable field** (WAI-ARIA APG): a `[data-autofocus]`/`autofocus` element, else the first enabled **rendered** text-entry control (readOnly/disabled textareas excluded, unrendered candidates skipped), else the dialog itself — `initialFocus` overrides the order (`'dialog'` restores the old container focus); the focus landing is verified, else the container is focused. When the dialog is the fallback focus, **Escape is owned by the dialog element itself** (not the window handler), so `trapKeys`' stopPropagation can't swallow Esc; a Modal opened inside a host Drawer closes before the drawer. The component ships its own open-state `.modal-box` opacity rule, so it renders under DaisyUI 5 with no host global override. | `open` (bindable), `title`, `children` (snippet), `zIndex?: string \| number`, `trapKeys?`, `closeOnEscape?`, `initialFocus?` |
@@ -110,7 +110,7 @@ Then import:
110
110
 
111
111
  ### Agent surfaces (BoardUI wave 2)
112
112
 
113
- "Agent is working" UI. Free BoardUI items (agent-thinking, agent-log, composer-loader, notification-center) were ported under MIT — see `THIRD_PARTY_NOTICES.md`; AgentTaskList, Questionnaire and ContextMeter are clean-room. Demos: `/components/agent-thinking`, `agent-log`, `agent-task-list`, `questionnaire`, `context-meter`, `notification-center`, `composer-glow`, `streamed-text`.
113
+ "Agent is working" UI. Free BoardUI items (agent-thinking, agent-log, composer-loader, notification-center) were ported under MIT — see `THIRD_PARTY_NOTICES.md`; AgentTaskList, Questionnaire and ContextMeter are clean-room. Demos: `/components/agent-thinking`, `agent-log`, `agent-task-list`, `questionnaire`, `context-meter`, `notification-center`, `composer-glow`, `streamed-text`, `chat-transcript`.
114
114
 
115
115
  | Component | Purpose | Key Props |
116
116
  |-----------|---------|-----------|
@@ -124,6 +124,7 @@ Then import:
124
124
  | `NotificationCenter` | Inbox panel: title + "N unread" + "Mark all read"; a tab list with count chips (default All / Mentions / System by `category`; `bind:tab` or uncontrolled `defaultTab`; arrows / Home / End switch tabs). Items are newest first under Today / Yesterday / This week / Earlier headers (`groupNotificationsByDate`, `now` injectable). Roving focus via `createListNav`, scoped to the list: j/k/arrows move, Enter opens → `onOpen(item)` or follows `href`. Opening an item or clicking one of its actions marks it read. The host owns read state (`read`); the component also tracks read ids locally, so it works uncontrolled. The unread dot is announced (sr-only "Unread"). Status glyph or `Avatar`; `<time>` via `formatRelativeTime`, refreshed each minute. Empty state via `Empty` ("You're all caught up"). | `items: NotificationItem[]`, `tabs?: {id, label, filter?}[]`, `tab` (bindable), `defaultTab?`, `onTabChange?`, `onOpen?`, `onMarkRead?(ids)`, `onMarkAllRead?` (omitted → `onMarkRead` gets every unread id), `groupByDate?` (true), `now?`, `maxHeight?` ('32rem'), `title?`, `emptyTitle?`, `emptyDescription?` |
125
125
  | `ComposerGlow` | Opt-in ambient rim light around a composer while the agent works: an SVG `<rect rx>` stroke with a pathLength=100 dash laps the box in three layers (crisp line, glow, bloom clipped inside; SVG `feGaussianBlur`, so iOS stays soft); a ResizeObserver keeps the viewBox equal to the px box. Token colours only (info → primary → `color-mix` midpoint → secondary → accent). Fades in/out on `active`; the lap runs only while visible. Under `data-motion="calm"` / reduced motion there is no frozen arc: it shows a static primary focus-ring tint. Paints a `--color-base-100` surface, so the wrapped composer must be transparent. | `active`, `speed?` (4.5 s/lap), `intensity?` ('subtle'\|'normal'\|'vivid'), `radius?` (px or CSS; default box radius), `class?`, `children` |
126
126
  | `StreamedText` | Streamed assistant text revealed per paragraph/line, never per token: an append-only splitter (`splitStreamedText`, index keys, code fences kept whole) mounts each block once with a short fade + un-blur on motion tokens, after which it only grows. History doesn't animate; reduced motion is static; a subtle caret shows while `streaming`. `renderBlock` lets the host render markdown per block. Recipe: show `AgentThinking` until the first *text* part exists, not while the request status is "submitted". | `text`, `streaming?`, `split?` ('paragraph'\|'line'), `as?`, `class?`, `renderBlock?: Snippet<[string, number]>` |
127
+ | `ChatTranscript` | Read-only, two-sided transcript of NAMED speakers — a live phone call (caller vs. the assistant's persona), a Scribe dictation, a meeting recording. Not a chat client (no composer/reactions/edit/delete, no HTML in the words; for a channel use `MessageThread`). Speakers sit on opposite sides (first listed `start`, second `end`, or `side`); consecutive turns group into a run (name + avatar once, tail on the last bubble; a grouped bubble carries the name sr-only). Label speakers by real names ("Dana Testwell", "Ruby"), never role words. `streaming` reveals the LAST turn per line through `StreamedText` with a caret. New turns slide in from their own side on motion tokens (history doesn't animate; calm/reduced motion honoured). Follows the live edge via `createStickToBottom`, `JumpToLatest` when scrolled away. Polite `role="log"`, keyboard-scrollable, `<time>` stamps (offset ms → "1:05", ISO → clock). Bubble tints are `color-mix` of a token family (`tone`: neutral/primary/secondary/accent/info). `meta` snippet (latency, flags — in words), `actions` snippet (hover/focus toolbar, none by default), `onTurnClick` (bubble becomes a button, e.g. seek) + `activeId`. Pure helpers: `buildTranscriptRows`, `resolveSpeakerSides`, `formatTurnTime`. | `turns: TranscriptTurn[]` (`{id, speaker, text \| null, at?, placeholder?}`), `speakers: TranscriptSpeaker[]` (`{id, name, side?, avatarUrl?, tone?}`), `streaming?`, `group?` (true), `avatars?` (true), `live?` (true), `follow?` (true), `ariaLabel?`, `formatTime?`, `emptyText?`, `meta?`/`actions?` (snippets), `onTurnClick?`, `activeId?`, `maxHeight?`, `class?` |
127
128
 
128
129
  **Upgrades in this wave (all additive):**
129
130
  - **`Steps`** (`progress/Steps.svelte`) — `collapsible?`, `collapsed?` (bindable), `summary?: string | Snippet` (default "3 steps left" / "All steps done", live while folded; the header is a real `aria-expanded` / `aria-controls` button), `orientation?` ('horizontal'\|'vertical'; vertical also shows descriptions), `activeProgress?` (0–1, a mini ProgressRing on the active node and in the folded header), `completionDelay?` + `onFinished?` (fires once after `current` passes the last step; re-arms if `current` drops back). Unset, the markup is unchanged. Helpers: `stepsSummary`, `stepsRemaining`, `isStepsFinished`, `clampStepProgress`.
@@ -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
+ }
@@ -6,6 +6,13 @@
6
6
  (color-mix from one theme colour into base-100, no hex), a single tooltip,
7
7
  a legend and a summary line. One tab stop: arrows move by day/week inside
8
8
  the grid (role="grid"), Enter/Space selects.
9
+
10
+ Fill mode (jatui-pldt4, default): the component is an inline-size container
11
+ and --hm-cell is derived from the container width in pure CSS (cqi + calc,
12
+ no ResizeObserver, no first-paint jump), so a wide card is spanned end to
13
+ end and the footer — summary left, legend right — stays as wide as the
14
+ grid instead of floating at the card edges. fill={false} keeps the exact
15
+ legacy fixed-cell markup and scroll behaviour.
9
16
  -->
10
17
  <script lang="ts" generics="T = { date: string | Date; count: number }">
11
18
  import { untrack } from 'svelte';
@@ -34,6 +41,21 @@
34
41
  levelOf?: (count: number, max: number, levels: number) => number;
35
42
  /** Smaller cells and labels. */
36
43
  compact?: boolean;
44
+ /**
45
+ * Size cells to the host's width (default `true`): `--hm-cell` becomes
46
+ * `(containerWidth - focus padding - weekday gutter) / weeks - gap`,
47
+ * clamped to `--hm-cell-min` (8px) … `--hm-cell-max` (20px) — both
48
+ * overridable in CSS. Below the floor the grid overflows and scrolls
49
+ * horizontally inside the component; above the ceiling the grid keeps its
50
+ * maximum size at the inline start and the footer (summary + legend) is
51
+ * sized to the grid's width, so the legend sits under the grid's right
52
+ * edge rather than the card's. Needs a host that gives the component a
53
+ * definite width (a card body): in a shrink-to-fit host the inline-size
54
+ * container collapses — pass `fill={false}` there. `false` keeps the
55
+ * fixed `--hm-cell` grid (the exact legacy markup) with the same
56
+ * horizontal scroll whenever the weeks don't fit.
57
+ */
58
+ fill?: boolean;
37
59
  showLegend?: boolean;
38
60
  showWeekdays?: boolean;
39
61
  showMonths?: boolean;
@@ -60,6 +82,7 @@
60
82
  levels = 4,
61
83
  levelOf,
62
84
  compact = false,
85
+ fill = true,
63
86
  showLegend = true,
64
87
  showWeekdays = true,
65
88
  showMonths = true,
@@ -165,6 +188,7 @@
165
188
  <div
166
189
  class="jatui-heatmap tone-{tone} {className}"
167
190
  class:is-compact={compact}
191
+ class:is-fill={fill}
168
192
  bind:this={wrapEl}
169
193
  style:--weeks={layout.weeks}
170
194
  >
@@ -255,6 +279,13 @@
255
279
  .jatui-heatmap {
256
280
  --hm-cell: 0.75rem;
257
281
  --hm-gap: 3px;
282
+ /* Root font size and the focus-ring padding as vars so the fill math can
283
+ * reference them without em (em inside an inherited custom property
284
+ * resolves against each USING element's font size — the weekday labels
285
+ * are 0.9em and would disagree with the cells). rem is absolute, so
286
+ * every element resolves the same px. */
287
+ --hm-fs: 0.75rem;
288
+ --hm-pad: 2px;
258
289
  --hm-tone: var(--color-primary);
259
290
  --hm-empty: color-mix(in oklch, var(--color-base-content) 8%, var(--color-base-100));
260
291
  position: relative;
@@ -263,13 +294,56 @@
263
294
  gap: var(--jatui-space-base, 8px);
264
295
  min-width: 0;
265
296
  max-width: 100%;
266
- font-size: 0.75rem;
297
+ font-size: var(--hm-fs);
267
298
  color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
268
299
  }
269
300
  .jatui-heatmap.is-compact {
270
301
  --hm-cell: 0.625rem;
302
+ --hm-fs: 0.6875rem;
271
303
  --hm-gap: 2px;
272
- font-size: 0.6875rem;
304
+ font-size: var(--hm-fs);
305
+ }
306
+
307
+ /* Fill mode (jatui-pldt4): the component is the size container; cells size
308
+ * to the width the host gives it — (container - focus padding - weekday
309
+ * gutter) / weeks - gap — clamped to a readable band (--hm-cell-min …
310
+ * --hm-cell-max). Below the floor the grid overflows and .hm-scroll scrolls
311
+ * (the floor is the only thing that triggers scrolling); above the ceiling
312
+ * the grid keeps its maximum size at the inline start and the footer is
313
+ * sized to the grid's width, so the legend lands under the grid's right
314
+ * edge, never the card's. Month labels and the weekday column keep reading
315
+ * the same --hm-cell, so everything stays on one grid. */
316
+ .jatui-heatmap.is-fill {
317
+ container-type: inline-size;
318
+ }
319
+ .jatui-heatmap.is-fill:has(.hm-weekdays) {
320
+ --hm-label-w: calc(2.25 * var(--hm-fs) + var(--hm-gap));
321
+ }
322
+ .jatui-heatmap.is-fill .hm-months {
323
+ /* A month starting in the final week column hangs its label past the
324
+ * grid's right edge; legacy let the body grow to fit it, but the fill
325
+ * grid is pinned to the container — clip the decorative label at the
326
+ * grid's edge instead of handing the scroller a phantom scrollbar. */
327
+ overflow: hidden;
328
+ }
329
+ .jatui-heatmap.is-fill :is(.hm-scroll, .hm-foot) {
330
+ --hm-cell: clamp(
331
+ var(--hm-cell-min, 8px),
332
+ calc(
333
+ (100cqi - 2 * var(--hm-pad) - var(--hm-label-w, 0px)) / var(--weeks) -
334
+ var(--hm-gap)
335
+ ),
336
+ var(--hm-cell-max, 20px)
337
+ );
338
+ }
339
+ .jatui-heatmap.is-fill .hm-foot {
340
+ width: min(
341
+ calc(
342
+ var(--hm-pad) + var(--hm-label-w, 0px) +
343
+ var(--weeks) * (var(--hm-cell) + var(--hm-gap)) - var(--hm-gap)
344
+ ),
345
+ 100%
346
+ );
273
347
  }
274
348
  .tone-secondary { --hm-tone: var(--color-secondary); }
275
349
  .tone-accent { --hm-tone: var(--color-accent); }
@@ -282,7 +356,7 @@
282
356
  overflow-x: auto;
283
357
  overscroll-behavior-x: contain;
284
358
  /* room for the focus ring */
285
- padding: 2px;
359
+ padding: var(--hm-pad);
286
360
  }
287
361
  .hm-body {
288
362
  display: inline-flex;
@@ -24,6 +24,21 @@ declare function $$render<T = {
24
24
  levelOf?: (count: number, max: number, levels: number) => number;
25
25
  /** Smaller cells and labels. */
26
26
  compact?: boolean;
27
+ /**
28
+ * Size cells to the host's width (default `true`): `--hm-cell` becomes
29
+ * `(containerWidth - focus padding - weekday gutter) / weeks - gap`,
30
+ * clamped to `--hm-cell-min` (8px) … `--hm-cell-max` (20px) — both
31
+ * overridable in CSS. Below the floor the grid overflows and scrolls
32
+ * horizontally inside the component; above the ceiling the grid keeps its
33
+ * maximum size at the inline start and the footer (summary + legend) is
34
+ * sized to the grid's width, so the legend sits under the grid's right
35
+ * edge rather than the card's. Needs a host that gives the component a
36
+ * definite width (a card body): in a shrink-to-fit host the inline-size
37
+ * container collapses — pass `fill={false}` there. `false` keeps the
38
+ * fixed `--hm-cell` grid (the exact legacy markup) with the same
39
+ * horizontal scroll whenever the weeks don't fit.
40
+ */
41
+ fill?: boolean;
27
42
  showLegend?: boolean;
28
43
  showWeekdays?: boolean;
29
44
  showMonths?: boolean;
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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.56.0",
3
+ "version": "0.58.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",