@poodle64/librarian 2026.9.21 → 2026.9.23

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 +131 -8
  2. package/dist/chat.svelte.d.ts +189 -0
  3. package/dist/chat.svelte.js +554 -0
  4. package/dist/client.d.ts +29 -6
  5. package/dist/client.js +11 -3
  6. package/dist/components/agent-transcript/agent-transcript.svelte +40 -6
  7. package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +9 -0
  8. package/dist/components/answer-mark/answer-mark.svelte +223 -0
  9. package/dist/components/answer-mark/answer-mark.svelte.d.ts +11 -0
  10. package/dist/components/answer-mark/index.d.ts +2 -0
  11. package/dist/components/answer-mark/index.js +2 -0
  12. package/dist/components/conversation/conversation.svelte +21 -3
  13. package/dist/components/conversation/conversation.svelte.d.ts +9 -0
  14. package/dist/components/conversation-list/conversation-list.svelte +496 -0
  15. package/dist/components/conversation-list/conversation-list.svelte.d.ts +26 -0
  16. package/dist/components/conversation-list/index.d.ts +2 -0
  17. package/dist/components/conversation-list/index.js +2 -0
  18. package/dist/components/fair-use-notice/fair-use-notice.svelte +97 -0
  19. package/dist/components/fair-use-notice/fair-use-notice.svelte.d.ts +11 -0
  20. package/dist/components/fair-use-notice/index.d.ts +2 -0
  21. package/dist/components/fair-use-notice/index.js +2 -0
  22. package/dist/components/working/working.svelte +8 -3
  23. package/dist/components/working/working.svelte.d.ts +3 -0
  24. package/dist/copy.d.ts +40 -0
  25. package/dist/copy.js +36 -1
  26. package/dist/session.svelte.js +1 -1
  27. package/dist/transcript.svelte.js +1 -1
  28. package/package.json +8 -3
@@ -0,0 +1,496 @@
1
+ <!--
2
+ A person's past conversations, newest first: each one reopens, and each can
3
+ be renamed, downloaded or deleted. And a way to start a new one.
4
+
5
+ From cadmus's Past questions (cadmus#114). Presentation only: the host hands
6
+ it the list and the acts (`Chat`'s own), nothing here fetches, and where it
7
+ sits — a column, a sheet — is the host's.
8
+
9
+ Each row's acts open under it rather than in a floating menu, and a delete
10
+ asks under the row too: nothing to position, nothing to trap focus in, and
11
+ a thumb on a phone reaches all of it.
12
+ -->
13
+ <script lang="ts">
14
+ import DownloadIcon from '@lucide/svelte/icons/download';
15
+ import MessageSquareIcon from '@lucide/svelte/icons/message-square';
16
+ import MoreHorizontalIcon from '@lucide/svelte/icons/more-horizontal';
17
+ import PencilIcon from '@lucide/svelte/icons/pencil';
18
+ import PlusIcon from '@lucide/svelte/icons/plus';
19
+ import TrashIcon from '@lucide/svelte/icons/trash-2';
20
+ import type { ConversationSummary } from '../../chat.svelte';
21
+ import { DEFAULT_PERSONA, resolveCopy, type LibrarianCopy } from '../../copy';
22
+
23
+ interface Props {
24
+ /** Null while it is still being read. */
25
+ conversations: ConversationSummary[] | null;
26
+ /** It could not be read. */
27
+ failed?: boolean;
28
+ /** The conversation open now. */
29
+ current?: string | null;
30
+ /** Each row's address, which makes it a link: a conversation the page
31
+ * keeps in its address opens by following it. */
32
+ href?: (id: string) => string;
33
+ /** A row chosen; with `href`, as well as following it. */
34
+ onopen?: (id: string) => void;
35
+ onnew: () => void;
36
+ /** Each act offered only when given; each resolves true once done. */
37
+ onrename?: (id: string, title: string) => Promise<boolean>;
38
+ ondownload?: (id: string) => Promise<boolean>;
39
+ ondelete?: (id: string) => Promise<boolean>;
40
+ /** Who is answering, for the line on a conversation still being answered. */
41
+ name?: string;
42
+ copy?: Partial<LibrarianCopy>;
43
+ }
44
+
45
+ let {
46
+ conversations,
47
+ failed = false,
48
+ current = null,
49
+ href,
50
+ onopen,
51
+ onnew,
52
+ onrename,
53
+ ondownload,
54
+ ondelete,
55
+ name = DEFAULT_PERSONA,
56
+ copy
57
+ }: Props = $props();
58
+
59
+ const words = $derived(resolveCopy(copy, name));
60
+ const acts = $derived(Boolean(onrename || ondownload || ondelete));
61
+
62
+ const newestFirst = $derived(
63
+ [...(conversations ?? [])].sort(
64
+ (a, b) => (Date.parse(b.last_activity_at) || 0) - (Date.parse(a.last_activity_at) || 0)
65
+ )
66
+ );
67
+
68
+ let opened = $state<string | null>(null);
69
+ let renaming = $state<string | null>(null);
70
+ let deleting = $state<string | null>(null);
71
+ let title = $state('');
72
+ let problem = $state('');
73
+ let busy = $state(false);
74
+ let field = $state<HTMLInputElement | null>(null);
75
+
76
+ $effect(() => {
77
+ if (renaming && field) field.select();
78
+ });
79
+
80
+ function toggle(id: string) {
81
+ opened = opened === id ? null : id;
82
+ deleting = null;
83
+ problem = '';
84
+ }
85
+
86
+ function close() {
87
+ opened = null;
88
+ renaming = null;
89
+ deleting = null;
90
+ }
91
+
92
+ async function run(act: () => Promise<boolean>, failure: string) {
93
+ if (busy) return;
94
+ busy = true;
95
+ problem = '';
96
+ try {
97
+ if (await act()) close();
98
+ else problem = failure;
99
+ } finally {
100
+ busy = false;
101
+ }
102
+ }
103
+
104
+ function startRename(conversation: ConversationSummary) {
105
+ renaming = conversation.id;
106
+ title = conversation.title;
107
+ problem = '';
108
+ }
109
+
110
+ function submitRename(event: SubmitEvent, id: string) {
111
+ event.preventDefault();
112
+ const next = title.trim();
113
+ if (!next || !onrename) return;
114
+ void run(() => onrename(id, next), words.renameFailed);
115
+ }
116
+ </script>
117
+
118
+ <section class="ds-lib-list" aria-label={words.pastQuestions}>
119
+ <header class="ds-lib-list-head">
120
+ <h2 class="ds-lib-list-title">{words.pastQuestions}</h2>
121
+ <button type="button" class="ds-lib-list-new" onclick={onnew}>
122
+ <PlusIcon size={14} />
123
+ <span>{words.newQuestion}</span>
124
+ </button>
125
+ </header>
126
+
127
+ {#if failed}
128
+ <p class="ds-lib-list-note">{words.pastQuestionsFailed}</p>
129
+ {:else if conversations === null}
130
+ <p class="ds-lib-list-note">{words.readingPastQuestions}</p>
131
+ {:else if newestFirst.length === 0}
132
+ <p class="ds-lib-list-note">{words.noPastQuestions}</p>
133
+ {:else}
134
+ <ul class="ds-lib-list-items">
135
+ {#each newestFirst as conversation (conversation.id)}
136
+ {@const id = conversation.id}
137
+ <li class="ds-lib-list-item" class:is-current={id === current}>
138
+ {#if renaming === id}
139
+ <form class="ds-lib-list-rename" onsubmit={(event) => submitRename(event, id)}>
140
+ <input
141
+ bind:this={field}
142
+ bind:value={title}
143
+ maxlength={120}
144
+ aria-label={words.rename}
145
+ class="ds-lib-list-input"
146
+ onkeydown={(event) => {
147
+ if (event.key === 'Escape') renaming = null;
148
+ }}
149
+ />
150
+ <button type="submit" class="ds-lib-list-act" disabled={!title.trim() || busy}>
151
+ {words.save}
152
+ </button>
153
+ <button type="button" class="ds-lib-list-act" onclick={() => (renaming = null)}>
154
+ {words.cancel}
155
+ </button>
156
+ </form>
157
+ {:else}
158
+ <div class="ds-lib-list-row">
159
+ {#snippet label()}
160
+ <MessageSquareIcon size={14} class="ds-lib-list-icon" />
161
+ <span class="ds-lib-list-name">{conversation.title}</span>
162
+ {#if conversation.answering_since}
163
+ <span class="ds-lib-list-live" title={words.stillAnswering}>
164
+ <span class="ds-lib-sr">{words.stillAnswering}</span>
165
+ </span>
166
+ {/if}
167
+ {/snippet}
168
+ {#if href}
169
+ <a
170
+ class="ds-lib-list-open"
171
+ href={href(id)}
172
+ aria-current={id === current ? 'page' : undefined}
173
+ onclick={() => onopen?.(id)}
174
+ >
175
+ {@render label()}
176
+ </a>
177
+ {:else}
178
+ <button
179
+ type="button"
180
+ class="ds-lib-list-open"
181
+ aria-current={id === current ? 'true' : undefined}
182
+ onclick={() => onopen?.(id)}
183
+ >
184
+ {@render label()}
185
+ </button>
186
+ {/if}
187
+ {#if acts}
188
+ <button
189
+ type="button"
190
+ class="ds-lib-list-more"
191
+ aria-expanded={opened === id}
192
+ aria-label="{words.conversationActions}: {conversation.title}"
193
+ onclick={() => toggle(id)}
194
+ >
195
+ <MoreHorizontalIcon size={16} />
196
+ </button>
197
+ {/if}
198
+ </div>
199
+ {#if opened === id && deleting === id}
200
+ <div class="ds-lib-list-confirm" role="group" aria-label={words.delete}>
201
+ <p>{words.deletePrompt}</p>
202
+ <div class="ds-lib-list-acts">
203
+ <button
204
+ type="button"
205
+ class="ds-lib-list-act is-destructive"
206
+ disabled={busy}
207
+ onclick={() => ondelete && run(() => ondelete(id), words.deleteFailed)}
208
+ >
209
+ {words.delete}
210
+ </button>
211
+ <button type="button" class="ds-lib-list-act" onclick={() => (deleting = null)}>
212
+ {words.cancel}
213
+ </button>
214
+ </div>
215
+ </div>
216
+ {:else if opened === id}
217
+ <div class="ds-lib-list-acts">
218
+ {#if onrename}
219
+ <button
220
+ type="button"
221
+ class="ds-lib-list-act"
222
+ onclick={() => startRename(conversation)}
223
+ >
224
+ <PencilIcon size={14} />
225
+ {words.rename}
226
+ </button>
227
+ {/if}
228
+ {#if ondownload}
229
+ <button
230
+ type="button"
231
+ class="ds-lib-list-act"
232
+ disabled={busy}
233
+ onclick={() => run(() => ondownload(id), words.downloadFailed)}
234
+ >
235
+ <DownloadIcon size={14} />
236
+ {words.download}
237
+ </button>
238
+ {/if}
239
+ {#if ondelete}
240
+ <button type="button" class="ds-lib-list-act" onclick={() => (deleting = id)}>
241
+ <TrashIcon size={14} />
242
+ {words.delete}
243
+ </button>
244
+ {/if}
245
+ </div>
246
+ {/if}
247
+ {/if}
248
+ </li>
249
+ {/each}
250
+ </ul>
251
+ {/if}
252
+
253
+ {#if problem}
254
+ <p class="ds-lib-list-problem" role="status">{problem}</p>
255
+ {/if}
256
+ </section>
257
+
258
+ <style>
259
+ .ds-lib-list {
260
+ display: flex;
261
+ min-width: 0;
262
+ flex-direction: column;
263
+ gap: 0.25rem;
264
+ padding: 0.5rem;
265
+ }
266
+
267
+ .ds-lib-list-head {
268
+ display: flex;
269
+ align-items: center;
270
+ gap: 0.5rem;
271
+ padding: 0.25rem 0.25rem 0.5rem 0.5rem;
272
+ }
273
+
274
+ .ds-lib-list-title {
275
+ flex: 1;
276
+ margin: 0;
277
+ color: var(--ds-color-foreground);
278
+ font-family: inherit;
279
+ font-size: 0.875rem;
280
+ font-weight: 600;
281
+ }
282
+
283
+ .ds-lib-list-new,
284
+ .ds-lib-list-act {
285
+ display: flex;
286
+ align-items: center;
287
+ gap: 0.375rem;
288
+ border: 1px solid var(--ds-color-border);
289
+ border-radius: var(--ds-radius-md);
290
+ background: none;
291
+ padding: 0.375rem 0.625rem;
292
+ color: var(--ds-color-foreground);
293
+ font: inherit;
294
+ font-size: 0.8125rem;
295
+ cursor: pointer;
296
+ transition: background-color 150ms ease;
297
+ }
298
+
299
+ .ds-lib-list-new:hover,
300
+ .ds-lib-list-act:hover:not(:disabled) {
301
+ background: var(--ds-color-surface-2);
302
+ }
303
+
304
+ .ds-lib-list-act:disabled {
305
+ cursor: default;
306
+ opacity: 0.5;
307
+ }
308
+
309
+ /* The house destructive button: a tint, and the colour in the words. */
310
+ .ds-lib-list-act.is-destructive {
311
+ border-color: transparent;
312
+ background: color-mix(in oklab, var(--ds-color-destructive) 12%, transparent);
313
+ color: var(--ds-color-destructive);
314
+ }
315
+
316
+ .ds-lib-list-act.is-destructive:hover:not(:disabled) {
317
+ background: color-mix(in oklab, var(--ds-color-destructive) 20%, transparent);
318
+ }
319
+
320
+ .ds-lib-list-note,
321
+ .ds-lib-list-problem {
322
+ margin: 0;
323
+ padding: 0.25rem 0.5rem;
324
+ color: var(--ds-color-muted-foreground);
325
+ font-size: 0.875rem;
326
+ }
327
+
328
+ .ds-lib-list-problem {
329
+ color: var(--ds-color-status-error);
330
+ }
331
+
332
+ .ds-lib-list-items {
333
+ display: flex;
334
+ flex-direction: column;
335
+ gap: 0.125rem;
336
+ margin: 0;
337
+ padding: 0;
338
+ list-style: none;
339
+ }
340
+
341
+ .ds-lib-list-item {
342
+ border-radius: var(--ds-radius-md);
343
+ }
344
+
345
+ .ds-lib-list-item.is-current {
346
+ background: var(--ds-color-surface-3);
347
+ }
348
+
349
+ .ds-lib-list-row {
350
+ display: flex;
351
+ align-items: center;
352
+ }
353
+
354
+ /* 44px: a row is a thumb's target on a phone. */
355
+ .ds-lib-list-open {
356
+ display: flex;
357
+ min-width: 0;
358
+ min-height: 2.75rem;
359
+ flex: 1;
360
+ align-items: center;
361
+ gap: 0.5rem;
362
+ border: 0;
363
+ border-radius: var(--ds-radius-md);
364
+ background: none;
365
+ padding: 0.25rem 0.5rem;
366
+ color: var(--ds-color-muted-foreground);
367
+ font: inherit;
368
+ font-size: 0.875rem;
369
+ text-align: start;
370
+ text-decoration: none;
371
+ cursor: pointer;
372
+ transition:
373
+ color 150ms ease,
374
+ background-color 150ms ease;
375
+ }
376
+
377
+ .ds-lib-list-open:hover {
378
+ background: var(--ds-color-surface-2);
379
+ color: var(--ds-color-foreground);
380
+ }
381
+
382
+ .is-current .ds-lib-list-open {
383
+ color: var(--ds-color-foreground);
384
+ }
385
+
386
+ .ds-lib-list-open :global(.ds-lib-list-icon) {
387
+ flex: none;
388
+ opacity: 0.6;
389
+ }
390
+
391
+ .ds-lib-list-name {
392
+ min-width: 0;
393
+ flex: 1;
394
+ overflow: hidden;
395
+ text-overflow: ellipsis;
396
+ white-space: nowrap;
397
+ }
398
+
399
+ .ds-lib-list-live {
400
+ width: 0.375rem;
401
+ height: 0.375rem;
402
+ flex: none;
403
+ border-radius: var(--ds-radius-full);
404
+ background: var(--ds-color-status-info);
405
+ animation: ds-lib-list-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
406
+ }
407
+
408
+ .ds-lib-list-more {
409
+ display: grid;
410
+ width: 2.75rem;
411
+ height: 2.75rem;
412
+ flex: none;
413
+ place-items: center;
414
+ border: 0;
415
+ border-radius: var(--ds-radius-md);
416
+ background: none;
417
+ color: var(--ds-color-muted-foreground);
418
+ cursor: pointer;
419
+ }
420
+
421
+ .ds-lib-list-more:hover,
422
+ .ds-lib-list-more[aria-expanded='true'] {
423
+ color: var(--ds-color-foreground);
424
+ }
425
+
426
+ .ds-lib-list-acts {
427
+ display: flex;
428
+ flex-wrap: wrap;
429
+ gap: 0.375rem;
430
+ padding: 0.25rem 0.5rem 0.5rem;
431
+ }
432
+
433
+ .ds-lib-list-confirm {
434
+ padding: 0.25rem 0 0;
435
+ }
436
+
437
+ .ds-lib-list-confirm p {
438
+ margin: 0;
439
+ padding: 0 0.5rem;
440
+ color: var(--ds-color-foreground);
441
+ font-size: 0.8125rem;
442
+ }
443
+
444
+ .ds-lib-list-rename {
445
+ display: flex;
446
+ align-items: center;
447
+ gap: 0.375rem;
448
+ padding: 0.375rem;
449
+ }
450
+
451
+ .ds-lib-list-input {
452
+ min-width: 0;
453
+ flex: 1;
454
+ border: 1px solid var(--ds-color-border);
455
+ border-radius: var(--ds-radius-md);
456
+ background: var(--ds-color-surface-1);
457
+ padding: 0.375rem 0.5rem;
458
+ color: var(--ds-color-foreground);
459
+ font: inherit;
460
+ font-size: 0.875rem;
461
+ }
462
+
463
+ .ds-lib-list-input:focus {
464
+ border-color: color-mix(in oklab, var(--ds-color-primary) 60%, transparent);
465
+ outline: none;
466
+ }
467
+
468
+ .ds-lib-list-new:focus-visible,
469
+ .ds-lib-list-act:focus-visible,
470
+ .ds-lib-list-open:focus-visible,
471
+ .ds-lib-list-more:focus-visible {
472
+ outline: 2px solid var(--ds-color-ring);
473
+ outline-offset: -2px;
474
+ }
475
+
476
+ .ds-lib-sr {
477
+ position: absolute;
478
+ width: 1px;
479
+ height: 1px;
480
+ overflow: hidden;
481
+ clip-path: inset(50%);
482
+ white-space: nowrap;
483
+ }
484
+
485
+ @keyframes ds-lib-list-pulse {
486
+ 50% {
487
+ opacity: 0.4;
488
+ }
489
+ }
490
+
491
+ @media (prefers-reduced-motion: reduce) {
492
+ .ds-lib-list-live {
493
+ animation: none;
494
+ }
495
+ }
496
+ </style>
@@ -0,0 +1,26 @@
1
+ import type { ConversationSummary } from '../../chat.svelte';
2
+ import { type LibrarianCopy } from '../../copy';
3
+ interface Props {
4
+ /** Null while it is still being read. */
5
+ conversations: ConversationSummary[] | null;
6
+ /** It could not be read. */
7
+ failed?: boolean;
8
+ /** The conversation open now. */
9
+ current?: string | null;
10
+ /** Each row's address, which makes it a link: a conversation the page
11
+ * keeps in its address opens by following it. */
12
+ href?: (id: string) => string;
13
+ /** A row chosen; with `href`, as well as following it. */
14
+ onopen?: (id: string) => void;
15
+ onnew: () => void;
16
+ /** Each act offered only when given; each resolves true once done. */
17
+ onrename?: (id: string, title: string) => Promise<boolean>;
18
+ ondownload?: (id: string) => Promise<boolean>;
19
+ ondelete?: (id: string) => Promise<boolean>;
20
+ /** Who is answering, for the line on a conversation still being answered. */
21
+ name?: string;
22
+ copy?: Partial<LibrarianCopy>;
23
+ }
24
+ declare const ConversationList: import("svelte").Component<Props, {}, "">;
25
+ type ConversationList = ReturnType<typeof ConversationList>;
26
+ export default ConversationList;
@@ -0,0 +1,2 @@
1
+ export { default as ConversationList } from './conversation-list.svelte';
2
+ export { default } from './conversation-list.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as ConversationList } from './conversation-list.svelte';
2
+ export { default } from './conversation-list.svelte';
@@ -0,0 +1,97 @@
1
+ <!--
2
+ Today's fair-use allowance, beside the composer (from cadmus#114).
3
+
4
+ One component for two states because they are one fact at two points: a
5
+ quiet count while there are questions left, and a plain notice once there
6
+ are none. Two components are how a count and a limit come to disagree about
7
+ the same number.
8
+
9
+ Read before a question is typed, so someone who is out learns it with an
10
+ empty box rather than after three paragraphs. Nobody exempt reads anything,
11
+ and neither does anyone where there is no limit: a counter that never moves
12
+ is noise in the way of the box.
13
+ -->
14
+ <script lang="ts">
15
+ import InfoIcon from '@lucide/svelte/icons/info';
16
+ import type { Quota } from '../../chat.svelte';
17
+ import { fill, resolveCopy, type LibrarianCopy } from '../../copy';
18
+
19
+ interface Props {
20
+ quota: Quota | null;
21
+ /** Where to ask about the limit. Absent takes the allowance's own. */
22
+ supportHref?: string;
23
+ copy?: Partial<LibrarianCopy>;
24
+ }
25
+
26
+ let { quota, supportHref, copy }: Props = $props();
27
+
28
+ const words = $derived(resolveCopy(copy));
29
+ const shown = $derived(quota !== null && !quota.exempt && quota.limit > 0 ? quota : null);
30
+ const href = $derived(supportHref ?? shown?.support_url ?? undefined);
31
+
32
+ /** When it comes back, in the reader's own clock. */
33
+ const resets = $derived.by(() => {
34
+ const at = new Date(shown?.resets_at ?? '');
35
+ if (Number.isNaN(at.getTime()) || (at.getHours() === 0 && at.getMinutes() === 0)) {
36
+ return words.midnight;
37
+ }
38
+ return at.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
39
+ });
40
+ </script>
41
+
42
+ {#if shown?.reached}
43
+ <div class="ds-lib-fair-use" role="status">
44
+ <InfoIcon size={16} class="ds-lib-fair-use-icon" />
45
+ <p>
46
+ {fill(words.limitReached, { limit: shown.limit, resets })}
47
+ {#if href}<a {href}>{words.support}</a>{/if}
48
+ </p>
49
+ </div>
50
+ {:else if shown}
51
+ <p class="ds-lib-fair-use-count">
52
+ {fill(words.questionsLeft, { remaining: shown.remaining, limit: shown.limit, resets })}
53
+ </p>
54
+ {/if}
55
+
56
+ <style>
57
+ .ds-lib-fair-use {
58
+ display: flex;
59
+ align-items: flex-start;
60
+ gap: 0.5rem;
61
+ margin-bottom: 0.5rem;
62
+ border: 1px solid var(--ds-color-border);
63
+ border-radius: var(--ds-radius-lg);
64
+ background: var(--ds-color-surface-2);
65
+ padding: 0.5rem 0.75rem;
66
+ color: var(--ds-color-foreground);
67
+ font-size: 0.875rem;
68
+ }
69
+
70
+ .ds-lib-fair-use p {
71
+ min-width: 0;
72
+ margin: 0;
73
+ }
74
+
75
+ .ds-lib-fair-use :global(.ds-lib-fair-use-icon) {
76
+ flex: none;
77
+ margin-top: 0.125rem;
78
+ color: var(--ds-color-muted-foreground);
79
+ }
80
+
81
+ .ds-lib-fair-use a {
82
+ color: var(--ds-color-primary);
83
+ text-decoration: underline;
84
+ text-underline-offset: 2px;
85
+ }
86
+
87
+ .ds-lib-fair-use a:focus-visible {
88
+ outline: 2px solid var(--ds-color-ring);
89
+ outline-offset: 2px;
90
+ }
91
+
92
+ .ds-lib-fair-use-count {
93
+ margin: 0 0 0.375rem;
94
+ color: var(--ds-color-muted-foreground);
95
+ font-size: var(--ds-text-2xs);
96
+ }
97
+ </style>
@@ -0,0 +1,11 @@
1
+ import type { Quota } from '../../chat.svelte';
2
+ import { type LibrarianCopy } from '../../copy';
3
+ interface Props {
4
+ quota: Quota | null;
5
+ /** Where to ask about the limit. Absent takes the allowance's own. */
6
+ supportHref?: string;
7
+ copy?: Partial<LibrarianCopy>;
8
+ }
9
+ declare const FairUseNotice: import("svelte").Component<Props, {}, "">;
10
+ type FairUseNotice = ReturnType<typeof FairUseNotice>;
11
+ export default FairUseNotice;
@@ -0,0 +1,2 @@
1
+ export { default as FairUseNotice } from './fair-use-notice.svelte';
2
+ export { default } from './fair-use-notice.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as FairUseNotice } from './fair-use-notice.svelte';
2
+ export { default } from './fair-use-notice.svelte';
@@ -17,20 +17,25 @@
17
17
  interface Props {
18
18
  /** Shown instead of the cycling word once real work is identifiable. */
19
19
  label?: string;
20
+ /** When the answer began, epoch ms, for one this page did not see start:
21
+ * the clock counts from it rather than from the moment it appeared. */
22
+ since?: number;
20
23
  /** The persona's words. Resolved from `name` upstream. */
21
24
  copy?: Partial<LibrarianCopy>;
22
25
  }
23
26
 
24
- let { label, copy }: Props = $props();
27
+ let { label, since, copy }: Props = $props();
25
28
 
26
29
  const words = $derived(resolveCopy(copy));
27
30
 
31
+ const mounted = Date.now();
28
32
  let tick = $state(0);
29
- let elapsed = $state(0);
33
+ let now = $state(mounted);
34
+ const elapsed = $derived(Math.max(0, Math.floor((now - (since ?? mounted)) / 1000)));
30
35
 
31
36
  $effect(() => {
32
37
  const word = setInterval(() => (tick += 1), 2600);
33
- const clock = setInterval(() => (elapsed += 1), 1000);
38
+ const clock = setInterval(() => (now = Date.now()), 1000);
34
39
  return () => {
35
40
  clearInterval(word);
36
41
  clearInterval(clock);
@@ -2,6 +2,9 @@ import { type LibrarianCopy } from '../../copy';
2
2
  interface Props {
3
3
  /** Shown instead of the cycling word once real work is identifiable. */
4
4
  label?: string;
5
+ /** When the answer began, epoch ms, for one this page did not see start:
6
+ * the clock counts from it rather than from the moment it appeared. */
7
+ since?: number;
5
8
  /** The persona's words. Resolved from `name` upstream. */
6
9
  copy?: Partial<LibrarianCopy>;
7
10
  }