@poodle64/librarian 2026.10.5 → 2026.10.7

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
@@ -179,7 +179,7 @@ on it fires once and never again.
179
179
  | Asking again | `onregenerate`: re-send the last question as a NEW turn; the package exposes the action and never re-asks by itself |
180
180
  | Choosing a collection | `Composer`'s `collections` (`{ name, title?, description? }[]`, the ones the person may read) and `collection` (the chosen name; undefined is all of them), with `oncollection(name \| undefined)`. From two collections up it is ONE control naming the choice, opening a listbox (searchable from eight) where each shows by its title (the name made readable where there is none) over its description, never a retrieval summary |
181
181
  | The empty state | `welcome` and up to three `examples`. With a composer, the opening and the composer sit together in the middle of the panel, the examples, the folded scope and `lead` under it; the first turn docks the composer at the foot |
182
- | Conversation controls | `toolbar`: a snippet at the measure's trailing edge above the transcript, outside its scroll (the room page's Past questions and New question) |
182
+ | Conversation controls | `toolbar`: a snippet at the measure's trailing edge above the transcript, outside its scroll (the chat page's Past questions and New question) |
183
183
  | What this surface covers | `scope`: one statement per persona, in the host's own words. Rendered above the first turn and folded to a line once the conversation starts |
184
184
  | The words themselves | `copy`: a partial of `ChatCopy`. Every component resolves it itself, so overriding one line does not mean restating the rest |
185
185
  | An answer its own way | `turn`: a snippet given each turn's `AgentTranscriptProps` in place of `AgentTranscript`. Render your own from the parts, or `AgentTranscript` inside your own to add to it |
@@ -405,7 +405,7 @@ end that way.
405
405
  | Documents it wrote | `artefact` frames and a stored turn's `artefacts` (each with `title`, `kind`, `changes`, `pages`) become `turn.written`: the newest version of each is a card with Open and a download chip (Word where `formats` has it, else the source). The pane marks what a version changed since the one before: a "Changed since vN" line from `changes`, and a bar beside each changed block. `loadWritten(name, version)` and `writtenUrl(name, version, format)` (`Chat.loadArtefact` / `Chat.artefactUrl` over the transport) let the pane show a version: Markdown sanitised, HTML only in a `sandbox=""` frame |
406
406
  | The turn's steps | One fold, "Read your attachment, searched the library and the web, and wrote a minute · 6 steps", composed from the steps (`stepsSummary`); it says what is happening now while the turn runs, and opens on one row per step (a search row counts what it found, a web search opens on the pages it found) |
407
407
  | Web sources | Pages from the turn's supacrawl calls and the answer's Markdown links fold with the library's sources ("6 sources · 4 library · 2 web"), numbered on from the library's, and a link to one in the answer reads as that number's chip |
408
- | Drawing a fence | `visuals` on `Conversation` is a registry keyed on a fence's language, `{ component, parse }` each; `defaultVisuals` (from `@poodle64/librarian/visuals`) draws `vega-lite` (one Vega-Lite v5 spec, in the app's tokens), `timeline` and `card`. An app passes `{ ...defaultVisuals, openui }`. A block whose closing fence has not arrived is "Drawing…", one that does not parse is its code |
408
+ | Drawing a fence | `visuals` on `Conversation` is a registry keyed on a fence's language, `{ component, parse }` each; `defaultVisuals` (from `@poodle64/librarian/visuals`) draws `chart` (`{title, type: bar | line | area, x, y: [fields], data}` on LayerChart, in the app's `--chart-1`…`--chart-5` tokens, loaded when an answer carries one), `timeline` and `card`. An app passes `{ ...defaultVisuals, openui }`. A block whose closing fence has not arrived is "Drawing…", one that does not parse is its code |
409
409
  | "Answered without a source" | `copy.notHeld: ''` for a persona that answers from no sources |
410
410
  | Saying something mid-run | `Composer`'s `sendWhileRunning`: while the session works the box stays open and Send sits beside Stop. For a route that reads a message while the run works; a chat leaves it off and waits for its answer |
411
411
 
@@ -205,11 +205,13 @@ export declare class Chat {
205
205
  new(): void;
206
206
  /**
207
207
  * Ask `question`, or what is in the box, as a `kind` of turn when it is not
208
- * a plain question, narrowed to one `collection` by name when given. Resolves
209
- * once the turn settles: true if it was asked, false if it was refused,
210
- * here or by the server, in which case typed words go back into the box.
208
+ * a plain question, narrowed to one `collection` by name when given. A
209
+ * question handed in carries `files` with it, the box's own going with a
210
+ * typed one. Resolves once the turn settles: true if it was asked, false if
211
+ * it was refused, here or by the server, in which case the question and its
212
+ * files go back into the box, handed or typed.
211
213
  */
212
- ask(question?: string, kind?: string, collection?: string): Promise<boolean>;
214
+ ask(question?: string, kind?: string, collection?: string, files?: File[]): Promise<boolean>;
213
215
  /** The last question again, as a new turn: the conversation is append-only,
214
216
  * because the agent's transcript is. Same kind, same collection. */
215
217
  again(): Promise<boolean>;
@@ -144,25 +144,31 @@ export class Chat {
144
144
  }
145
145
  /**
146
146
  * Ask `question`, or what is in the box, as a `kind` of turn when it is not
147
- * a plain question, narrowed to one `collection` by name when given. Resolves
148
- * once the turn settles: true if it was asked, false if it was refused,
149
- * here or by the server, in which case typed words go back into the box.
147
+ * a plain question, narrowed to one `collection` by name when given. A
148
+ * question handed in carries `files` with it, the box's own going with a
149
+ * typed one. Resolves once the turn settles: true if it was asked, false if
150
+ * it was refused, here or by the server, in which case the question and its
151
+ * files go back into the box, handed or typed.
150
152
  */
151
- async ask(question, kind, collection) {
153
+ async ask(question, kind, collection, files = []) {
152
154
  const typed = question === undefined;
153
155
  const text = (question ?? this.draft).trim();
154
156
  if (!text)
155
157
  return false;
156
158
  if (this.#job)
157
159
  return this.#message(text, typed);
158
- if (this.busy || this.quota?.reached)
160
+ if (this.busy || this.quota?.reached) {
161
+ // A handed question has no other home: the box takes it, as it takes a refused one.
162
+ if (!typed)
163
+ this.#restore(text, files);
159
164
  return false;
160
- const files = typed ? this.files : [];
165
+ }
166
+ const sent = typed ? this.files : files;
161
167
  if (typed) {
162
168
  this.draft = '';
163
169
  this.files = [];
164
170
  }
165
- return this.#ask(text, files, typed, kind, collection);
171
+ return this.#ask(text, sent, typed, kind, collection);
166
172
  }
167
173
  /** The last question again, as a new turn: the conversation is append-only,
168
174
  * because the agent's transcript is. Same kind, same collection. */
@@ -419,10 +425,7 @@ export class Chat {
419
425
  const at = this.#turns.findIndex((t) => t.id === live.id);
420
426
  if (at >= 0)
421
427
  this.#turns.splice(at, 1);
422
- if (typed) {
423
- this.draft = text;
424
- this.files = files;
425
- }
428
+ this.#restore(text, files);
426
429
  this.#finish();
427
430
  // Asked elsewhere and still being answered: show that instead.
428
431
  if (ending === 'answering' && id)
@@ -564,6 +567,11 @@ export class Chat {
564
567
  return;
565
568
  }
566
569
  }
570
+ /** A question that was not asked goes back into the box with its files, ahead of anything typed since. */
571
+ #restore(text, files) {
572
+ this.draft = this.draft ? `${text}\n\n${this.draft}` : text;
573
+ this.files = [...files, ...this.files];
574
+ }
567
575
  async #message(text, typed) {
568
576
  const id = this.conversationId;
569
577
  if (!id)
@@ -576,6 +584,8 @@ export class Chat {
576
584
  catch {
577
585
  if (typed)
578
586
  this.draft = text;
587
+ else
588
+ this.#restore(text, []);
579
589
  return false;
580
590
  }
581
591
  // A settled job takes it as a new run, already started when the
@@ -18,6 +18,7 @@
18
18
  import PencilIcon from '@lucide/svelte/icons/pencil';
19
19
  import FileTextIcon from '@lucide/svelte/icons/file-text';
20
20
  import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
21
+ import type { Snippet } from 'svelte';
21
22
  import {
22
23
  outcomeLine,
23
24
  readerQuestion,
@@ -116,6 +117,9 @@
116
117
  * ("about A$0.34"). The package never converts currency itself,
117
118
  * and a zero-cost run (a local model) shows no cost regardless. */
118
119
  formatCost?: (usd: number) => string;
120
+ /** Drawn in place of the package's own "working" line while the answer has
121
+ * shown nothing yet; given the line's `label` and `since`. */
122
+ working?: Snippet<[{ label?: string; since?: number }]>;
119
123
  }
120
124
 
121
125
  let {
@@ -145,7 +149,8 @@
145
149
  onmark,
146
150
  shared,
147
151
  onshare,
148
- formatCost
152
+ formatCost,
153
+ working
149
154
  }: AgentTranscriptProps = $props();
150
155
 
151
156
  // The prose IS the artefact: it reads in the column, and the transcript
@@ -390,11 +395,18 @@
390
395
  </header>
391
396
 
392
397
  {#if running && segments.length === 0}
393
- <Working
394
- copy={words}
395
- label={answering !== undefined ? words.stillAnswering : undefined}
396
- since={answering}
397
- />
398
+ {#if working}
399
+ {@render working({
400
+ label: answering !== undefined ? words.stillAnswering : undefined,
401
+ since: answering
402
+ })}
403
+ {:else}
404
+ <Working
405
+ copy={words}
406
+ label={answering !== undefined ? words.stillAnswering : undefined}
407
+ since={answering}
408
+ />
409
+ {/if}
398
410
  {/if}
399
411
 
400
412
  {#each segments as seg (seg.index)}
@@ -1,3 +1,4 @@
1
+ import type { Snippet } from 'svelte';
1
2
  import { type Artefact, type Block, type DescribeTool, type Outcome } from '../../transcript.svelte';
2
3
  import { type Citation } from '../../citations';
3
4
  import { type ChatCopy } from '../../copy';
@@ -71,6 +72,12 @@ export interface AgentTranscriptProps {
71
72
  * ("about A$0.34"). The package never converts currency itself,
72
73
  * and a zero-cost run (a local model) shows no cost regardless. */
73
74
  formatCost?: (usd: number) => string;
75
+ /** Drawn in place of the package's own "working" line while the answer has
76
+ * shown nothing yet; given the line's `label` and `since`. */
77
+ working?: Snippet<[{
78
+ label?: string;
79
+ since?: number;
80
+ }]>;
74
81
  }
75
82
  declare const AgentTranscript: import("svelte").Component<AgentTranscriptProps, {}, "">;
76
83
  type AgentTranscript = ReturnType<typeof AgentTranscript>;
@@ -120,8 +120,13 @@
120
120
 
121
121
  // Who the question is put to: every collection the person may read, or one
122
122
  // of them. A person with one collection (or none) has nothing to pick, so
123
- // nothing renders rather than a control that only reselects itself.
124
- const hasChoice = $derived(collections.length > 1);
123
+ // nothing renders rather than a control that only reselects itself, unless
124
+ // the question is already narrowed to the one held: a page that has not yet
125
+ // had the list shows the collection its address names, and the control stays
126
+ // put as the rest of the list arrives.
127
+ const hasChoice = $derived(
128
+ collections.length > 1 || (collection !== undefined && collections.length > 0)
129
+ );
125
130
 
126
131
  let box = $state<HTMLElement | null>(null);
127
132
  let wrapper = $state<HTMLElement | null>(null);
@@ -137,8 +142,10 @@
137
142
  const el = textarea;
138
143
  if (!el) return;
139
144
  el.style.height = 'auto';
140
- const line = parseFloat(getComputedStyle(el).lineHeight) || 24;
141
- el.style.height = `${Math.min(el.scrollHeight, line * 6 + 20)}px`;
145
+ const style = getComputedStyle(el);
146
+ const line = parseFloat(style.lineHeight) || 27;
147
+ const padding = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
148
+ el.style.height = `${Math.min(el.scrollHeight, line * 6 + padding)}px`;
142
149
  }
143
150
 
144
151
  $effect(() => {
@@ -280,7 +287,7 @@
280
287
  bind:value
281
288
  oninput={grow}
282
289
  onkeydown={keydown}
283
- rows="1"
290
+ rows="3"
284
291
  disabled={waiting}
285
292
  placeholder={waiting ? words.answeringPlaceholder : words.askPlaceholder}
286
293
  class="ds-lib-input"
@@ -305,7 +312,7 @@
305
312
  disabled={running || files.length >= MAX_FILES}
306
313
  aria-label="Attach a file"
307
314
  >
308
- <PaperclipIcon size={16} />
315
+ <PaperclipIcon size={20} />
309
316
  </button>
310
317
  {/if}
311
318
 
@@ -318,7 +325,7 @@
318
325
  aria-label="Ask for a briefing"
319
326
  title="Ask for a briefing"
320
327
  >
321
- <FileTextIcon size={16} />
328
+ <FileTextIcon size={20} />
322
329
  </button>
323
330
  {/if}
324
331
 
@@ -339,7 +346,7 @@
339
346
  <span class="ds-lib-spacer"></span>
340
347
  {#if running}
341
348
  <button type="button" class="ds-lib-stop" onclick={onstop} aria-label="Stop">
342
- <SquareIcon size={14} fill="currentColor" />
349
+ <SquareIcon size={16} fill="currentColor" />
343
350
  </button>
344
351
  {/if}
345
352
  {#if !waiting}
@@ -350,7 +357,7 @@
350
357
  disabled={!value.trim()}
351
358
  aria-label="Send"
352
359
  >
353
- <ArrowUpIcon size={16} />
360
+ <ArrowUpIcon size={20} />
354
361
  </button>
355
362
  {/if}
356
363
  </div>
@@ -441,10 +448,14 @@
441
448
  resize: none;
442
449
  border: 0;
443
450
  background: transparent;
444
- padding: 0.75rem 1rem 0.5rem;
451
+ padding: var(--ds-spacing-md) var(--ds-spacing-md) var(--ds-spacing-sm);
445
452
  color: var(--ds-color-foreground);
446
453
  font: inherit;
447
- font-size: 1rem;
454
+ /* No token carries a body size or a control size, so these are raw:
455
+ a resting box that reads as a chat composer on every page (an app
456
+ overriding them is a defect here). Three lines, 18px text. */
457
+ font-size: 1.125rem;
458
+ line-height: 1.5;
448
459
  outline: none;
449
460
  }
450
461
 
@@ -462,7 +473,7 @@
462
473
  align-items: center;
463
474
  gap: 0.5rem;
464
475
  row-gap: 0.375rem;
465
- padding: 0 0.75rem 0.625rem;
476
+ padding: 0 var(--ds-spacing-sm) var(--ds-spacing-sm);
466
477
  }
467
478
 
468
479
  .ds-lib-picker {
@@ -473,8 +484,8 @@
473
484
  .ds-lib-stop,
474
485
  .ds-lib-send {
475
486
  display: flex;
476
- width: 2rem;
477
- height: 2rem;
487
+ width: 2.75rem;
488
+ height: 2.75rem;
478
489
  flex: none;
479
490
  align-items: center;
480
491
  justify-content: center;
@@ -292,10 +292,11 @@
292
292
  border: 1px solid transparent;
293
293
  border-radius: var(--ds-radius-full);
294
294
  background: none;
295
- padding: 0.25rem 0.5rem 0.25rem 0.625rem;
295
+ min-height: 2.75rem;
296
+ padding: 0 var(--ds-spacing-sm) 0 0.75rem;
296
297
  color: var(--ds-color-muted-foreground);
297
298
  font: inherit;
298
- font-size: var(--ds-text-2xs);
299
+ font-size: 0.875rem;
299
300
  cursor: pointer;
300
301
  transition:
301
302
  color 150ms ease,
@@ -137,6 +137,9 @@
137
137
  * transcript. It stays put while the transcript scrolls and narrows
138
138
  * with it when a pane opens. */
139
139
  toolbar?: Snippet;
140
+ /** Drawn in place of each answer's "working" line before it shows anything
141
+ * (`AgentTranscriptProps.working`). */
142
+ working?: AgentTranscriptProps['working'];
140
143
  }
141
144
 
142
145
  let {
@@ -168,7 +171,8 @@
168
171
  turn: presentTurn,
169
172
  lead,
170
173
  toolbar,
171
- formatCost
174
+ formatCost,
175
+ working
172
176
  }: Props = $props();
173
177
 
174
178
  // Resolved ONCE, here, and handed down whole: every child takes `copy` and
@@ -392,6 +396,7 @@
392
396
  ? (text: string) => onedit(index, text)
393
397
  : undefined,
394
398
  formatCost,
399
+ working,
395
400
  oncite: oncite || loadDocument ? cite : undefined,
396
401
  onregenerate: last && !running ? onregenerate : undefined,
397
402
  onmark:
@@ -96,6 +96,9 @@ interface Props {
96
96
  * transcript. It stays put while the transcript scrolls and narrows
97
97
  * with it when a pane opens. */
98
98
  toolbar?: Snippet;
99
+ /** Drawn in place of each answer's "working" line before it shows anything
100
+ * (`AgentTranscriptProps.working`). */
101
+ working?: AgentTranscriptProps['working'];
99
102
  }
100
103
  declare const Conversation: import("svelte").Component<Props, {}, "">;
101
104
  type Conversation = ReturnType<typeof Conversation>;
@@ -0,0 +1,26 @@
1
+ <script lang="ts">
2
+ import { AreaChart, BarChart, LineChart } from 'layerchart';
3
+ import type { ChartValue } from '../../visuals';
4
+
5
+ let { value }: { value: ChartValue } = $props();
6
+
7
+ // `--chart-1` to `--chart-5` are the app's own series colours.
8
+ const series = $derived(
9
+ value.y.map((key, index) => ({ key, label: key, color: `var(--chart-${index + 1})` }))
10
+ );
11
+ const legend = $derived(series.length > 1);
12
+ // A line or area over ISO dates is a time axis; anything else is the label it says.
13
+ const data = $derived(
14
+ value.type !== 'bar' && value.data.every((row) => /^\d{4}-\d{2}(-\d{2})?$/.test(String(row[value.x])))
15
+ ? value.data.map((row) => ({ ...row, [value.x]: new Date(String(row[value.x])) }))
16
+ : value.data
17
+ );
18
+ </script>
19
+
20
+ {#if value.type === 'bar'}
21
+ <BarChart {data} x={value.x} {series} {legend} seriesLayout="group" props={{ bars: { strokeWidth: 0, radius: 2 } }} />
22
+ {:else if value.type === 'line'}
23
+ <LineChart {data} x={value.x} {series} {legend} />
24
+ {:else}
25
+ <AreaChart {data} x={value.x} {series} {legend} />
26
+ {/if}
@@ -0,0 +1,7 @@
1
+ import type { ChartValue } from '../../visuals';
2
+ type $$ComponentProps = {
3
+ value: ChartValue;
4
+ };
5
+ declare const ChartDraw: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type ChartDraw = ReturnType<typeof ChartDraw>;
7
+ export default ChartDraw;
@@ -0,0 +1,73 @@
1
+ <!--
2
+ One chart block, drawn with LayerChart in the app's chart tokens. The persona
3
+ writes the data and the kind; colour, type, axes and size come from here.
4
+ LayerChart is loaded when an answer carries a chart, never with the page.
5
+ -->
6
+ <script lang="ts">
7
+ import type { Component } from 'svelte';
8
+ import type { ChartValue } from '../../visuals';
9
+
10
+ interface Props {
11
+ value: unknown;
12
+ source: string;
13
+ }
14
+
15
+ let { value, source }: Props = $props();
16
+ const chart = $derived(value as ChartValue);
17
+ let Draw = $state<Component<{ value: ChartValue }> | null>(null);
18
+ let failed = $state(false);
19
+
20
+ $effect(() => {
21
+ if (Draw || failed) return;
22
+ import('./chart-draw.svelte').then(
23
+ (module) => (Draw = module.default),
24
+ () => (failed = true)
25
+ );
26
+ });
27
+ </script>
28
+
29
+ {#if failed}
30
+ <pre data-highlighted="true"><code>{source}</code></pre>
31
+ {:else}
32
+ <figure class="ds-lib-chart">
33
+ {#if chart.title}<figcaption class="ds-lib-chart-title">{chart.title}</figcaption>{/if}
34
+ <div class="ds-lib-chart-host">
35
+ {#if Draw}<Draw value={chart} />{/if}
36
+ </div>
37
+ </figure>
38
+ {/if}
39
+
40
+ <style>
41
+ .ds-lib-chart {
42
+ margin: 0.75em 0;
43
+ border: 1px solid var(--ds-color-border);
44
+ border-radius: var(--ds-radius-lg);
45
+ background: var(--ds-color-surface-1);
46
+ padding: 0.75rem 0.875rem;
47
+ }
48
+
49
+ .ds-lib-chart-title {
50
+ margin-bottom: 0.5rem;
51
+ color: var(--ds-color-foreground);
52
+ font-size: 0.875rem;
53
+ font-weight: 600;
54
+ }
55
+
56
+ .ds-lib-chart-host {
57
+ width: 100%;
58
+ height: 16rem;
59
+ }
60
+
61
+ .ds-lib-chart-host :global(text) {
62
+ fill: var(--ds-color-muted-foreground);
63
+ stroke: none;
64
+ font-size: 12px;
65
+ }
66
+
67
+ .ds-lib-chart-host :global(.lc-rule),
68
+ .ds-lib-chart-host :global(.lc-axis-tick),
69
+ .ds-lib-chart-host :global(.lc-grid-x-line),
70
+ .ds-lib-chart-host :global(.lc-grid-y-line) {
71
+ stroke: var(--ds-color-border-strong);
72
+ }
73
+ </style>
@@ -0,0 +1,8 @@
1
+ import type { Component } from 'svelte';
2
+ interface Props {
3
+ value: unknown;
4
+ source: string;
5
+ }
6
+ declare const Chart: Component<Props, {}, "">;
7
+ type Chart = ReturnType<typeof Chart>;
8
+ export default Chart;
package/dist/visuals.d.ts CHANGED
@@ -17,6 +17,14 @@ export interface Visual {
17
17
  /** The block's text as the value its component draws; throws on a block that is invalid. */
18
18
  parse: (source: string) => unknown;
19
19
  }
20
+ export interface ChartValue {
21
+ title: string;
22
+ type: 'bar' | 'line' | 'area';
23
+ /** The field on the x axis; each y is a field drawn as a series. */
24
+ x: string;
25
+ y: string[];
26
+ data: Array<Record<string, string | number>>;
27
+ }
20
28
  export interface TimelineValue {
21
29
  title: string;
22
30
  events: Array<{
@@ -34,5 +42,5 @@ export interface CardValue {
34
42
  value: string;
35
43
  }>;
36
44
  }
37
- /** A chart, a timeline and an entity card. An app adds to these or replaces one through `ChatExtensions.visuals`. */
45
+ /** A chart (LayerChart), a timeline and an entity card. An app adds to these or replaces one through `ChatExtensions.visuals`. */
38
46
  export declare const defaultVisuals: Record<string, Visual>;
package/dist/visuals.js CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import EntityCard from './components/visual-block/entity-card.svelte';
12
12
  import Timeline from './components/visual-block/timeline.svelte';
13
- import VegaChart from './components/visual-block/vega-chart.svelte';
13
+ import Chart from './components/visual-block/chart.svelte';
14
14
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
15
15
  const text = (value) => typeof value === 'string' ? value : typeof value === 'number' ? String(value) : '';
16
16
  const object = (source) => {
@@ -19,32 +19,34 @@ const object = (source) => {
19
19
  throw new Error('not an object');
20
20
  return value;
21
21
  };
22
- /** Any string naming an address: a spec that carries one could fetch it, or link to it carrying the conversation out. */
23
- const ADDRESS = /^[a-z][a-z0-9+.-]*:|\/\//i;
24
- /**
25
- * A spec that names an address anywhere, by key or by value, or carries
26
- * `usermeta`, whose `embedOptions` vega-embed merges over the app's own
27
- * (`config` or `patch` as a URL is fetched, `actions` turns the export menu back on).
28
- */
29
- function addressed(value) {
30
- if (typeof value === 'string')
31
- return ADDRESS.test(value.trim());
32
- if (Array.isArray(value))
33
- return value.some(addressed);
34
- if (!isRecord(value))
35
- return false;
36
- return Object.entries(value).some(
37
- // `$schema` names the spec's own version and is never drawn or fetched (`vega-chart` drops it).
38
- ([key, inner]) => key !== '$schema' && (key === 'url' || key === 'href' || key === 'usermeta' || addressed(inner)));
39
- }
40
- const VIEWS = ['mark', 'layer', 'hconcat', 'vconcat', 'concat', 'facet', 'repeat'];
41
- function parseVega(source) {
42
- const spec = object(source);
43
- if (!VIEWS.some((key) => key in spec))
44
- throw new Error('not a view');
45
- if (addressed(spec))
46
- throw new Error('names an address');
47
- return spec;
22
+ const KINDS = ['bar', 'line', 'area'];
23
+ const MAX_SERIES = 5;
24
+ const MAX_ROWS = 500;
25
+ function parseChart(source) {
26
+ const value = object(source);
27
+ const type = KINDS.find((kind) => kind === value.type);
28
+ if (!type)
29
+ throw new Error('not a bar, line or area');
30
+ const x = text(value.x);
31
+ const y = Array.isArray(value.y) ? value.y.map(text) : [];
32
+ if (!x || y.length === 0 || y.length > MAX_SERIES || y.some((key) => !key))
33
+ throw new Error('no x or y');
34
+ const rows = Array.isArray(value.data) ? value.data : [];
35
+ if (rows.length === 0 || rows.length > MAX_ROWS)
36
+ throw new Error('no data');
37
+ const data = rows.map((row) => {
38
+ if (!isRecord(row) || !text(row[x]))
39
+ throw new Error('a row with no x');
40
+ const point = { [x]: typeof row[x] === 'number' ? row[x] : text(row[x]) };
41
+ for (const key of y) {
42
+ const number = row[key];
43
+ if (typeof number !== 'number' || !Number.isFinite(number))
44
+ throw new Error('a y that is not a number');
45
+ point[key] = number;
46
+ }
47
+ return point;
48
+ });
49
+ return { title: text(value.title), type, x, y, data };
48
50
  }
49
51
  function parseTimeline(source) {
50
52
  const value = object(source);
@@ -74,9 +76,9 @@ function parseCard(source) {
74
76
  .filter((fact) => fact.label || fact.value)
75
77
  };
76
78
  }
77
- /** A chart, a timeline and an entity card. An app adds to these or replaces one through `ChatExtensions.visuals`. */
79
+ /** A chart (LayerChart), a timeline and an entity card. An app adds to these or replaces one through `ChatExtensions.visuals`. */
78
80
  export const defaultVisuals = {
79
- 'vega-lite': { component: VegaChart, parse: parseVega },
81
+ chart: { component: Chart, parse: parseChart },
80
82
  timeline: { component: Timeline, parse: parseTimeline },
81
83
  card: { component: EntityCard, parse: parseCard }
82
84
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/librarian",
3
- "version": "2026.10.5",
3
+ "version": "2026.10.7",
4
4
  "description": "An agent's conversation surface as a consumable Svelte 5 package: the stream client, transcript state and self-styling chat components (transcript, composer, markdown) every household app renders instead of rebuilding, speaking as whichever persona the app names.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -73,9 +73,7 @@
73
73
  "shiki": "^4.4.3"
74
74
  },
75
75
  "dependencies": {
76
- "vega": "6.4.0",
77
- "vega-embed": "7.3.0",
78
- "vega-lite": "6.4.3"
76
+ "layerchart": "2.6.0"
79
77
  },
80
78
  "devDependencies": {
81
79
  "@lucide/svelte": "^1.7.0",
@@ -1,110 +0,0 @@
1
- <!--
2
- One Vega-Lite v5 spec, drawn with the app's own tokens. The persona writes
3
- the data and the marks; colour, type, axes and size come from here and
4
- override whatever the spec says. Loaded on demand: vega is not small.
5
- -->
6
- <script lang="ts">
7
- import { onMount } from 'svelte';
8
-
9
- interface Props {
10
- value: unknown;
11
- source: string;
12
- }
13
-
14
- let { value, source }: Props = $props();
15
- let host = $state<HTMLDivElement | null>(null);
16
- let failed = $state(false);
17
-
18
- const css = (name: string): string =>
19
- getComputedStyle(document.documentElement).getPropertyValue(name).trim();
20
-
21
- function config() {
22
- const ink = css('--ds-color-foreground') || '#231f20';
23
- const muted = css('--ds-color-muted-foreground') || '#57595c';
24
- const line = css('--ds-color-border-strong') || '#00000038';
25
- const font = getComputedStyle(document.body).fontFamily;
26
- const series = [1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => css(`--chart-${i}`)).filter(Boolean);
27
- return {
28
- background: null,
29
- font,
30
- ...(series.length > 0 ? { range: { category: series } } : {}),
31
- mark: { color: series[0] ?? ink },
32
- bar: { cornerRadius: 1 },
33
- view: { stroke: null },
34
- axis: {
35
- labelColor: muted,
36
- titleColor: muted,
37
- domainColor: line,
38
- tickColor: line,
39
- gridColor: line,
40
- gridOpacity: 0.5,
41
- labelFont: font,
42
- titleFont: font,
43
- labelFontSize: 12,
44
- titleFontSize: 12,
45
- titleFontWeight: 600
46
- },
47
- legend: { labelColor: ink, titleColor: muted, labelFont: font, titleFont: font },
48
- title: { color: ink, font, fontSize: 14, fontWeight: 600, anchor: 'start' }
49
- };
50
- }
51
-
52
- let latest = 0;
53
- // The view drawn last, ended before the next is drawn and when the chart goes.
54
- let drawn: { finalize: () => void } | undefined;
55
- async function draw() {
56
- if (!host) return;
57
- const mine = ++latest;
58
- try {
59
- const { default: embed } = await import('vega-embed');
60
- if (mine !== latest) return;
61
- const { $schema: _schema, config: _config, usermeta: _usermeta, ...spec } = value as Record<string, unknown>;
62
- drawn?.finalize();
63
- drawn = await embed(
64
- host,
65
- { ...spec, width: 'container', autosize: { type: 'fit', contains: 'padding' } } as never,
66
- { actions: false, renderer: 'svg', config: config() as never }
67
- );
68
- failed = false;
69
- } catch {
70
- if (mine === latest) failed = true;
71
- }
72
- }
73
-
74
- onMount(() => {
75
- // The palette follows the theme, and the theme is a class on the root.
76
- const watch = new MutationObserver(() => void draw());
77
- watch.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
78
- return () => {
79
- watch.disconnect();
80
- drawn?.finalize();
81
- };
82
- });
83
-
84
- $effect(() => {
85
- void value;
86
- void host;
87
- void draw();
88
- });
89
- </script>
90
-
91
- {#if failed}
92
- <pre data-highlighted="true"><code>{source}</code></pre>
93
- {:else}
94
- <figure class="ds-lib-chart"><div bind:this={host} class="ds-lib-chart-host"></div></figure>
95
- {/if}
96
-
97
- <style>
98
- .ds-lib-chart {
99
- margin: 0.75em 0;
100
- border: 1px solid var(--ds-color-border);
101
- border-radius: var(--ds-radius-lg);
102
- background: var(--ds-color-surface-1);
103
- padding: 0.75rem 0.875rem;
104
- }
105
-
106
- .ds-lib-chart-host {
107
- width: 100%;
108
- min-height: 6rem;
109
- }
110
- </style>
@@ -1,7 +0,0 @@
1
- interface Props {
2
- value: unknown;
3
- source: string;
4
- }
5
- declare const VegaChart: import("svelte").Component<Props, {}, "">;
6
- type VegaChart = ReturnType<typeof VegaChart>;
7
- export default VegaChart;