@poodle64/librarian 2026.10.0 → 2026.10.2

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
@@ -35,7 +35,7 @@ src/lib/
35
35
  document-pane/ the cited document, open at the cited passage
36
36
  artefact-card/ an artefact's card, in the host's words
37
37
  artefact-pane/ an artefact that is its answer's prose, in the column
38
- composer/ the input box: attachments, collection picker, a note, send/stop
38
+ composer/ the input box: attachments, one collection control, a note, send/stop
39
39
  markdown/ sanitised, streaming-safe markdown + highlighting
40
40
  activity-group/ a whole investigation, as one quiet line
41
41
  tool-row/ one tool call
@@ -174,12 +174,13 @@ on it fires once and never again.
174
174
  | Attachments | nothing: `ask()` posts multipart (`question`, `resume`, `collection`, `files[]`) whenever `files` is non-empty, and JSON when it is not. The route must accept both |
175
175
  | Reading a cited document | `loadDocument(document_id) => Promise<{title, sections: [{anchor, heading, text}]}>`, proxied through the app's own authenticated route (caspo: `GET /api/sources/documents/{id}/content`) |
176
176
  | Asking again | `onregenerate`: re-send the last question as a NEW turn; the package exposes the action and never re-asks by itself |
177
- | Choosing a collection | `Composer`'s `collections` (`{ name, summary }[]`, the ones the person may read) and `collection` (the chosen name; undefined is all of them), with `oncollection(name \| undefined)`. It renders from two collections up, each by its summary, never its name |
178
- | The empty state | `welcome` and up to three `examples` |
177
+ | 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 |
178
+ | 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 |
179
+ | 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) |
179
180
  | 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 |
180
181
  | The words themselves | `copy`: a partial of `ChatCopy`. Every component resolves it itself, so overriding one line does not mean restating the rest |
181
182
  | 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 |
182
- | What leads a conversation | `lead`: a snippet inside the scroll, under the opening and the scope, so a tall one scrolls with the conversation instead of taking its height |
183
+ | What leads a conversation | `lead`: a snippet inside the scroll, under the opening and the scope, so a tall one scrolls with the conversation instead of taking its height; under the centred composer before the first question |
183
184
 
184
185
  Nothing here fetches on its own behalf. The document read is
185
186
  authenticated, and a package that called it directly would be reaching past
@@ -319,8 +320,9 @@ the job may go on by itself; `sendWhileRunning` is true.
319
320
  | Sharing an answer | `onshare(turn, share)` on `Conversation`, over `chat.share`: every settled answer offers Share, which copies the link `StoredTurn.shared` names; a shared one offers Copy link and Stop sharing. Nothing is shared unless the reader asks. A host's own `turn` places `AnswerShare` (`./answer-share`) with the `onshare` and `shared` it is handed |
320
321
  | A shared answer, read | `Conversation` given `[storedTurn(id, 0, answer)]` from `./chat` and no handlers, no composer: the question, the answer and its sources, read-only, citations opening as in the chat |
321
322
  | Still answering | `answering` on `Conversation`, from the controller |
323
+ | Reopening | `reopening` on `Conversation` while a reopened conversation has no turns yet (`chat.conversationId` set, `chat.turns` empty): docked and silent, never a new conversation's centred opening |
322
324
  | The allowance | `FairUseNotice quota={…}` in the composer snippet: a count while there are questions left, a notice with `support_url` once there are none. Nothing for `exempt`, or a `limit` of 0 |
323
- | Past conversations | `ConversationList`: newest first, `href` makes each row a link, and each act renders only when its handler is given; a delete asks first |
325
+ | Past conversations | `ConversationList`: newest first, `href` makes each row a link, and each act renders only when its handler is given; a delete asks first. A host laying it over the page (a sheet) passes `onclose` for the header's close |
324
326
 
325
327
  ### Watching a session somebody else started
326
328
 
@@ -480,7 +482,7 @@ of them is composed from the persona's name:
480
482
 
481
483
  That one prop carries the working line ("Penny is looking…"), both composer
482
484
  placeholders, the uncited-answer note, both failure sentences, the scope
483
- label, the collection picker's label, the opening line, the name signed on every answer card and the
485
+ label, the collection picker's label, the opening line, the name signed on every answer and the
484
486
  accessible name of the scroll region. A slug is fine: `penny` renders as
485
487
  "Penny", `chief-engineer` as "Chief Engineer", and a name the host already
486
488
  capitalised is left exactly as written.
@@ -511,8 +513,8 @@ settles, offering "Ask again".
511
513
 
512
514
  ### Timestamps
513
515
 
514
- `Turn.at` (epoch ms) renders as a clock time on the question and the answer
515
- card, which is a different fact from the duration badge beside the actions. A
516
+ `Turn.at` (epoch ms) renders as a clock time on the question and the answer,
517
+ which is a different fact from the duration badge beside the actions. A
516
518
  host that persists conversations sets it; a host that does not gets one
517
519
  stamped when the turn first appears, and a turn already on screen at mount —
518
520
  one read back out of a stored conversation — deliberately shows none rather than claiming
@@ -541,8 +543,9 @@ running from its own static build. The same script asserts what a screenshot
541
543
  cannot: that nothing scrolls sideways at any width, that the source pane
542
544
  opens and closes from the keyboard with focus returning to the chip and is
543
545
  really draggable, that the scope statement folds once there is a
544
- conversation over it and reopens from that line, that the collection picker
545
- starts on all of them and marks the one tapped by its summary, that the persona's
546
+ conversation over it and reopens from that line, that the collection control
547
+ starts on all of them, stays one control for 27 and takes a searched pick from
548
+ the keyboard, that past questions come over the page and hand focus back, that the persona's
546
549
  between-tool narration is nowhere in the answer prose before the activity
547
550
  line is opened, that a job's artefact and the pages it read open in the
548
551
  host's own columns and never in a pane of the package's, and that the reading
@@ -1,16 +1,16 @@
1
1
  <!--
2
2
  One question and everything the persona did answering it.
3
3
 
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.
4
+ Four things carry the shape, ChatGPT's and Claude's. The question is a quiet
5
+ bubble against the trailing edge, so a reader scanning back finds their own
6
+ words without reading any of the answer. The answer is prose under it, signed
7
+ with the persona's name and the time, in no box: space and type tell one turn
8
+ from the next (the operator's bar, 19/10/2026: "clean and elegant, like what
9
+ you get when you chat with ChatGPT or Claude"). Everything the persona DID is
10
+ one quiet line in the answer, never a stack. And the measure is the package's
11
+ own CSS, never a utility class a consuming app's Tailwind has to discover in
12
+ `dist` — the one that got away compiled to `max-width: none` in production
13
+ and left a 2302px-wide transcript.
14
14
  -->
15
15
  <script lang="ts">
16
16
  import CheckIcon from '@lucide/svelte/icons/check';
@@ -364,7 +364,7 @@
364
364
  .ds-lib-turn {
365
365
  display: flex;
366
366
  flex-direction: column;
367
- gap: 0.5rem;
367
+ gap: 1rem;
368
368
  }
369
369
 
370
370
  .ds-lib-ask {
@@ -376,27 +376,19 @@
376
376
  align-self: flex-end;
377
377
  }
378
378
 
379
- /* The reader's own words carry the consuming app's hue, the way every chat
380
- a person already uses colours THEIR side and leaves the other party
381
- neutral. It is an identity mark, not decoration: scanning back for "what
382
- did I ask" is a colour search, not a reading task.
383
-
384
- --ds-color-primary-solid, falling back to --ds-color-primary, because a
385
- dark-mode palette lightens its primary so the SAME token can serve as
386
- coloured text on a dark page. A fill behind light text and text on a dark
387
- ground want opposite lightness, and a brand asked to be both comes out
388
- washed — a deep red reads as salmon. An app with a fill-grade brand
389
- colour declares the solid pair; one that has not is unaffected.
390
- It stays on the SHORT side of the transcript on purpose — a question is
391
- a line or two, an answer is paragraphs, so filling the question is an
392
- accent and filling the answer would be a wall. */
379
+ /* A raised tone, not the brand: both references set the reader's words on
380
+ a quiet surface at the trailing edge, and position and shape are what a
381
+ reader scanning back finds them by. It stays on the SHORT side of the
382
+ transcript on purpose — a question is a line or two, an answer is
383
+ paragraphs, so the bubble is an accent and boxing the answer would be a
384
+ wall. */
393
385
  .ds-lib-bubble {
394
386
  max-width: 85%;
395
387
  border: 1px solid transparent;
396
388
  border-radius: var(--ds-radius-xl);
397
- background: var(--ds-color-primary-solid, var(--ds-color-primary));
389
+ background: var(--ds-color-surface-2);
398
390
  padding: 0.625rem 1rem;
399
- color: var(--ds-color-primary-solid-foreground, var(--ds-color-primary-foreground));
391
+ color: var(--ds-color-foreground);
400
392
  font-size: 1rem;
401
393
  line-height: 1.5rem;
402
394
  overflow-wrap: break-word;
@@ -410,18 +402,10 @@
410
402
  font-variant-numeric: tabular-nums;
411
403
  }
412
404
 
413
- /* The card. A turn a reader can see the edges of is the whole of what the
414
- director was asking for: one question and its answer, bounded, rather
415
- than prose running edge to edge under the last lot of prose. */
416
405
  .ds-lib-answer {
417
406
  display: flex;
418
407
  flex-direction: column;
419
408
  gap: 0.75rem;
420
- border: 1px solid var(--ds-color-border);
421
- border-inline-start: 3px solid var(--ds-color-primary);
422
- border-radius: var(--ds-radius-xl);
423
- background: var(--ds-color-surface-2);
424
- padding: 0.875rem 1rem 0.75rem;
425
409
  }
426
410
 
427
411
  .ds-lib-answer-head {
@@ -502,7 +486,7 @@
502
486
  }
503
487
 
504
488
  .ds-lib-action:hover {
505
- background: var(--ds-color-surface-1);
489
+ background: var(--ds-color-surface-2);
506
490
  color: var(--ds-color-foreground);
507
491
  }
508
492
 
@@ -138,7 +138,7 @@
138
138
  }
139
139
 
140
140
  .ds-lib-mark:hover:not(:disabled) {
141
- background: var(--ds-color-surface-1);
141
+ background: var(--ds-color-surface-2);
142
142
  }
143
143
 
144
144
  /* A verdict keeps its colour under the pointer that just gave it. */
@@ -101,7 +101,7 @@
101
101
  }
102
102
 
103
103
  .ds-lib-share:hover:not(:disabled) {
104
- background: var(--ds-color-surface-1);
104
+ background: var(--ds-color-surface-2);
105
105
  color: var(--ds-color-foreground);
106
106
  }
107
107
 
@@ -0,0 +1,12 @@
1
+ /** A collection a person may read, as the app lists it: `name` is what a
2
+ * question is narrowed to, `title` is what the picker shows (the name,
3
+ * made readable, where there is none) and `description` is its one line
4
+ * under the title. A retrieval summary is never a field here: it is filing
5
+ * history. */
6
+ export interface CollectionChoice {
7
+ name: string;
8
+ title?: string;
9
+ description?: string;
10
+ }
11
+ /** `defence-housing` as a reader would first read it: "Defence housing". */
12
+ export declare function titleOf(choice: CollectionChoice): string;
@@ -0,0 +1,8 @@
1
+ /** `defence-housing` as a reader would first read it: "Defence housing". */
2
+ export function titleOf(choice) {
3
+ const given = choice.title?.trim();
4
+ if (given)
5
+ return given;
6
+ const words = choice.name.replace(/[-_]+/g, ' ').trim();
7
+ return words ? words[0].toLocaleUpperCase() + words.slice(1) : choice.name;
8
+ }
@@ -1,10 +1,11 @@
1
1
  <!--
2
2
  The composer: one rounded box holding attachments, the input AND a footer row.
3
3
 
4
- Two things carry it: the chips sit BELOW the input inside the same box rather
5
- than beside it, and the trailing control is a circular filled accent button.
6
- The empty→typing transition lives almost entirely in that control — the box
7
- chrome grows with the text and nothing else moves.
4
+ Two things carry it: the quiet controls sit BELOW the input inside the same
5
+ box rather than beside it (attach, a briefing, and where the question looks
6
+ as one compact control), and the trailing control is a circular filled
7
+ accent button. The empty→typing transition lives almost entirely in that
8
+ control — the box chrome grows with the text and nothing else moves.
8
9
 
9
10
  While the persona is answering the box is disabled and the send button becomes a
10
11
  stop button: an ask route takes one question per request, and a box that
@@ -27,24 +28,23 @@
27
28
  rejectionMessage
28
29
  } from '../../attachments';
29
30
  import { DEFAULT_PERSONA, resolveCopy, type ChatCopy } from '../../copy';
31
+ import type { CollectionChoice } from './choices';
32
+ import ScopePicker from './scope-picker.svelte';
30
33
 
31
34
  /** Past this many turns, the long-conversation banner offers to start
32
35
  * over: a follow-up re-reads the whole conversation, so each one costs
33
36
  * more than the last (measured — the longest ran 2.05M cached tokens). */
34
37
  const DEFAULT_TURN_LIMIT = 8;
35
38
 
36
- /** A collection a person may read, as the app lists it: `name` is what a
37
- * question is narrowed to, `summary` is what the picker shows. */
38
- export interface CollectionChoice {
39
- name: string;
40
- summary: string;
41
- }
39
+ /** px kept between a lifted box and the keyboard. */
40
+ const KEYBOARD_GAP = 12;
42
41
 
43
42
  interface Props {
44
43
  value: string;
45
44
  running: boolean;
46
45
  /** The collections this person may read. The picker renders when there
47
- * are two or more: with one, or none, there is nothing to choose. */
46
+ * are two or more: with one, or none, there is nothing to choose. It
47
+ * is one control however many there are, searchable from eight. */
48
48
  collections?: CollectionChoice[];
49
49
  /** The collection the next question is narrowed to, by name; undefined
50
50
  * asks across all of them. */
@@ -120,14 +120,11 @@
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
- // the row renders nothing rather than a chip that only reselects itself.
124
- // A collection is shown by its summary, the words a reader knows it by.
123
+ // nothing renders rather than a control that only reselects itself.
125
124
  const hasChoice = $derived(collections.length > 1);
126
125
 
127
- // Three chips in a narrow column clipped all three to fragments
128
- // ("defence-s…", "All colle…"). Wrapping beats truncating: a chip a reader
129
- // cannot finish reading is not a control, it is decoration.
130
-
126
+ let box = $state<HTMLElement | null>(null);
127
+ let wrapper = $state<HTMLElement | null>(null);
131
128
  let textarea = $state<HTMLTextAreaElement | null>(null);
132
129
  let picker = $state<HTMLInputElement | null>(null);
133
130
  let rejected = $state('');
@@ -204,26 +201,45 @@
204
201
 
205
202
  // The keyboard does not shrink the layout viewport on iOS, so a composer at
206
203
  // the bottom of a full-height column sits UNDER it. The visual viewport is
207
- // the one that moves; the overlap between the two is exactly how far the
208
- // box must rise.
204
+ // the one that moves, and the box rises by however far its unlifted foot
205
+ // falls below it: the keyboard's height for a docked box, nothing for one
206
+ // centred above the keyboard on an empty conversation.
207
+ //
208
+ // The foot is read from layout, never from where the box is drawn: the
209
+ // transform's current value (mid-transition included) and the safe-area
210
+ // padding that lifting removes are both taken back out, so a second event
211
+ // inside the 150ms transition cannot read a half-lifted box and overshoot.
212
+ // Any scroll re-measures, since a scrolling stage moves the box too.
209
213
  let lift = $state(0);
210
214
  $effect(() => {
211
215
  const viewport = globalThis.visualViewport;
212
216
  if (!viewport) return;
213
217
  const measure = () => {
214
- lift = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop);
218
+ const shown = viewport.offsetTop + viewport.height;
219
+ if (!wrapper || shown >= window.innerHeight) {
220
+ lift = 0;
221
+ return;
222
+ }
223
+ const style = getComputedStyle(wrapper);
224
+ const drawn = style.transform === 'none' ? 0 : new DOMMatrixReadOnly(style.transform).m42;
225
+ const foot =
226
+ wrapper.getBoundingClientRect().bottom - drawn - (parseFloat(style.paddingBottom) || 0);
227
+ lift = Math.max(0, Math.round(foot + KEYBOARD_GAP - shown));
215
228
  };
216
229
  measure();
217
230
  viewport.addEventListener('resize', measure);
218
231
  viewport.addEventListener('scroll', measure);
232
+ document.addEventListener('scroll', measure, { capture: true, passive: true });
219
233
  return () => {
220
234
  viewport.removeEventListener('resize', measure);
221
235
  viewport.removeEventListener('scroll', measure);
236
+ document.removeEventListener('scroll', measure, { capture: true });
222
237
  };
223
238
  });
224
239
  </script>
225
240
 
226
241
  <div
242
+ bind:this={wrapper}
227
243
  class="ds-lib-composer-lift"
228
244
  style="transform: translateY(-{lift}px); padding-bottom: {lift
229
245
  ? '0px'
@@ -239,7 +255,7 @@
239
255
  </div>
240
256
  {/if}
241
257
 
242
- <div class="ds-lib-box">
258
+ <div class="ds-lib-box" bind:this={box}>
243
259
  {#if files.length > 0}
244
260
  <ul class="ds-lib-files">
245
261
  {#each files as file, index (file.name + file.size)}
@@ -307,28 +323,16 @@
307
323
  {/if}
308
324
 
309
325
  {#if hasChoice}
310
- <div role="group" aria-label={words.collectionsLabel} class="ds-lib-scopes">
311
- <button
312
- type="button"
313
- class="ds-lib-scope-chip"
314
- aria-pressed={collection === undefined}
315
- class:is-chosen={collection === undefined}
316
- onclick={() => oncollection?.(undefined)}
317
- >
318
- {words.allCollections}
319
- </button>
320
- {#each collections as choice (choice.name)}
321
- <button
322
- type="button"
323
- class="ds-lib-scope-chip"
324
- aria-pressed={collection === choice.name}
325
- class:is-chosen={collection === choice.name}
326
- onclick={() => oncollection?.(choice.name)}
327
- >
328
- {choice.summary || choice.name}
329
- </button>
330
- {/each}
331
- </div>
326
+ <ScopePicker
327
+ {collections}
328
+ {collection}
329
+ {words}
330
+ anchor={box}
331
+ onchoose={(name) => {
332
+ oncollection?.(name);
333
+ textarea?.focus();
334
+ }}
335
+ />
332
336
  {:else if note}
333
337
  <span class="ds-lib-note">{note}</span>
334
338
  {/if}
@@ -362,7 +366,9 @@
362
366
  transition: transform 150ms ease;
363
367
  }
364
368
 
369
+ /* Positioned: the scope list is placed against it. */
365
370
  .ds-lib-box {
371
+ position: relative;
366
372
  border: 1px solid var(--ds-color-border);
367
373
  border-radius: var(--ds-radius-xl);
368
374
  background: var(--ds-color-surface-1);
@@ -500,7 +506,6 @@
500
506
  .ds-lib-icon-button:focus-visible,
501
507
  .ds-lib-stop:focus-visible,
502
508
  .ds-lib-send:focus-visible,
503
- .ds-lib-scope-chip:focus-visible,
504
509
  .ds-lib-file-remove:focus-visible {
505
510
  outline: 2px solid var(--ds-color-ring);
506
511
  outline-offset: 2px;
@@ -521,45 +526,6 @@
521
526
  opacity: 0.9;
522
527
  }
523
528
 
524
- /* Three chips in a narrow column clipped all three to fragments
525
- ("defence-s…", "All colle…"). Wrapping beats truncating: a chip a reader
526
- cannot finish reading is not a control, it is decoration. */
527
- .ds-lib-scopes {
528
- display: flex;
529
- min-width: 0;
530
- flex-wrap: wrap;
531
- align-items: center;
532
- gap: 0.25rem;
533
- }
534
-
535
- .ds-lib-scope-chip {
536
- max-width: 100%;
537
- overflow: hidden;
538
- border: 1px solid transparent;
539
- border-radius: var(--ds-radius-full);
540
- background: none;
541
- padding: 0.125rem 0.5rem;
542
- color: var(--ds-color-muted-foreground);
543
- font: inherit;
544
- font-size: var(--ds-text-2xs);
545
- text-overflow: ellipsis;
546
- white-space: nowrap;
547
- cursor: pointer;
548
- transition:
549
- color 150ms ease,
550
- background-color 150ms ease;
551
- }
552
-
553
- .ds-lib-scope-chip:hover {
554
- color: var(--ds-color-foreground);
555
- }
556
-
557
- .ds-lib-scope-chip.is-chosen {
558
- border-color: color-mix(in oklab, var(--ds-color-primary) 40%, transparent);
559
- background: color-mix(in oklab, var(--ds-color-primary) 15%, transparent);
560
- color: var(--ds-color-foreground);
561
- }
562
-
563
529
  .ds-lib-spacer {
564
530
  flex: 1;
565
531
  }
@@ -1,15 +1,11 @@
1
1
  import { type ChatCopy } from '../../copy';
2
- /** A collection a person may read, as the app lists it: `name` is what a
3
- * question is narrowed to, `summary` is what the picker shows. */
4
- export interface CollectionChoice {
5
- name: string;
6
- summary: string;
7
- }
2
+ import type { CollectionChoice } from './choices';
8
3
  interface Props {
9
4
  value: string;
10
5
  running: boolean;
11
6
  /** The collections this person may read. The picker renders when there
12
- * are two or more: with one, or none, there is nothing to choose. */
7
+ * are two or more: with one, or none, there is nothing to choose. It
8
+ * is one control however many there are, searchable from eight. */
13
9
  collections?: CollectionChoice[];
14
10
  /** The collection the next question is narrowed to, by name; undefined
15
11
  * asks across all of them. */
@@ -1,3 +1,3 @@
1
1
  export { default as Composer } from './composer.svelte';
2
2
  export { default } from './composer.svelte';
3
- export type { CollectionChoice } from './composer.svelte';
3
+ export type { CollectionChoice } from './choices';