@poodle64/librarian 2026.9.14 → 2026.9.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +77 -19
  2. package/dist/client.d.ts +16 -1
  3. package/dist/client.js +91 -21
  4. package/dist/components/activity-group/activity-group.svelte +76 -8
  5. package/dist/components/agent-transcript/agent-transcript.svelte +345 -155
  6. package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +7 -0
  7. package/dist/components/artefact-card/artefact-card.svelte +30 -2
  8. package/dist/components/artefact-pane/artefact-pane.svelte +141 -50
  9. package/dist/components/composer/composer.svelte +253 -39
  10. package/dist/components/composer/composer.svelte.d.ts +5 -1
  11. package/dist/components/conversation/conversation.svelte +247 -89
  12. package/dist/components/conversation/conversation.svelte.d.ts +6 -2
  13. package/dist/components/document-pane/document-pane.svelte +216 -33
  14. package/dist/components/markdown/markdown.svelte +73 -24
  15. package/dist/components/scope-statement/scope-statement.svelte +79 -12
  16. package/dist/components/source-list/index.d.ts +2 -0
  17. package/dist/components/source-list/index.js +2 -0
  18. package/dist/components/source-list/source-list.svelte +137 -0
  19. package/dist/components/source-list/source-list.svelte.d.ts +13 -0
  20. package/dist/components/thinking-row/thinking-row.svelte +69 -18
  21. package/dist/components/tool-row/tool-row.svelte +132 -26
  22. package/dist/components/working/working.svelte +51 -7
  23. package/dist/components/working/working.svelte.d.ts +3 -0
  24. package/dist/copy.d.ts +44 -12
  25. package/dist/copy.js +77 -33
  26. package/dist/transcript.svelte.d.ts +10 -0
  27. package/dist/transcript.svelte.js +4 -1
  28. package/package.json +4 -2
@@ -13,7 +13,7 @@
13
13
  import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
14
14
  import type { Turn } from '../../transcript.svelte';
15
15
  import type { Citation, LoadDocument } from '../../citations';
16
- import { resolveCopy, type LibrarianCopy } from '../../copy';
16
+ import { DEFAULT_PERSONA, personaName, resolveCopy, type LibrarianCopy } from '../../copy';
17
17
  import { FollowScroll } from '../../follow-scroll.svelte';
18
18
  import AgentTranscript from '../agent-transcript/agent-transcript.svelte';
19
19
  import ArtefactPane from '../artefact-pane/artefact-pane.svelte';
@@ -31,9 +31,13 @@
31
31
  /** `Transcript.version` — bumped per event. Text grows in place, so a
32
32
  * count of turns is not enough to keep the scroll following. */
33
33
  version?: number;
34
- /** The persona's display name. The aria-label, the `welcome` default and
35
- * the unreachable alert all take it. */
34
+ /** Who is speaking. A slug is fine — `penny` renders as "Penny" — and
35
+ * every word this package says is composed from it: the working line,
36
+ * the failure sentences, the scope label, the composer's placeholder
37
+ * and the accessible name of the scroll region. Absent is the library's
38
+ * own Milton, which is a default rather than a hardcoding. */
36
39
  name?: string;
40
+ /** The empty state's opening line. Absent takes the persona's own. */
37
41
  welcome?: string;
38
42
  /** Up to three, shown in the empty state as tappable pills. */
39
43
  examples?: string[];
@@ -62,8 +66,8 @@
62
66
  turns,
63
67
  running,
64
68
  version = 0,
65
- name = 'Milton',
66
- welcome = `Ask ${name} a question about the library.`,
69
+ name = DEFAULT_PERSONA,
70
+ welcome,
67
71
  examples = [],
68
72
  onexample,
69
73
  scope,
@@ -75,13 +79,24 @@
75
79
  composer
76
80
  }: Props = $props();
77
81
 
78
- const words = $derived(resolveCopy(copy));
82
+ // Resolved ONCE, here, and handed down whole: every child takes `copy` and
83
+ // none of them takes the name, so there is exactly one place a persona
84
+ // turns into sentences.
85
+ const words = $derived(resolveCopy(copy, name));
86
+ const who = $derived(personaName(name));
87
+ const opening = $derived(welcome ?? words.welcome);
79
88
 
80
- // A host's own `copy.unreachable` still wins: it comes last in the spread.
81
- const effectiveCopy = $derived<Partial<LibrarianCopy>>({
82
- unreachable: `${name} can't be reached right now.`,
83
- ...copy
84
- });
89
+ /**
90
+ * When each turn arrived, for the turns this surface actually watched
91
+ * arrive.
92
+ *
93
+ * A host that persists its conversations sets `turn.at` and that wins. A
94
+ * host that does not gets a stamp the moment a turn appears, which is true
95
+ * for a live turn and deliberately absent for one read back out of
96
+ * history — stamping those with `Date.now()` would print this afternoon
97
+ * against a question asked last week.
98
+ */
99
+ let stamps = $state<Record<string, number>>({});
85
100
 
86
101
  let viewport = $state<HTMLElement | null>(null);
87
102
  let column = $state<Column>(null);
@@ -102,9 +117,12 @@
102
117
  // of reactive state re-triggers itself, which Svelte stops with
103
118
  // `effect_update_depth_exceeded` — measured, on this component. Nothing
104
119
  // renders this, so a plain variable is both correct and enough.
105
- let seen = 0;
120
+ let seen = -1;
106
121
  $effect(() => {
107
- if (turns.length > seen) follow.pin();
122
+ if (seen >= 0 && turns.length > seen) {
123
+ follow.pin();
124
+ for (const turn of turns.slice(seen)) stamps[turn.id] ??= Date.now();
125
+ }
108
126
  seen = turns.length;
109
127
  });
110
128
 
@@ -158,105 +176,245 @@
158
176
  }
159
177
  </script>
160
178
 
161
- <div class="flex min-h-0 flex-1">
162
- <div class="flex min-h-0 min-w-0 flex-1 flex-col">
179
+ <div class="ds-lib-surface">
180
+ <div class="ds-lib-main">
163
181
  <!-- The pill is positioned against the SCROLL region, not the column: the
164
182
  composer is in the column too, and a pill over the input is a pill in
165
183
  the way. -->
166
- <div class="relative flex min-h-0 flex-1 flex-col">
167
- <div
168
- bind:this={viewport}
169
- onscroll={scrolled}
170
- role="log"
171
- aria-live="polite"
172
- aria-busy={running}
173
- aria-label="Conversation with {name}"
174
- class="min-h-0 flex-1 overflow-y-auto overscroll-contain"
175
- >
176
- <div class="mx-auto flex w-full max-w-[46rem] flex-col gap-8 px-4 py-6">
177
- {#if turns.length === 0}
178
- <div class="flex flex-col items-start gap-4 py-8">
179
- <p class="text-foreground max-w-[46ch] text-lg leading-snug">{welcome}</p>
180
- {#if examples.length > 0}
181
- <ul class="flex flex-col items-start gap-2">
182
- {#each examples.slice(0, 3) as example (example)}
183
- <li class="max-w-full">
184
- <button
185
- type="button"
186
- onclick={() => onexample?.(example)}
187
- class="border-border hover:border-border-strong hover:bg-surface-2 focus-visible:ring-ring text-foreground max-w-full rounded-full border px-3.5 py-2 text-left text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
188
- >
189
- {example}
190
- </button>
191
- </li>
192
- {/each}
193
- </ul>
194
- {/if}
195
- </div>
196
- {/if}
197
-
198
- <!-- Above the first turn, and in ONE place in the markup: the empty
199
- state carries the welcome, this carries the boundary, and both
200
- sit at the top of the same column so a reader meets them in
201
- the same order whether or not they have asked anything yet. -->
202
- {#if scope}
203
- <ScopeStatement statement={scope} expanded={turns.length === 0} copy={effectiveCopy} />
204
- {/if}
205
-
206
- {#each turns as turn, index (turn.id)}
207
- <AgentTranscript
208
- question={turn.question}
209
- blocks={turn.blocks}
210
- outcome={turn.outcome}
211
- running={running && index === turns.length - 1}
212
- citations={turn.citations ?? []}
213
- suggestions={turn.suggestions ?? []}
214
- {collectionNames}
215
- copy={effectiveCopy}
216
- kind={turn.kind}
217
- title={turn.title}
218
- onopenartefact={() => openArtefact(turn)}
219
- oncite={cite}
220
- onregenerate={index === turns.length - 1 && !running ? onregenerate : undefined}
221
- onsuggest={index === turns.length - 1 && !running ? onsuggest : undefined}
222
- />
223
- {/each}
184
+ <div class="ds-lib-scroll-region">
185
+ <div
186
+ bind:this={viewport}
187
+ onscroll={scrolled}
188
+ role="log"
189
+ aria-live="polite"
190
+ aria-busy={running}
191
+ aria-label={words.conversationLabel}
192
+ class="ds-lib-scroll"
193
+ >
194
+ <!-- The measure, in this package's own stylesheet.
195
+ `--ds-lib-measure` is the one knob a host may turn, and it is a
196
+ token rather than a class precisely because a class here is a
197
+ string a consuming app's Tailwind has to find in `dist` — the
198
+ arrangement that silently produced `max-width: none` and a
199
+ 2302px line in Pebblestone's production build. -->
200
+ <div class="ds-lib-column">
201
+ {#if turns.length === 0}
202
+ <div class="ds-lib-empty">
203
+ <p class="ds-lib-welcome">{opening}</p>
204
+ {#if examples.length > 0}
205
+ <ul class="ds-lib-chips">
206
+ {#each examples.slice(0, 3) as example (example)}
207
+ <li>
208
+ <button type="button" class="ds-lib-chip" onclick={() => onexample?.(example)}>
209
+ {example}
210
+ </button>
211
+ </li>
212
+ {/each}
213
+ </ul>
214
+ {/if}
215
+ </div>
216
+ {/if}
217
+
218
+ <!-- Above the first turn, and in ONE place in the markup: the empty
219
+ state carries the welcome, this carries the boundary, and both
220
+ sit at the top of the same column so a reader meets them in
221
+ the same order whether or not they have asked anything yet. -->
222
+ {#if scope}
223
+ <ScopeStatement statement={scope} expanded={turns.length === 0} copy={words} />
224
+ {/if}
225
+
226
+ {#each turns as turn, index (turn.id)}
227
+ <AgentTranscript
228
+ question={turn.question}
229
+ blocks={turn.blocks}
230
+ outcome={turn.outcome}
231
+ running={running && index === turns.length - 1}
232
+ citations={turn.citations ?? []}
233
+ suggestions={turn.suggestions ?? []}
234
+ {collectionNames}
235
+ copy={words}
236
+ name={who}
237
+ at={turn.at ?? stamps[turn.id]}
238
+ kind={turn.kind}
239
+ title={turn.title}
240
+ onopenartefact={() => openArtefact(turn)}
241
+ oncite={cite}
242
+ onregenerate={index === turns.length - 1 && !running ? onregenerate : undefined}
243
+ onsuggest={index === turns.length - 1 && !running ? onsuggest : undefined}
244
+ />
245
+ {/each}
246
+ </div>
224
247
  </div>
225
- </div>
226
248
 
227
- {#if !follow.following && turns.length > 0}
228
- <button
229
- type="button"
230
- onclick={jump}
231
- class="border-border bg-surface-1 text-foreground hover:bg-surface-2 focus-visible:ring-ring absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs shadow-lg transition-colors focus-visible:ring-2 focus-visible:outline-none"
232
- >
233
- <ArrowDownIcon class="size-3.5" />
234
- {words.jumpToLatest}
235
- </button>
236
- {/if}
249
+ {#if !follow.following && turns.length > 0}
250
+ <button type="button" class="ds-lib-jump" onclick={jump}>
251
+ <ArrowDownIcon size={14} />
252
+ {words.jumpToLatest}
253
+ </button>
254
+ {/if}
237
255
  </div>
238
256
 
239
257
  {#if composer}
240
- <div class="shrink-0 px-4 pb-3">
241
- <div class="mx-auto w-full max-w-[46rem]">{@render composer()}</div>
258
+ <!-- Lifted off the scroll area, and on the app's own background rather
259
+ than the transcript's: prose scrolling up to the exact edge of the
260
+ input is the one place a reader loses track of which is which. -->
261
+ <div class="ds-lib-composer">
262
+ <div class="ds-lib-column ds-lib-column-flush">{@render composer()}</div>
242
263
  </div>
243
264
  {/if}
244
-
245
265
  </div>
246
266
 
247
267
  {#if column?.kind === 'document' && loadDocument}
248
268
  <DocumentPane
249
269
  citation={column.citation}
250
270
  {loadDocument}
251
- copy={effectiveCopy}
271
+ copy={words}
252
272
  onclose={() => (column = null)}
253
273
  />
254
274
  {:else if column?.kind === 'artefact'}
255
275
  <ArtefactPane
256
276
  turn={column.turn}
257
- copy={effectiveCopy}
277
+ copy={words}
258
278
  onclose={() => (column = null)}
259
279
  oncite={(citation) => cite(citation)}
260
280
  />
261
281
  {/if}
262
282
  </div>
283
+
284
+ <style>
285
+ .ds-lib-surface {
286
+ display: flex;
287
+ min-height: 0;
288
+ flex: 1;
289
+ }
290
+
291
+ .ds-lib-main {
292
+ display: flex;
293
+ min-width: 0;
294
+ min-height: 0;
295
+ flex: 1;
296
+ flex-direction: column;
297
+ }
298
+
299
+ .ds-lib-scroll-region {
300
+ position: relative;
301
+ display: flex;
302
+ min-height: 0;
303
+ flex: 1;
304
+ flex-direction: column;
305
+ }
306
+
307
+ .ds-lib-scroll {
308
+ min-height: 0;
309
+ flex: 1;
310
+ overflow-y: auto;
311
+ overscroll-behavior: contain;
312
+ }
313
+
314
+ .ds-lib-column {
315
+ display: flex;
316
+ width: 100%;
317
+ max-width: var(--ds-lib-measure, 46rem);
318
+ flex-direction: column;
319
+ gap: 1.5rem;
320
+ margin-inline: auto;
321
+ padding: 1.5rem 1rem;
322
+ }
323
+
324
+ .ds-lib-column-flush {
325
+ gap: 0;
326
+ padding: 0;
327
+ }
328
+
329
+ .ds-lib-empty {
330
+ display: flex;
331
+ flex-direction: column;
332
+ align-items: flex-start;
333
+ gap: 1rem;
334
+ padding-block: 2rem;
335
+ }
336
+
337
+ .ds-lib-welcome {
338
+ max-width: 46ch;
339
+ margin: 0;
340
+ color: var(--ds-color-foreground);
341
+ font-size: 1.125rem;
342
+ line-height: 1.375;
343
+ }
344
+
345
+ .ds-lib-chips {
346
+ display: flex;
347
+ flex-direction: column;
348
+ align-items: flex-start;
349
+ gap: 0.5rem;
350
+ margin: 0;
351
+ padding: 0;
352
+ list-style: none;
353
+ }
354
+
355
+ .ds-lib-chips li {
356
+ max-width: 100%;
357
+ }
358
+
359
+ .ds-lib-chip {
360
+ max-width: 100%;
361
+ border: 1px solid var(--ds-color-border);
362
+ border-radius: var(--ds-radius-full);
363
+ background: none;
364
+ padding: 0.5rem 0.875rem;
365
+ color: var(--ds-color-foreground);
366
+ font: inherit;
367
+ font-size: 0.875rem;
368
+ text-align: start;
369
+ cursor: pointer;
370
+ transition:
371
+ background-color 150ms ease,
372
+ border-color 150ms ease;
373
+ }
374
+
375
+ .ds-lib-chip:hover {
376
+ border-color: var(--ds-color-border-strong);
377
+ background: var(--ds-color-surface-2);
378
+ }
379
+
380
+ .ds-lib-chip:focus-visible {
381
+ outline: 2px solid var(--ds-color-ring);
382
+ outline-offset: 2px;
383
+ }
384
+
385
+ .ds-lib-jump {
386
+ position: absolute;
387
+ bottom: 0.75rem;
388
+ left: 50%;
389
+ display: flex;
390
+ align-items: center;
391
+ gap: 0.375rem;
392
+ transform: translateX(-50%);
393
+ border: 1px solid var(--ds-color-border);
394
+ border-radius: var(--ds-radius-full);
395
+ background: var(--ds-color-surface-1);
396
+ padding: 0.375rem 0.75rem;
397
+ color: var(--ds-color-foreground);
398
+ font: inherit;
399
+ font-size: var(--ds-text-2xs);
400
+ cursor: pointer;
401
+ box-shadow: 0 8px 20px rgb(0 0 0 / 0.18);
402
+ transition: background-color 150ms ease;
403
+ }
404
+
405
+ .ds-lib-jump:hover {
406
+ background: var(--ds-color-surface-2);
407
+ }
408
+
409
+ .ds-lib-jump:focus-visible {
410
+ outline: 2px solid var(--ds-color-ring);
411
+ outline-offset: 2px;
412
+ }
413
+
414
+ .ds-lib-composer {
415
+ flex: none;
416
+ border-top: 1px solid var(--ds-color-border);
417
+ background: var(--ds-color-background);
418
+ padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom, 0px));
419
+ }
420
+ </style>
@@ -8,9 +8,13 @@ interface Props {
8
8
  /** `Transcript.version` — bumped per event. Text grows in place, so a
9
9
  * count of turns is not enough to keep the scroll following. */
10
10
  version?: number;
11
- /** The persona's display name. The aria-label, the `welcome` default and
12
- * the unreachable alert all take it. */
11
+ /** Who is speaking. A slug is fine — `penny` renders as "Penny" — and
12
+ * every word this package says is composed from it: the working line,
13
+ * the failure sentences, the scope label, the composer's placeholder
14
+ * and the accessible name of the scroll region. Absent is the library's
15
+ * own Milton, which is a default rather than a hardcoding. */
13
16
  name?: string;
17
+ /** The empty state's opening line. Absent takes the persona's own. */
14
18
  welcome?: string;
15
19
  /** Up to three, shown in the empty state as tappable pills. */
16
20
  examples?: string[];