@poodle64/librarian 2026.9.13 → 2026.9.15

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 +55 -8
  4. package/dist/components/activity-group/activity-group.svelte +76 -8
  5. package/dist/components/agent-transcript/agent-transcript.svelte +346 -150
  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 +255 -38
  10. package/dist/components/composer/composer.svelte.d.ts +6 -0
  11. package/dist/components/conversation/conversation.svelte +254 -81
  12. package/dist/components/conversation/conversation.svelte.d.ts +7 -0
  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 +45 -10
  25. package/dist/copy.js +77 -32
  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,6 +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
+ /** 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. */
39
+ name?: string;
40
+ /** The empty state's opening line. Absent takes the persona's own. */
34
41
  welcome?: string;
35
42
  /** Up to three, shown in the empty state as tappable pills. */
36
43
  examples?: string[];
@@ -59,7 +66,8 @@
59
66
  turns,
60
67
  running,
61
68
  version = 0,
62
- welcome = 'Ask Milton a question about the library.',
69
+ name = DEFAULT_PERSONA,
70
+ welcome,
63
71
  examples = [],
64
72
  onexample,
65
73
  scope,
@@ -71,7 +79,24 @@
71
79
  composer
72
80
  }: Props = $props();
73
81
 
74
- 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);
88
+
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>>({});
75
100
 
76
101
  let viewport = $state<HTMLElement | null>(null);
77
102
  let column = $state<Column>(null);
@@ -92,9 +117,12 @@
92
117
  // of reactive state re-triggers itself, which Svelte stops with
93
118
  // `effect_update_depth_exceeded` — measured, on this component. Nothing
94
119
  // renders this, so a plain variable is both correct and enough.
95
- let seen = 0;
120
+ let seen = -1;
96
121
  $effect(() => {
97
- 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
+ }
98
126
  seen = turns.length;
99
127
  });
100
128
 
@@ -148,100 +176,245 @@
148
176
  }
149
177
  </script>
150
178
 
151
- <div class="flex min-h-0 flex-1">
152
- <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">
153
181
  <!-- The pill is positioned against the SCROLL region, not the column: the
154
182
  composer is in the column too, and a pill over the input is a pill in
155
183
  the way. -->
156
- <div class="relative flex min-h-0 flex-1 flex-col">
157
- <div
158
- bind:this={viewport}
159
- onscroll={scrolled}
160
- role="log"
161
- aria-live="polite"
162
- aria-busy={running}
163
- aria-label="Conversation with Milton"
164
- class="min-h-0 flex-1 overflow-y-auto overscroll-contain"
165
- >
166
- <div class="mx-auto flex w-full max-w-[46rem] flex-col gap-8 px-4 py-6">
167
- {#if turns.length === 0}
168
- <div class="flex flex-col items-start gap-4 py-8">
169
- <p class="text-foreground max-w-[46ch] text-lg leading-snug">{welcome}</p>
170
- {#if examples.length > 0}
171
- <ul class="flex flex-col items-start gap-2">
172
- {#each examples.slice(0, 3) as example (example)}
173
- <li class="max-w-full">
174
- <button
175
- type="button"
176
- onclick={() => onexample?.(example)}
177
- 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"
178
- >
179
- {example}
180
- </button>
181
- </li>
182
- {/each}
183
- </ul>
184
- {/if}
185
- </div>
186
- {/if}
187
-
188
- <!-- Above the first turn, and in ONE place in the markup: the empty
189
- state carries the welcome, this carries the boundary, and both
190
- sit at the top of the same column so a reader meets them in
191
- the same order whether or not they have asked anything yet. -->
192
- {#if scope}
193
- <ScopeStatement statement={scope} expanded={turns.length === 0} {copy} />
194
- {/if}
195
-
196
- {#each turns as turn, index (turn.id)}
197
- <AgentTranscript
198
- question={turn.question}
199
- blocks={turn.blocks}
200
- outcome={turn.outcome}
201
- running={running && index === turns.length - 1}
202
- citations={turn.citations ?? []}
203
- suggestions={turn.suggestions ?? []}
204
- {collectionNames}
205
- {copy}
206
- kind={turn.kind}
207
- title={turn.title}
208
- onopenartefact={() => openArtefact(turn)}
209
- oncite={cite}
210
- onregenerate={index === turns.length - 1 && !running ? onregenerate : undefined}
211
- onsuggest={index === turns.length - 1 && !running ? onsuggest : undefined}
212
- />
213
- {/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>
214
247
  </div>
215
- </div>
216
248
 
217
- {#if !follow.following && turns.length > 0}
218
- <button
219
- type="button"
220
- onclick={jump}
221
- 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"
222
- >
223
- <ArrowDownIcon class="size-3.5" />
224
- {words.jumpToLatest}
225
- </button>
226
- {/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}
227
255
  </div>
228
256
 
229
257
  {#if composer}
230
- <div class="shrink-0 px-4 pb-3">
231
- <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>
232
263
  </div>
233
264
  {/if}
234
-
235
265
  </div>
236
266
 
237
267
  {#if column?.kind === 'document' && loadDocument}
238
- <DocumentPane citation={column.citation} {loadDocument} {copy} onclose={() => (column = null)} />
268
+ <DocumentPane
269
+ citation={column.citation}
270
+ {loadDocument}
271
+ copy={words}
272
+ onclose={() => (column = null)}
273
+ />
239
274
  {:else if column?.kind === 'artefact'}
240
275
  <ArtefactPane
241
276
  turn={column.turn}
242
- {copy}
277
+ copy={words}
243
278
  onclose={() => (column = null)}
244
279
  oncite={(citation) => cite(citation)}
245
280
  />
246
281
  {/if}
247
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,6 +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
+ /** 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. */
16
+ name?: string;
17
+ /** The empty state's opening line. Absent takes the persona's own. */
11
18
  welcome?: string;
12
19
  /** Up to three, shown in the empty state as tappable pills. */
13
20
  examples?: string[];