@poodle64/librarian 2026.9.18 → 2026.9.19

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/README.md CHANGED
@@ -16,8 +16,9 @@ there.
16
16
 
17
17
  ```text
18
18
  src/lib/
19
- client.ts ask(): streams Claude Code's OWN events, unaltered
19
+ client.ts ask() and watch(): Claude Code's OWN events, unaltered
20
20
  transcript.svelte.ts Transcript state, the fold/segment/describe helpers
21
+ session.svelte.ts Session: a whole stream of runs, as turns
21
22
  citations.ts the Citation shape, `[n]` markers, the trust mark
22
23
  copy.ts every word this package says, and the host's overrides
23
24
  attachments.ts what a reader may attach, and the limits
@@ -28,9 +29,9 @@ src/lib/
28
29
  scope-statement/ what this room answers from, and what it does not hold
29
30
  agent-transcript/ one question and everything Milton did answering it
30
31
  document-pane/ the cited document, open at the cited passage
31
- artefact-card/ a study artefact's card in the transcript
32
- artefact-pane/ a study artefact, open in the reading column
33
- composer/ the input box: attachments, scope chips, send/stop
32
+ artefact-card/ an artefact's card, in the host's words
33
+ artefact-pane/ an artefact that is its answer's prose, in the column
34
+ composer/ the input box: attachments, scope chips, a note, send/stop
34
35
  markdown/ sanitised, streaming-safe markdown + highlighting
35
36
  activity-group/ a whole investigation, as one quiet line
36
37
  tool-row/ one tool call
@@ -41,12 +42,12 @@ src/lib/
41
42
  ## Installation
42
43
 
43
44
  ```bash
44
- pnpm add @poodle64/librarian @poodle64/ui @lucide/svelte
45
+ pnpm add @poodle64/librarian @lucide/svelte
45
46
  ```
46
47
 
47
- `svelte`, `@poodle64/ui`, `@lucide/svelte`, `marked`, `isomorphic-dompurify`
48
- and `shiki` are peer dependencies: declare them yourself so Renovate tracks
49
- their versions and `pnpm ls` shows them.
48
+ `svelte`, `@lucide/svelte`, `marked`, `isomorphic-dompurify` and `shiki` are
49
+ peer dependencies: declare them yourself so Renovate tracks their versions and
50
+ `pnpm ls` shows them.
50
51
 
51
52
  **No Tailwind content-scan line, and deliberately none.** These components
52
53
  carry their own CSS, written against the `--ds-*` tokens and compiled by
@@ -65,9 +66,6 @@ What it does need is the token layer every app already imports:
65
66
  @import '@poodle64/design-tokens/tokens.css';
66
67
  ```
67
68
 
68
- The composed page chrome this package borrows from `@poodle64/ui` (`Panel`)
69
- still follows that package's own `@source` line, which every app has.
70
-
71
69
  One knob: `--ds-lib-measure` (default `46rem`) sets the transcript's reading
72
70
  column. Set it on any ancestor.
73
71
 
@@ -153,8 +151,6 @@ gives it a height and a composer and nothing else:
153
151
  bind:value={question}
154
152
  bind:files
155
153
  {running}
156
- scope="library"
157
- onscope={() => {}}
158
154
  onsubmit={submit}
159
155
  onstop={() => controller?.abort()}
160
156
  />
@@ -184,6 +180,71 @@ Nothing here fetches on its own behalf. The library's document read is
184
180
  authenticated, and a package that called it directly would be reaching past
185
181
  the app's proxy with a session it has no business holding.
186
182
 
183
+ ### Watching a session somebody else started
184
+
185
+ A session the APP started — a persona reading a document with nobody asking —
186
+ is watched rather than asked. `watch()` GETs its stream; `Session` folds it
187
+ into turns: the prompt and every later message on the reader's side (the CLI
188
+ echoes each one back under `--replay-user-messages`), each run's answer on the
189
+ persona's, each settled by its own `result`. A stream without deltas is folded
190
+ from its whole messages. Events are deduped by `uuid`, so opening the watch
191
+ again after sending a message folds only what is new.
192
+
193
+ ```svelte
194
+ <script lang="ts">
195
+ import { watch } from '@poodle64/librarian/client';
196
+ import { Session } from '@poodle64/librarian/session';
197
+
198
+ const session = new Session();
199
+ let watching = $state(false);
200
+
201
+ async function follow(signal: AbortSignal) {
202
+ watching = true;
203
+ try {
204
+ for await (const event of watch({ endpoint: `/api/agent/${persona}/jobs/${id}/watch`, signal }))
205
+ session.apply(event);
206
+ } finally {
207
+ watching = false;
208
+ }
209
+ }
210
+
211
+ // The host's own additions: what a run handed in, carded under its answer.
212
+ const turns = $derived(
213
+ session.turns.map((turn) =>
214
+ turn.outcome?.structuredOutput ? { ...turn, artefact: artefactFrom(turn) } : turn
215
+ )
216
+ );
217
+ </script>
218
+
219
+ <Conversation
220
+ {turns}
221
+ running={watching && session.working}
222
+ version={session.version}
223
+ {name}
224
+ {describeTool}
225
+ copy={{ sources: 'Read from', notHeld: '' }}
226
+ onopenartefact={(turn) => (showing = turn.id)}
227
+ {showing}
228
+ oncite={(citation) => showPage(citation.document_id)}
229
+ >
230
+ {#snippet composer()}
231
+ <Composer bind:value {running} {name} note="It carries on from where it stopped." {onsubmit} {onstop} />
232
+ {/snippet}
233
+ </Conversation>
234
+ ```
235
+
236
+ `watch()` differs from `ask()` in one way: a clean close with no `result` is
237
+ not a failure, because a stopped run and a session waiting between runs both
238
+ end that way.
239
+
240
+ | Concern | How |
241
+ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
242
+ | The persona's tools | `describeTool(block) => ToolWords \| undefined`: the row (`verb`, `object`, `detail`), the activity line's counts (`tally`), and what it read (`source`). `undefined` leaves a call to the package's own words |
243
+ | What an answer read | `ToolWords.source` lists under the answer, under `copy.sources`; tapping one calls `oncite`. Nothing checked it, so it carries no trust mark |
244
+ | An artefact | `turn.artefact = { title, summary }` cards under the prose once the turn settles; `isAnswer: true` is an artefact that IS the prose, and replaces it |
245
+ | Opening it | `onopenartefact(turn)` and `showing` (the turn id the host shows). Without a handler, an `isAnswer` artefact opens in `ArtefactPane` and any other offers no action |
246
+ | "Answered without a source" | `copy.notHeld: ''` for a persona that answers from no shelf |
247
+
187
248
  ### Citations
188
249
 
189
250
  The library emits one SSE frame after the final assistant text:
@@ -343,7 +404,7 @@ pnpm run test # build + vitest
343
404
  pnpm run screenshots # the state grid, real engine (see below)
344
405
  ```
345
406
 
346
- `docs/screenshots/` is thirteen states x three widths x both themes, taken by
407
+ `docs/screenshots/` is sixteen states x three widths x both themes, taken by
347
408
  `scripts/screenshots.mjs` against the console's `/librarian` lab route
348
409
  running from its own static build. The same script asserts what a screenshot
349
410
  cannot: that nothing scrolls sideways at any width, that the source pane
@@ -352,8 +413,11 @@ really draggable, that the scope statement folds once there is a
352
413
  conversation over it and reopens from that line, that a follow-up chip asks
353
414
  its question and takes the rest of the row with it, that the persona's
354
415
  between-tool narration is nowhere in the answer prose before the activity
355
- line is opened, and that the reading column holds its 46rem measure and stays
356
- centred at every width. It exits non-zero on any of them.
416
+ line is opened, that a job's artefact and the pages it read open in the
417
+ host's own columns and never in a pane of the package's, and that the reading
418
+ column holds its 46rem measure and stays centred at every width. It exits
419
+ non-zero on any of them. The job states fold a stream captured from the real
420
+ CLI (`src/test/fixtures/`).
357
421
 
358
422
  The console's `app.css` deliberately does NOT scan this package's `dist`, so
359
423
  the grid is taken in a consumer that compiles none of its Tailwind classes —
@@ -26,7 +26,9 @@ export interface Citation {
26
26
  * as null.
27
27
  */
28
28
  verified_at?: string | null;
29
- /** True when this came from the prose block rather than the wire event. */
29
+ /** True when read off the stream — the prose's Sources block, or a call
30
+ * that read it — rather than the library's own frame. Nothing checked it,
31
+ * so it carries no trust mark. */
30
32
  derived?: boolean;
31
33
  }
32
34
  /** Inline `[n]` markers, in order of first appearance.
package/dist/client.d.ts CHANGED
@@ -27,15 +27,25 @@ export interface AgentEvent {
27
27
  id?: string;
28
28
  };
29
29
  };
30
+ /** A whole message; `content` is a plain string only on a user frame. */
30
31
  message?: {
31
- content?: Array<Record<string, unknown>>;
32
+ id?: string;
33
+ content?: Array<Record<string, unknown>> | string;
32
34
  };
35
+ /** A user frame the CLI echoed back as the turn that consumes it starts
36
+ * (`--replay-user-messages`): the reader's side of a session. */
37
+ isReplay?: boolean;
38
+ uuid?: string;
39
+ /** ISO 8601, on every user and assistant frame. */
40
+ timestamp?: string;
33
41
  session_id?: string;
34
42
  result?: string;
35
43
  num_turns?: number;
36
44
  total_cost_usd?: number;
37
45
  duration_ms?: number;
38
46
  is_error?: boolean;
47
+ /** What a run returned under `--json-schema`, on its `result`. */
48
+ structured_output?: unknown;
39
49
  error?: string;
40
50
  detail?: string;
41
51
  tools?: string[];
@@ -85,3 +95,20 @@ export interface AskOptions {
85
95
  * where the turn is rendered.
86
96
  */
87
97
  export declare function ask(options: AskOptions): AsyncGenerator<AgentEvent>;
98
+ export interface WatchOptions {
99
+ /** The session's watch route, e.g. `/api/agent/{persona}/jobs/{id}/watch`. */
100
+ endpoint: string;
101
+ signal?: AbortSignal;
102
+ fetch?: typeof fetch;
103
+ }
104
+ /**
105
+ * Async-iterate a session somebody else started: every event from the first,
106
+ * then live while it runs.
107
+ *
108
+ * The same frames and the same never-throw contract as `ask()`, with one
109
+ * difference: a clean close with no `result` is not a failure. A run the
110
+ * reader stopped ends exactly that way, and so does a session that is
111
+ * waiting between runs, so only a stream that would not open, or broke,
112
+ * yields `library_error`. Fold the events with `Session`.
113
+ */
114
+ export declare function watch(options: WatchOptions): AsyncGenerator<AgentEvent>;
package/dist/client.js CHANGED
@@ -64,14 +64,35 @@ function requestInit(options, signal) {
64
64
  * where the turn is rendered.
65
65
  */
66
66
  export async function* ask(options) {
67
- const doFetch = options.fetch ?? fetch;
67
+ yield* read(() => (options.fetch ?? fetch)(options.endpoint ?? '/api/agent/ask', requestInit(options, options.signal)), options.signal, true);
68
+ }
69
+ /**
70
+ * Async-iterate a session somebody else started: every event from the first,
71
+ * then live while it runs.
72
+ *
73
+ * The same frames and the same never-throw contract as `ask()`, with one
74
+ * difference: a clean close with no `result` is not a failure. A run the
75
+ * reader stopped ends exactly that way, and so does a session that is
76
+ * waiting between runs, so only a stream that would not open, or broke,
77
+ * yields `library_error`. Fold the events with `Session`.
78
+ */
79
+ export async function* watch(options) {
80
+ yield* read(() => (options.fetch ?? fetch)(options.endpoint, {
81
+ headers: { Accept: 'text/event-stream' },
82
+ credentials: 'include',
83
+ signal: options.signal
84
+ }), options.signal, false);
85
+ }
86
+ /** One SSE response, as Claude Code's own events. `terminal` demands a
87
+ * `result` before the stream closes. */
88
+ async function* read(open, signal, terminal) {
68
89
  let response;
69
90
  try {
70
- response = await doFetch(options.endpoint ?? '/api/agent/ask', requestInit(options, options.signal));
91
+ response = await open();
71
92
  }
72
93
  catch {
73
94
  // A reader who pressed stop asked for this one; it is not a failure.
74
- if (options.signal?.aborted)
95
+ if (signal?.aborted)
75
96
  return;
76
97
  yield { type: 'library_error' };
77
98
  return;
@@ -97,7 +118,7 @@ export async function* ask(options) {
97
118
  // A turn that ends with no terminal frame ended by accident. Tracked here
98
119
  // rather than left to the renderer, which cannot tell a stream that died
99
120
  // from one still arriving.
100
- let terminal = false;
121
+ let ended = false;
101
122
  /** One frame's `data:` lines, as the event they carry. */
102
123
  function parse(frame) {
103
124
  // `data:` only — the type lives inside the payload where Claude Code
@@ -123,7 +144,7 @@ export async function* ask(options) {
123
144
  chunk = await reader.read();
124
145
  }
125
146
  catch {
126
- if (options.signal?.aborted)
147
+ if (signal?.aborted)
127
148
  return;
128
149
  yield { type: 'library_error' };
129
150
  return;
@@ -140,7 +161,7 @@ export async function* ask(options) {
140
161
  if (!event)
141
162
  continue;
142
163
  if (event.type === 'result' || event.type === 'library_error')
143
- terminal = true;
164
+ ended = true;
144
165
  yield event;
145
166
  }
146
167
  }
@@ -151,9 +172,9 @@ export async function* ask(options) {
151
172
  const last = parse(buffered);
152
173
  if (last) {
153
174
  if (last.type === 'result' || last.type === 'library_error')
154
- terminal = true;
175
+ ended = true;
155
176
  yield last;
156
177
  }
157
- if (!terminal && !options.signal?.aborted)
178
+ if (terminal && !ended && !signal?.aborted)
158
179
  yield { type: 'library_error' };
159
180
  }
@@ -52,7 +52,12 @@
52
52
  <div class="ds-lib-activity-steps">
53
53
  {#each group.steps as step (step.block.index)}
54
54
  {#if step.block.kind === 'tool'}
55
- <ToolRow block={step.block} repeats={step.repeats} running={live} />
55
+ <ToolRow
56
+ block={step.block}
57
+ repeats={step.repeats}
58
+ words={step.words}
59
+ running={live}
60
+ />
56
61
  {:else}
57
62
  <ThinkingRow block={step.block} active={false} />
58
63
  {/if}
@@ -18,8 +18,11 @@
18
18
  import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
19
19
  import {
20
20
  readerQuestion,
21
+ readFrom,
21
22
  segment,
23
+ type Artefact,
22
24
  type Block,
25
+ type DescribeTool,
23
26
  type Outcome,
24
27
  type TextBlock
25
28
  } from '../../transcript.svelte';
@@ -64,14 +67,15 @@
64
67
  * and a reader scanning back for "what did I ask after lunch" needs the
65
68
  * first one. Absent renders no time rather than a guess. */
66
69
  at?: number;
67
- /** A study artefact rather than an ordinary answer: once settled, this
68
- * renders as a card instead of prose. */
69
- kind?: 'answer' | 'artefact';
70
- /** The artefact's own name, for the card. */
71
- title?: string;
72
- /** Opens the artefact in the reading column. Required wherever `kind`
73
- * is `'artefact'`. */
70
+ /** What this turn produced for the reader to open, carded once it
71
+ * settles: under the prose, or in its place when it IS the prose. */
72
+ artefact?: Artefact;
73
+ /** The artefact is open now. */
74
+ artefactOpen?: boolean;
75
+ /** Opens it. Omit and the card informs but does not open. */
74
76
  onopenartefact?: () => void;
77
+ /** The persona's own words for its tools, and what they read. */
78
+ describeTool?: DescribeTool;
75
79
  }
76
80
 
77
81
  let {
@@ -88,12 +92,15 @@
88
92
  copy,
89
93
  name = DEFAULT_PERSONA,
90
94
  at,
91
- kind = 'answer',
92
- title,
93
- onopenartefact
95
+ artefact,
96
+ artefactOpen = false,
97
+ onopenartefact,
98
+ describeTool
94
99
  }: Props = $props();
95
100
 
96
- const isArtefact = $derived(kind === 'artefact');
101
+ // The prose IS the artefact: it reads in the column, and the transcript
102
+ // shows the card alone.
103
+ const isArtefact = $derived(artefact?.isAnswer === true);
97
104
 
98
105
  const words = $derived(resolveCopy(copy, name));
99
106
  const who = $derived(personaName(name));
@@ -120,7 +127,7 @@
120
127
  // An activity group stays live — and therefore labelled "Working…" — only
121
128
  // while it is the last thing in the turn. As soon as prose arrives, it
122
129
  // settles into its count.
123
- const segments = $derived(segment(blocks));
130
+ const segments = $derived(segment(blocks, describeTool));
124
131
  const lastIndex = $derived(segments.at(-1)?.index ?? -1);
125
132
 
126
133
  // An answer can arrive as SEVERAL text blocks with tool calls between them,
@@ -135,7 +142,11 @@
135
142
  const split = $derived(
136
143
  running ? { body: '', citations: [] } : splitSources(texts.at(-1)?.text ?? '')
137
144
  );
138
- const sources = $derived(resolveCitations(citations, split.citations));
145
+ // What the calls read, when the answer names no source of its own.
146
+ const read = $derived(running ? [] : readFrom(blocks, describeTool));
147
+ const sources = $derived(
148
+ resolveCitations(citations, split.citations.length > 0 ? split.citations : read)
149
+ );
139
150
 
140
151
  /** The answer as a reader would paste it: every block, Sources stripped. */
141
152
  const answer = $derived(
@@ -218,13 +229,16 @@
218
229
  <article class="ds-lib-turn">
219
230
  <!-- Trailing-edge bubble, following Claude and ChatGPT rather than a
220
231
  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}
227
- </div>
232
+ those two as their model of what a chat looks like. A session whose
233
+ stream never carried its prompt has no words to put here. -->
234
+ {#if asked}
235
+ <div class="ds-lib-ask">
236
+ <div class="ds-lib-bubble">{asked}</div>
237
+ {#if clock}
238
+ <time class="ds-lib-ask-time" datetime={clock.iso}>{clock.text}</time>
239
+ {/if}
240
+ </div>
241
+ {/if}
228
242
 
229
243
  <div class="ds-lib-answer">
230
244
  <header class="ds-lib-answer-head">
@@ -258,19 +272,17 @@
258
272
  {/if}
259
273
  {/each}
260
274
 
261
- {#if isArtefact && settled}
262
- <ArtefactCard
263
- title={title ?? 'Briefing'}
264
- citationCount={sources.length}
265
- onopen={() => onopenartefact?.()}
266
- />
275
+ {#if artefact && settled}
276
+ <ArtefactCard {artefact} open={artefactOpen} onopen={onopenartefact} copy={words} />
267
277
  {/if}
268
278
 
269
279
  {#if !isArtefact && sources.length > 0}
270
280
  <SourceList {sources} {words} {oncite} />
271
281
  {/if}
272
282
 
273
- {#if !isArtefact && notHeld}
283
+ <!-- An empty `notHeld` is a host whose persona answers from no shelf,
284
+ where "without a source" would be a claim about nothing. -->
285
+ {#if !isArtefact && notHeld && words.notHeld}
274
286
  <p class="ds-lib-not-held">{words.notHeld}</p>
275
287
  {/if}
276
288
 
@@ -1,4 +1,4 @@
1
- import { type Block, type Outcome } from '../../transcript.svelte';
1
+ import { type Artefact, type Block, type DescribeTool, type Outcome } from '../../transcript.svelte';
2
2
  import { type Citation } from '../../citations';
3
3
  import { type LibrarianCopy } from '../../copy';
4
4
  interface Props {
@@ -29,14 +29,15 @@ interface Props {
29
29
  * and a reader scanning back for "what did I ask after lunch" needs the
30
30
  * first one. Absent renders no time rather than a guess. */
31
31
  at?: number;
32
- /** A study artefact rather than an ordinary answer: once settled, this
33
- * renders as a card instead of prose. */
34
- kind?: 'answer' | 'artefact';
35
- /** The artefact's own name, for the card. */
36
- title?: string;
37
- /** Opens the artefact in the reading column. Required wherever `kind`
38
- * is `'artefact'`. */
32
+ /** What this turn produced for the reader to open, carded once it
33
+ * settles: under the prose, or in its place when it IS the prose. */
34
+ artefact?: Artefact;
35
+ /** The artefact is open now. */
36
+ artefactOpen?: boolean;
37
+ /** Opens it. Omit and the card informs but does not open. */
39
38
  onopenartefact?: () => void;
39
+ /** The persona's own words for its tools, and what they read. */
40
+ describeTool?: DescribeTool;
40
41
  }
41
42
  declare const AgentTranscript: import("svelte").Component<Props, {}, "">;
42
43
  type AgentTranscript = ReturnType<typeof AgentTranscript>;
@@ -1,68 +1,129 @@
1
1
  <!--
2
- A study artefact's card in the transcript: what a colleague taps to read it.
3
- It is not the artefact itself — that lives in the reading column
4
- (`ArtefactPane`), never stacked beside this card and never inline as prose,
5
- which is the whole of the one-surface decision this package exists to keep
6
- every consumer honest about.
2
+ An artefact's card in the transcript: what a reader taps to open it.
3
+
4
+ It is not the artefact itself. Every word on it is the host's — what the
5
+ thing is called and what it holds — and opening it is the host's too, in
6
+ whatever column the host keeps for it; the package's own `ArtefactPane` is
7
+ one such column, for an artefact that is the answer's own prose.
8
+
9
+ One button, not a card with a button in it: the whole row is the target,
10
+ so a thumb that lands on the summary still opens it.
7
11
  -->
8
12
  <script lang="ts">
9
13
  import FileTextIcon from '@lucide/svelte/icons/file-text';
10
- import Panel from '@poodle64/ui/panel';
14
+ import type { Artefact } from '../../transcript.svelte';
15
+ import { resolveCopy, type LibrarianCopy } from '../../copy';
11
16
 
12
17
  interface Props {
13
- title: string;
14
- citationCount: number;
15
- onopen: () => void;
18
+ artefact: Artefact;
19
+ /** The host is showing it now. */
20
+ open?: boolean;
21
+ /** Omit and the card informs but does not open: a host with nowhere
22
+ * to show the artefact offers no action rather than a dead one. */
23
+ onopen?: () => void;
24
+ copy?: Partial<LibrarianCopy>;
16
25
  }
17
26
 
18
- let { title, citationCount, onopen }: Props = $props();
27
+ let { artefact, open = false, onopen, copy }: Props = $props();
28
+
29
+ const words = $derived(resolveCopy(copy));
19
30
  </script>
20
31
 
21
- <Panel
22
- icon={FileTextIcon}
23
- {title}
24
- subtitle="Briefing doc"
25
- role="button"
26
- tabindex={0}
27
- onclick={onopen}
28
- onkeydown={(event: KeyboardEvent) => {
29
- if (event.key !== 'Enter' && event.key !== ' ') return;
30
- event.preventDefault();
31
- onopen();
32
- }}
33
- class="ds-lib-artefact-card"
32
+ <button
33
+ type="button"
34
+ class="ds-lib-artefact"
35
+ class:is-open={open}
36
+ onclick={() => onopen?.()}
37
+ disabled={!onopen}
34
38
  >
35
- {#snippet children()}
36
- <p class="ds-lib-artefact-count">
37
- {citationCount} source{citationCount === 1 ? '' : 's'}
38
- </p>
39
- {/snippet}
40
- </Panel>
39
+ <span class="ds-lib-artefact-icon" aria-hidden="true"><FileTextIcon size={16} /></span>
40
+ <span class="ds-lib-artefact-text">
41
+ <span class="ds-lib-artefact-title">{artefact.title}</span>
42
+ {#if artefact.summary}<span class="ds-lib-artefact-summary">{artefact.summary}</span>{/if}
43
+ </span>
44
+ {#if onopen}
45
+ <span class="ds-lib-artefact-action">{open ? words.showingArtefact : words.openArtefact}</span>
46
+ {/if}
47
+ </button>
41
48
 
42
49
  <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) {
50
+ .ds-lib-artefact {
51
+ display: flex;
47
52
  width: 100%;
48
- max-width: 28rem;
49
- cursor: pointer;
53
+ align-items: center;
54
+ gap: 0.75rem;
55
+ border: 1px solid var(--ds-color-border);
56
+ border-radius: var(--ds-radius-lg);
57
+ background: var(--ds-color-surface-1);
58
+ padding: 0.625rem 0.75rem;
59
+ color: var(--ds-color-foreground);
60
+ font: inherit;
50
61
  text-align: start;
62
+ cursor: pointer;
51
63
  transition: border-color 150ms ease;
52
64
  }
53
65
 
54
- :global(.ds-lib-artefact-card:hover) {
66
+ .ds-lib-artefact:hover:not(:disabled) {
55
67
  border-color: var(--ds-color-border-strong);
56
68
  }
57
69
 
58
- :global(.ds-lib-artefact-card:focus-visible) {
70
+ .ds-lib-artefact:disabled {
71
+ cursor: default;
72
+ }
73
+
74
+ .ds-lib-artefact:focus-visible {
59
75
  outline: 2px solid var(--ds-color-ring);
60
76
  outline-offset: 2px;
61
77
  }
62
78
 
63
- .ds-lib-artefact-count {
64
- margin: 0;
79
+ /* The primary edge, not a fill: the card says "this is what is open" and
80
+ the column beside it carries the weight. */
81
+ .ds-lib-artefact.is-open {
82
+ border-color: var(--ds-color-primary);
83
+ }
84
+
85
+ .ds-lib-artefact-icon {
86
+ display: flex;
87
+ width: 2rem;
88
+ height: 2rem;
89
+ flex: none;
90
+ align-items: center;
91
+ justify-content: center;
92
+ border: 1px solid var(--ds-color-border);
93
+ border-radius: var(--ds-radius-md);
94
+ color: var(--ds-color-muted-foreground);
95
+ }
96
+
97
+ .ds-lib-artefact-text {
98
+ display: flex;
99
+ min-width: 0;
100
+ flex: 1;
101
+ flex-direction: column;
102
+ }
103
+
104
+ .ds-lib-artefact-title {
105
+ font-size: 0.875rem;
106
+ font-weight: 500;
107
+ line-height: 1.25rem;
108
+ }
109
+
110
+ .ds-lib-artefact-summary {
65
111
  color: var(--ds-color-muted-foreground);
66
112
  font-size: var(--ds-text-2xs);
113
+ overflow-wrap: anywhere;
114
+ }
115
+
116
+ .ds-lib-artefact-action {
117
+ flex: none;
118
+ border: 1px solid var(--ds-color-border);
119
+ border-radius: var(--ds-radius-md);
120
+ padding: 0.25rem 0.625rem;
121
+ font-size: 0.8125rem;
122
+ }
123
+
124
+ @media (prefers-reduced-motion: reduce) {
125
+ .ds-lib-artefact {
126
+ transition: none;
127
+ }
67
128
  }
68
129
  </style>
@@ -1,7 +1,13 @@
1
+ import type { Artefact } from '../../transcript.svelte';
2
+ import { type LibrarianCopy } from '../../copy';
1
3
  interface Props {
2
- title: string;
3
- citationCount: number;
4
- onopen: () => void;
4
+ artefact: Artefact;
5
+ /** The host is showing it now. */
6
+ open?: boolean;
7
+ /** Omit and the card informs but does not open: a host with nowhere
8
+ * to show the artefact offers no action rather than a dead one. */
9
+ onopen?: () => void;
10
+ copy?: Partial<LibrarianCopy>;
5
11
  }
6
12
  declare const ArtefactCard: import("svelte").Component<Props, {}, "">;
7
13
  type ArtefactCard = ReturnType<typeof ArtefactCard>;