@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
@@ -1,11 +1,16 @@
1
1
  <!--
2
- One question and everything Milton did answering it.
2
+ One question and everything the persona did answering it.
3
3
 
4
- Three things carry the shape. The question is a bubble against the edge, so a
5
- reader scanning back finds their own words without reading any of the answer.
6
- The answer is plain prose held to a reading measure, because a full-width
7
- line at 1440 is the single clearest tell that a surface was never read on.
8
- Everything Milton DID is one quiet line above it, never a stack.
4
+ Four things carry the shape. The question is a bubble against the trailing
5
+ edge, so a reader scanning back finds their own words without reading any of
6
+ the answer. The answer is a bounded card under it, signed with the persona's
7
+ name and the time, because a transcript of unbounded prose reads as a log
8
+ and the director's first words about the live screen were "it doesn't look
9
+ like a ChatGPT window". Everything the persona DID is one quiet line inside
10
+ that card, never a stack. And the measure is the package's own CSS, never a
11
+ utility class a consuming app's Tailwind has to discover in `dist` — the one
12
+ that got away compiled to `max-width: none` in production and left a
13
+ 2302px-wide transcript.
9
14
  -->
10
15
  <script lang="ts">
11
16
  import CheckIcon from '@lucide/svelte/icons/check';
@@ -22,14 +27,14 @@
22
27
  citationMarkers,
23
28
  resolveCitations,
24
29
  splitSources,
25
- trustMark,
26
30
  type Citation
27
31
  } from '../../citations';
28
- import { resolveCopy, type LibrarianCopy } from '../../copy';
32
+ import { DEFAULT_PERSONA, personaName, resolveCopy, type LibrarianCopy } from '../../copy';
29
33
  import Working from '../working/working.svelte';
30
34
  import ActivityGroup from '../activity-group/activity-group.svelte';
31
35
  import ArtefactCard from '../artefact-card/artefact-card.svelte';
32
36
  import Markdown from '../markdown/markdown.svelte';
37
+ import SourceList from '../source-list/source-list.svelte';
33
38
 
34
39
  interface Props {
35
40
  question: string;
@@ -52,6 +57,13 @@
52
57
  * no chip. */
53
58
  onsuggest?: (question: string) => void;
54
59
  copy?: Partial<LibrarianCopy>;
60
+ /** The persona's display name, signed on the answer card. */
61
+ name?: string;
62
+ /** When the question was asked, epoch ms. Rendered as a clock time —
63
+ * which is a different fact from the duration badge beside the actions,
64
+ * and a reader scanning back for "what did I ask after lunch" needs the
65
+ * first one. Absent renders no time rather than a guess. */
66
+ at?: number;
55
67
  /** A study artefact rather than an ordinary answer: once settled, this
56
68
  * renders as a card instead of prose. */
57
69
  kind?: 'answer' | 'artefact';
@@ -74,6 +86,8 @@
74
86
  onregenerate,
75
87
  onsuggest,
76
88
  copy,
89
+ name = DEFAULT_PERSONA,
90
+ at,
77
91
  kind = 'answer',
78
92
  title,
79
93
  onopenartefact
@@ -81,10 +95,28 @@
81
95
 
82
96
  const isArtefact = $derived(kind === 'artefact');
83
97
 
84
- const words = $derived(resolveCopy(copy));
98
+ const words = $derived(resolveCopy(copy, name));
99
+ const who = $derived(personaName(name));
85
100
 
86
101
  const asked = $derived(readerQuestion(question));
87
102
 
103
+ // The reader's own locale and their own clock. A date is added only once
104
+ // the turn is not from today, because "14:32" is what a reader scanning
105
+ // this morning's conversation is actually matching against.
106
+ const clock = $derived.by(() => {
107
+ if (at === undefined) return null;
108
+ const when = new Date(at);
109
+ const sameDay = new Date().toDateString() === when.toDateString();
110
+ return {
111
+ iso: when.toISOString(),
112
+ text: when.toLocaleTimeString(undefined, {
113
+ hour: 'numeric',
114
+ minute: '2-digit',
115
+ ...(sameDay ? {} : { day: 'numeric', month: 'short' })
116
+ })
117
+ };
118
+ });
119
+
88
120
  // An activity group stays live — and therefore labelled "Working…" — only
89
121
  // while it is the last thing in the turn. As soon as prose arrives, it
90
122
  // settles into its count.
@@ -124,18 +156,23 @@
124
156
  * What went wrong, if anything did — in words, whether or not the run gave
125
157
  * any.
126
158
  *
127
- * Two different failures reach a turn and only one of them carries a
128
- * message. `library_error` (the stream never opened) sets `error`; a run
129
- * that opened and then failed sets `is_error` on its terminal frame and
130
- * says nothing at all — Claude Code's own `error_max_turns` and
131
- * `error_during_execution` are exactly that shape. Reading only `error`
132
- * left the second kind rendering as a clean, complete answer: no banner,
133
- * a duration badge, and — worse — the "he holds nothing on this" line,
134
- * which is a claim about the shelf made off a run that never finished
135
- * looking.
159
+ * Three shapes reach a turn and only one of them carries a message.
160
+ * `unreachable` is a stream that never opened or died half-way, which
161
+ * `client.ts` raises without words because that layer does not know whose
162
+ * voice to say them in. A run that opened and then failed sets `is_error`
163
+ * on its terminal frame and says nothing at all — Claude Code's own
164
+ * `error_max_turns` and `error_during_execution` are exactly that shape.
165
+ * Only a run that volunteered a message gets its own words through.
166
+ *
167
+ * Reading none of this left a failed turn rendering as a clean, complete
168
+ * answer: no banner, a duration badge, and — worse — the "answered without
169
+ * a source" line, which is a claim about the shelf made off a run that
170
+ * never finished looking.
136
171
  */
137
172
  const failure = $derived(
138
- outcome?.error ?? (outcome?.isError ? words.answerFailed : null)
173
+ outcome?.unreachable
174
+ ? words.unreachable
175
+ : (outcome?.error ?? (outcome?.isError ? words.answerFailed : null))
139
176
  );
140
177
 
141
178
  // A failed turn settles too, and "Ask again" is the one thing a reader wants
@@ -143,7 +180,7 @@
143
180
  const settled = $derived(!running && (hasAnswer || Boolean(failure)));
144
181
 
145
182
  /**
146
- * Milton answered and cited nothing, and we WATCHED him do it.
183
+ * The persona answered and cited nothing, and we WATCHED it happen.
147
184
  *
148
185
  * `outcome` is the gate, not merely an empty source list: a turn read back
149
186
  * out of the conversation history is prose with no citations and no
@@ -178,25 +215,34 @@
178
215
  }
179
216
  </script>
180
217
 
181
- <article class="flex flex-col gap-3">
182
- <!-- Right-aligned, following Claude and ChatGPT rather than a VS Code-style
183
- panel: this surface is read by people who arrive with those two as
184
- their model of what a chat looks like. -->
185
- <div
186
- class="bg-surface-3 text-foreground border-border max-w-[85%] self-end rounded-2xl border px-4 py-2.5 text-base break-words whitespace-pre-wrap"
187
- >
188
- {asked}
218
+ <article class="ds-lib-turn">
219
+ <!-- Trailing-edge bubble, following Claude and ChatGPT rather than a
220
+ VS Code-style panel: this surface is read by people who arrive with
221
+ those two as their model of what a chat looks like. -->
222
+ <div class="ds-lib-ask">
223
+ <div class="ds-lib-bubble">{asked}</div>
224
+ {#if clock}
225
+ <time class="ds-lib-ask-time" datetime={clock.iso}>{clock.text}</time>
226
+ {/if}
189
227
  </div>
190
228
 
191
- {#if running && segments.length === 0}
192
- <Working />
193
- {/if}
229
+ <div class="ds-lib-answer">
230
+ <header class="ds-lib-answer-head">
231
+ <span class="ds-lib-avatar" aria-hidden="true">{who.slice(0, 1)}</span>
232
+ <span class="ds-lib-who">{who}</span>
233
+ {#if clock}
234
+ <time class="ds-lib-answer-time" datetime={clock.iso}>{clock.text}</time>
235
+ {/if}
236
+ </header>
194
237
 
195
- {#each segments as seg (seg.index)}
196
- {#if seg.kind === 'activity'}
197
- <ActivityGroup group={seg} live={running && seg.index === lastIndex} />
198
- {:else if !isArtefact}
199
- <div class="max-w-[72ch] min-w-0">
238
+ {#if running && segments.length === 0}
239
+ <Working copy={words} />
240
+ {/if}
241
+
242
+ {#each segments as seg (seg.index)}
243
+ {#if seg.kind === 'activity'}
244
+ <ActivityGroup group={seg} live={running && seg.index === lastIndex} />
245
+ {:else if !isArtefact}
200
246
  <Markdown
201
247
  content={seg.index === lastTextIndex && split.citations.length > 0
202
248
  ? split.body
@@ -209,120 +255,270 @@
209
255
  if (found) oncite?.(found);
210
256
  }}
211
257
  />
212
- </div>
258
+ {/if}
259
+ {/each}
260
+
261
+ {#if isArtefact && settled}
262
+ <ArtefactCard
263
+ title={title ?? 'Briefing'}
264
+ citationCount={sources.length}
265
+ onopen={() => onopenartefact?.()}
266
+ />
213
267
  {/if}
214
- {/each}
215
-
216
- {#if isArtefact && settled}
217
- <ArtefactCard title={title ?? 'Briefing'} citationCount={sources.length} onopen={() => onopenartefact?.()} />
218
- {/if}
219
-
220
- {#if !isArtefact && sources.length > 0}
221
- <section class="max-w-[72ch]">
222
- <h2 class="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
223
- {words.sources}
224
- </h2>
225
- <ol class="flex flex-col gap-1">
226
- {#each sources as source (source.n)}
227
- {@const mark = trustMark(source, words)}
228
- <li>
229
- <button
230
- type="button"
231
- onclick={() => oncite?.(source)}
232
- disabled={!source.document_id || !oncite}
233
- class="border-border hover:border-border-strong hover:bg-surface-2 focus-visible:ring-ring flex w-full items-baseline gap-2 rounded-lg border px-2.5 py-1.5 text-left text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-default disabled:hover:bg-transparent"
234
- >
235
- <span
236
- class="bg-primary/15 text-foreground shrink-0 rounded px-1.5 font-mono text-xs tabular-nums"
237
- >{source.n}</span
268
+
269
+ {#if !isArtefact && sources.length > 0}
270
+ <SourceList {sources} {words} {oncite} />
271
+ {/if}
272
+
273
+ {#if !isArtefact && notHeld}
274
+ <p class="ds-lib-not-held">{words.notHeld}</p>
275
+ {/if}
276
+
277
+ {#if !isArtefact && followUps.length > 0}
278
+ <section>
279
+ <h2 class="ds-lib-follow-heading">{words.suggestions}</h2>
280
+ <!-- Wrapping, not scrolling: at 390 a question is most of a line, so
281
+ a row of three would clip two of them to fragments. -->
282
+ <ul class="ds-lib-chips">
283
+ {#each followUps as followUp (followUp)}
284
+ <li>
285
+ <button
286
+ type="button"
287
+ class="ds-lib-chip"
288
+ onclick={() => {
289
+ used = true;
290
+ onsuggest?.(followUp);
291
+ }}
238
292
  >
239
- <span class="min-w-0">
240
- <span class="text-foreground">{source.title}</span>
241
- {#if source.section}<span class="text-muted-foreground">
242
- · {source.section}</span
243
- >{/if}
244
- <!-- Same muted register as the section, deliberately. An
245
- unverified source is not an error and must not be
246
- dressed as one; the words carry the difference, and a
247
- red one would have a colleague discount a document
248
- that is simply new. -->
249
- {#if mark}<span class="text-muted-foreground/80"> · {mark}</span>{/if}
250
- </span>
251
- </button>
252
- </li>
253
- {/each}
254
- </ol>
255
- </section>
256
- {/if}
257
-
258
- {#if !isArtefact && notHeld}
259
- <p class="text-muted-foreground max-w-[72ch] text-sm">{words.notHeld}</p>
260
- {/if}
261
-
262
- {#if !isArtefact && followUps.length > 0}
263
- <section class="max-w-[72ch]">
264
- <h2 class="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
265
- {words.suggestions}
266
- </h2>
267
- <!-- Wrapping, not scrolling: at 390 a question is most of a line, so a
268
- row of three would clip two of them to fragments. -->
269
- <ul class="flex flex-wrap gap-2">
270
- {#each followUps as followUp (followUp)}
271
- <li class="max-w-full">
272
- <button
273
- type="button"
274
- onclick={() => {
275
- used = true;
276
- onsuggest?.(followUp);
277
- }}
278
- 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"
279
- >
280
- {followUp}
281
- </button>
282
- </li>
283
- {/each}
284
- </ul>
285
- </section>
286
- {/if}
287
-
288
- {#if failure}
289
- <p
290
- class="border-status-error/40 bg-status-error/10 text-foreground max-w-[72ch] rounded-lg border px-3 py-2 text-sm"
291
- role="alert"
292
- >
293
- {failure}
294
- </p>
295
- {/if}
296
-
297
- {#if !isArtefact && settled}
298
- <div class="text-muted-foreground -ml-1.5 flex items-center gap-1">
299
- {#if hasAnswer}
300
- <button
301
- type="button"
302
- onclick={copyToClipboard}
303
- aria-label={copied ? words.copiedAnswer : words.copyAnswer}
304
- class="hover:text-foreground hover:bg-surface-2 focus-visible:ring-ring flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
305
- >
306
- {#if copied}<CheckIcon class="size-3.5" />{:else}<CopyIcon class="size-3.5" />{/if}
307
- <span>{copied ? words.copiedAnswer : words.copyAnswer}</span>
308
- </button>
309
- {/if}
310
- {#if onregenerate}
311
- <button
312
- type="button"
313
- onclick={onregenerate}
314
- aria-label={words.askAgain}
315
- class="hover:text-foreground hover:bg-surface-2 focus-visible:ring-ring flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
316
- >
317
- <RefreshCwIcon class="size-3.5" />
318
- <span>{words.askAgain}</span>
319
- </button>
320
- {/if}
321
- {#if outcome && !failure}
322
- <span class="pl-1 font-mono text-xs tabular-nums opacity-70">
323
- {((outcome.durationMs ?? 0) / 1000).toFixed(1)}s
324
- </span>
325
- {/if}
326
- </div>
327
- {/if}
293
+ {followUp}
294
+ </button>
295
+ </li>
296
+ {/each}
297
+ </ul>
298
+ </section>
299
+ {/if}
300
+
301
+ {#if failure}
302
+ <p class="ds-lib-failure" role="alert">{failure}</p>
303
+ {/if}
304
+
305
+ {#if !isArtefact && settled}
306
+ <div class="ds-lib-actions">
307
+ {#if hasAnswer}
308
+ <button
309
+ type="button"
310
+ class="ds-lib-action"
311
+ onclick={copyToClipboard}
312
+ aria-label={copied ? words.copiedAnswer : words.copyAnswer}
313
+ >
314
+ {#if copied}<CheckIcon size={14} />{:else}<CopyIcon size={14} />{/if}
315
+ <span>{copied ? words.copiedAnswer : words.copyAnswer}</span>
316
+ </button>
317
+ {/if}
318
+ {#if onregenerate}
319
+ <button
320
+ type="button"
321
+ class="ds-lib-action"
322
+ onclick={onregenerate}
323
+ aria-label={words.askAgain}
324
+ >
325
+ <RefreshCwIcon size={14} />
326
+ <span>{words.askAgain}</span>
327
+ </button>
328
+ {/if}
329
+ <!-- How long it took, which is not the same fact as when it was
330
+ asked; both are on the card and neither stands in for the
331
+ other. -->
332
+ {#if outcome && !failure}
333
+ <span class="ds-lib-duration">{((outcome.durationMs ?? 0) / 1000).toFixed(1)}s</span>
334
+ {/if}
335
+ </div>
336
+ {/if}
337
+ </div>
328
338
  </article>
339
+
340
+ <style>
341
+ .ds-lib-turn {
342
+ display: flex;
343
+ flex-direction: column;
344
+ gap: 0.5rem;
345
+ }
346
+
347
+ .ds-lib-ask {
348
+ display: flex;
349
+ max-width: 100%;
350
+ flex-direction: column;
351
+ align-items: flex-end;
352
+ gap: 0.25rem;
353
+ align-self: flex-end;
354
+ }
355
+
356
+ .ds-lib-bubble {
357
+ max-width: 85%;
358
+ border: 1px solid var(--ds-color-border);
359
+ border-radius: var(--ds-radius-xl);
360
+ background: var(--ds-color-surface-3);
361
+ padding: 0.625rem 1rem;
362
+ color: var(--ds-color-foreground);
363
+ font-size: 1rem;
364
+ line-height: 1.5rem;
365
+ overflow-wrap: break-word;
366
+ white-space: pre-wrap;
367
+ }
368
+
369
+ .ds-lib-ask-time,
370
+ .ds-lib-answer-time {
371
+ color: var(--ds-color-muted-foreground);
372
+ font-size: var(--ds-text-2xs);
373
+ font-variant-numeric: tabular-nums;
374
+ }
375
+
376
+ /* The card. A turn a reader can see the edges of is the whole of what the
377
+ director was asking for: one question and its answer, bounded, rather
378
+ than prose running edge to edge under the last lot of prose. */
379
+ .ds-lib-answer {
380
+ display: flex;
381
+ flex-direction: column;
382
+ gap: 0.75rem;
383
+ border: 1px solid var(--ds-color-border);
384
+ border-radius: var(--ds-radius-xl);
385
+ background: var(--ds-color-surface-2);
386
+ padding: 0.875rem 1rem 0.75rem;
387
+ }
388
+
389
+ .ds-lib-answer-head {
390
+ display: flex;
391
+ align-items: center;
392
+ gap: 0.5rem;
393
+ }
394
+
395
+ .ds-lib-avatar {
396
+ display: flex;
397
+ width: 1.375rem;
398
+ height: 1.375rem;
399
+ flex: none;
400
+ align-items: center;
401
+ justify-content: center;
402
+ border-radius: var(--ds-radius-full);
403
+ background: color-mix(in oklab, var(--ds-color-primary) 18%, transparent);
404
+ color: var(--ds-color-foreground);
405
+ font-size: var(--ds-text-2xs);
406
+ font-weight: 600;
407
+ text-transform: uppercase;
408
+ }
409
+
410
+ .ds-lib-who {
411
+ flex: 1;
412
+ color: var(--ds-color-foreground);
413
+ font-size: 0.8125rem;
414
+ font-weight: 600;
415
+ }
416
+
417
+ .ds-lib-not-held {
418
+ margin: 0;
419
+ color: var(--ds-color-muted-foreground);
420
+ font-size: 0.875rem;
421
+ }
422
+
423
+ .ds-lib-follow-heading {
424
+ margin: 0 0 0.375rem;
425
+ color: var(--ds-color-muted-foreground);
426
+ font-family: inherit;
427
+ font-size: var(--ds-text-2xs);
428
+ font-weight: 500;
429
+ letter-spacing: 0.05em;
430
+ text-transform: uppercase;
431
+ }
432
+
433
+ .ds-lib-chips {
434
+ display: flex;
435
+ flex-wrap: wrap;
436
+ gap: 0.5rem;
437
+ margin: 0;
438
+ padding: 0;
439
+ list-style: none;
440
+ }
441
+
442
+ .ds-lib-chips li {
443
+ max-width: 100%;
444
+ }
445
+
446
+ .ds-lib-chip {
447
+ max-width: 100%;
448
+ border: 1px solid var(--ds-color-border);
449
+ border-radius: var(--ds-radius-full);
450
+ background: none;
451
+ padding: 0.5rem 0.875rem;
452
+ color: var(--ds-color-foreground);
453
+ font: inherit;
454
+ font-size: 0.875rem;
455
+ text-align: start;
456
+ cursor: pointer;
457
+ transition:
458
+ background-color 150ms ease,
459
+ border-color 150ms ease;
460
+ }
461
+
462
+ .ds-lib-chip:hover {
463
+ border-color: var(--ds-color-border-strong);
464
+ background: var(--ds-color-surface-1);
465
+ }
466
+
467
+ .ds-lib-chip:focus-visible {
468
+ outline: 2px solid var(--ds-color-ring);
469
+ outline-offset: 2px;
470
+ }
471
+
472
+ .ds-lib-failure {
473
+ margin: 0;
474
+ border: 1px solid color-mix(in oklab, var(--ds-color-status-error) 40%, transparent);
475
+ border-radius: var(--ds-radius-lg);
476
+ background: color-mix(in oklab, var(--ds-color-status-error) 10%, transparent);
477
+ padding: 0.5rem 0.75rem;
478
+ color: var(--ds-color-foreground);
479
+ font-size: 0.875rem;
480
+ }
481
+
482
+ .ds-lib-actions {
483
+ display: flex;
484
+ align-items: center;
485
+ gap: 0.25rem;
486
+ margin-inline-start: -0.375rem;
487
+ color: var(--ds-color-muted-foreground);
488
+ }
489
+
490
+ .ds-lib-action {
491
+ display: flex;
492
+ align-items: center;
493
+ gap: 0.375rem;
494
+ border: 0;
495
+ border-radius: var(--ds-radius-md);
496
+ background: none;
497
+ padding: 0.25rem 0.375rem;
498
+ color: inherit;
499
+ font: inherit;
500
+ font-size: var(--ds-text-2xs);
501
+ cursor: pointer;
502
+ transition:
503
+ color 150ms ease,
504
+ background-color 150ms ease;
505
+ }
506
+
507
+ .ds-lib-action:hover {
508
+ background: var(--ds-color-surface-1);
509
+ color: var(--ds-color-foreground);
510
+ }
511
+
512
+ .ds-lib-action:focus-visible {
513
+ outline: 2px solid var(--ds-color-ring);
514
+ outline-offset: 2px;
515
+ }
516
+
517
+ .ds-lib-duration {
518
+ padding-inline-start: 0.25rem;
519
+ font-family: var(--ds-font-code);
520
+ font-size: var(--ds-text-2xs);
521
+ font-variant-numeric: tabular-nums;
522
+ opacity: 0.7;
523
+ }
524
+ </style>
@@ -22,6 +22,13 @@ interface Props {
22
22
  * no chip. */
23
23
  onsuggest?: (question: string) => void;
24
24
  copy?: Partial<LibrarianCopy>;
25
+ /** The persona's display name, signed on the answer card. */
26
+ name?: string;
27
+ /** When the question was asked, epoch ms. Rendered as a clock time —
28
+ * which is a different fact from the duration badge beside the actions,
29
+ * and a reader scanning back for "what did I ask after lunch" needs the
30
+ * first one. Absent renders no time rather than a guess. */
31
+ at?: number;
25
32
  /** A study artefact rather than an ordinary answer: once settled, this
26
33
  * renders as a card instead of prose. */
27
34
  kind?: 'answer' | 'artefact';
@@ -30,11 +30,39 @@
30
30
  event.preventDefault();
31
31
  onopen();
32
32
  }}
33
- class="hover:border-border-strong focus-visible:ring-ring w-full max-w-[28rem] cursor-pointer text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
33
+ class="ds-lib-artefact-card"
34
34
  >
35
35
  {#snippet children()}
36
- <p class="text-muted-foreground text-xs">
36
+ <p class="ds-lib-artefact-count">
37
37
  {citationCount} source{citationCount === 1 ? '' : 's'}
38
38
  </p>
39
39
  {/snippet}
40
40
  </Panel>
41
+
42
+ <style>
43
+ /* `:global`, and the only one in this package that is not prose inside
44
+ `{@html}`: the class goes to another component's root element, which
45
+ Svelte's scoping hash never reaches. */
46
+ :global(.ds-lib-artefact-card) {
47
+ width: 100%;
48
+ max-width: 28rem;
49
+ cursor: pointer;
50
+ text-align: start;
51
+ transition: border-color 150ms ease;
52
+ }
53
+
54
+ :global(.ds-lib-artefact-card:hover) {
55
+ border-color: var(--ds-color-border-strong);
56
+ }
57
+
58
+ :global(.ds-lib-artefact-card:focus-visible) {
59
+ outline: 2px solid var(--ds-color-ring);
60
+ outline-offset: 2px;
61
+ }
62
+
63
+ .ds-lib-artefact-count {
64
+ margin: 0;
65
+ color: var(--ds-color-muted-foreground);
66
+ font-size: var(--ds-text-2xs);
67
+ }
68
+ </style>