@dorsk/tsumikit 0.61.1 → 0.62.0

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
@@ -295,6 +295,10 @@ shows a `N%` bubble while it is moved; `oninput` live, `onchange` on commit;
295
295
  `clearAtMax`/`uncappedLabel` express "no cap", `layout`/`stackBelow` put the
296
296
  readout above a full-width track, `capLabel`/`capValueText` localise the ARIA
297
297
  strings),
298
+ StatusBar (thin `role="status"` row: `tone` idle/busy/ok/warn/error dot or spinner, `label` or
299
+ `children`, `trailing`), ChatBubble (`timestamp`, `footer` snippet, `markdown` body rendered
300
+ through `renderMarkdown` into `Prose` when there are no `children`),
301
+ Composer (`toolbarStart`/`toolbarEnd` snippets move attach + send into a row under the field),
298
302
  Drawer (side-panel `<dialog>`: `side`, `width` clamped to the viewport, full-screen
299
303
  under 48rem; `nav` page column that turns into a horizontal strip on narrow
300
304
  screens, or `navMobile`; sticky `footer`; Escape / scrim / close button all close),
@@ -312,7 +316,8 @@ ArrowUp/Down, Enter/Tab (`selectOn`), Escape and blur; `open`/`index` are
312
316
  bindable, `onselect`/`onclose(reason)` report picks; `findTrigger`/`applyTrigger`
313
317
  are exported for trigger-char detection and token replacement).
314
318
 
315
- **Organisms:** DataTable (generic `<T>`, typed columns + cell snippets;
319
+ **Organisms:** ConversationPanel / ConversationFeed (chat column + sticky
320
+ feed, see below), DataTable (generic `<T>`, typed columns + cell snippets;
316
321
  `layout="fixed"` makes column widths authoritative, `Column.truncate` /
317
322
  `nowrap` / `hideBelow="sm|md|lg"` (container-query on the table's own box),
318
323
  `hideHeader` clips the header but keeps it for assistive tech, `rowTone(row)`
@@ -562,6 +567,49 @@ it is neither focusable nor announced. `Composer` inherits the behaviour.
562
567
  <AttachmentList tiles files={[{ name: 'cover.png', size: 1024, type: 'image/png', url }]} />
563
568
  ```
564
569
 
570
+ ### ConversationPanel, ConversationFeed, StatusBar
571
+
572
+ `ConversationPanel` is the chat column: `header`, the feed as `children`
573
+ (flexes), `status`, `composer`, filling its parent with token borders between
574
+ the rows. `ConversationFeed` (generic `<T>`) takes `items`, `key(item)` and an
575
+ `item(item, index)` snippet; `empty` renders when there is nothing. It sticks
576
+ to the bottom while the user is within 80px of it (`follow`, default true),
577
+ holds the viewport still when older items are prepended, and shows a
578
+ `jumpLabel` pill ("Jump to latest") when new items land while scrolled up.
579
+ `loadMore` idle | loading | error | done renders a `LoadMore` pill at the top
580
+ that calls `onloadmore`; `scrollToBottom(behavior?)` is exported on the
581
+ instance. `StatusBar` is the thin live row under the feed: `tone` idle |
582
+ busy (Spinner) | ok | warn | error, `label` or `children`, `trailing`.
583
+
584
+ `renderMarkdown(src, { tables?, autolink?, highlight? })` turns chat markdown
585
+ into HTML for `Prose`: everything is escaped first, then headings, emphasis,
586
+ inline/fenced code, lists, blockquotes, rules, GFM tables and links with safe
587
+ schemes only (`javascript:`/`data:` stay text). `highlight(code, lang)` is an
588
+ optional hook returning safe HTML for fenced blocks; `stripAnsi` and
589
+ `escapeHtml` are exported too. `ChatBubble` takes `markdown` directly, plus
590
+ `timestamp` (after the role pill) and a `footer` snippet. `Composer` gains
591
+ `toolbarStart` / `toolbarEnd`: either one moves attach and send into a row
592
+ under the field, start slot left, end slot then send right.
593
+
594
+ ```svelte
595
+ <ConversationPanel>
596
+ {#snippet header()}<Text weight="medium">Session</Text>{/snippet}
597
+ <ConversationFeed bind:this={feed} items={msgs} key={(m) => m.id} loadMore={more} onloadmore={loadOlder}>
598
+ {#snippet item(m)}
599
+ <ChatBubble role={m.role} timestamp={m.at} markdown={m.text}>
600
+ {#snippet footer()}<span>{m.ms}ms</span>{/snippet}
601
+ </ChatBubble>
602
+ {/snippet}
603
+ </ConversationFeed>
604
+ {#snippet status()}<StatusBar tone={busy ? 'busy' : 'ok'} label={busy ? 'Working…' : 'Listening'} />{/snippet}
605
+ {#snippet composer()}
606
+ <Composer onsubmit={send}>
607
+ {#snippet toolbarStart()}<Toggle pressed={pick} onclick={togglePick}>⌖ Pick</Toggle>{/snippet}
608
+ </Composer>
609
+ {/snippet}
610
+ </ConversationPanel>
611
+ ```
612
+
565
613
  ### Artwork
566
614
 
567
615
  `Artwork` is the one cover/thumbnail tile: `src` + `alt` (required) render a
@@ -5,12 +5,19 @@
5
5
  import Badge from '../atoms/Badge.svelte';
6
6
  import Button from '../atoms/Button.svelte';
7
7
  import CopyButton from './CopyButton.svelte';
8
+ import Prose from './Prose.svelte';
9
+ import Timestamp from './Timestamp.svelte';
10
+ import { renderMarkdown } from '../../markdown';
11
+ import type { TimeInput } from '../../timestamp';
8
12
 
9
13
  let {
10
14
  role = 'assistant',
11
15
  roleLabel,
12
16
  align,
17
+ timestamp,
13
18
  meta,
19
+ footer,
20
+ markdown,
14
21
  actions,
15
22
  state: delivery,
16
23
  onretry,
@@ -29,7 +36,13 @@
29
36
  roleLabel?: string;
30
37
  /** Defaults to `end` for the user, `start` otherwise. */
31
38
  align?: 'start' | 'end';
39
+ /** Rendered as a `Timestamp` after the role pill. */
40
+ timestamp?: TimeInput;
32
41
  meta?: Snippet;
42
+ /** Muted row under the body: durations, token usage, file info. */
43
+ footer?: Snippet;
44
+ /** Rendered through `renderMarkdown` into `Prose` when `children` is absent. */
45
+ markdown?: string;
33
46
  actions?: Snippet;
34
47
  state?: 'sending' | 'failed';
35
48
  onretry?: () => void;
@@ -41,13 +54,14 @@
41
54
  selected?: boolean;
42
55
  /** Renders a CopyButton in the actions row. */
43
56
  copyText?: string;
44
- children: Snippet;
57
+ children?: Snippet;
45
58
  class?: string;
46
59
  style?: string;
47
60
  } = $props();
48
61
 
49
62
  const side = $derived(align ?? (role === 'user' ? 'end' : 'start'));
50
63
  let expanded = $state(false);
64
+ const html = $derived(children || markdown === undefined ? undefined : renderMarkdown(markdown));
51
65
  </script>
52
66
 
53
67
  <article
@@ -62,6 +76,7 @@
62
76
  >
63
77
  <header class="meta">
64
78
  <Badge size="xs" class="role-pill">{roleLabel ?? role}</Badge>
79
+ {#if timestamp !== undefined}<Timestamp value={timestamp} mode="time" size="xs" />{/if}
65
80
  {#if meta}{@render meta()}{/if}
66
81
  {#if delivery === 'sending'}<span class="state">sending…</span>{/if}
67
82
  {#if delivery === 'failed'}
@@ -75,12 +90,15 @@
75
90
  </span>
76
91
  {/if}
77
92
  </header>
78
- <div class="body">{@render children()}</div>
93
+ <div class="body">
94
+ {#if children}{@render children()}{:else if html !== undefined}<Prose compact {html} />{/if}
95
+ </div>
79
96
  {#if clamp}
80
97
  <Button size="sm" variant="link" onclick={() => (expanded = !expanded)}>
81
98
  {expanded ? collapseLabel : expandLabel}
82
99
  </Button>
83
100
  {/if}
101
+ {#if footer}<footer class="footer">{@render footer()}</footer>{/if}
84
102
  </article>
85
103
 
86
104
  <style>
@@ -148,6 +166,14 @@
148
166
  min-width: 0;
149
167
  overflow-wrap: anywhere;
150
168
  }
169
+ .footer {
170
+ display: flex;
171
+ flex-wrap: wrap;
172
+ align-items: center;
173
+ gap: var(--sp-2);
174
+ font-size: var(--fs-xs);
175
+ color: var(--text-muted);
176
+ }
151
177
  .clamped .body {
152
178
  display: -webkit-box;
153
179
  -webkit-box-orient: vertical;
@@ -1,11 +1,18 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { TimeInput } from '../../timestamp';
2
3
  type $$ComponentProps = {
3
4
  role?: 'user' | 'assistant' | 'system' | 'tool' | 'mcp';
4
5
  /** Text of the role pill; defaults to the role name. */
5
6
  roleLabel?: string;
6
7
  /** Defaults to `end` for the user, `start` otherwise. */
7
8
  align?: 'start' | 'end';
9
+ /** Rendered as a `Timestamp` after the role pill. */
10
+ timestamp?: TimeInput;
8
11
  meta?: Snippet;
12
+ /** Muted row under the body: durations, token usage, file info. */
13
+ footer?: Snippet;
14
+ /** Rendered through `renderMarkdown` into `Prose` when `children` is absent. */
15
+ markdown?: string;
9
16
  actions?: Snippet;
10
17
  state?: 'sending' | 'failed';
11
18
  onretry?: () => void;
@@ -17,7 +24,7 @@ type $$ComponentProps = {
17
24
  selected?: boolean;
18
25
  /** Renders a CopyButton in the actions row. */
19
26
  copyText?: string;
20
- children: Snippet;
27
+ children?: Snippet;
21
28
  class?: string;
22
29
  style?: string;
23
30
  };
@@ -2,7 +2,8 @@
2
2
  // Chat composer: autoresizing Textarea with attach + send controls fused into
3
3
  // one InputGroup, submit shortcuts, prompt history on ↑/↓ at the text edges,
4
4
  // paste-to-attach and drag-over state. Attachments render above the field as
5
- // removable chips, or as tiles once the composer is narrow.
5
+ // removable chips, or as tiles once the composer is narrow. `toolbarStart` /
6
+ // `toolbarEnd` move attach + send into a row under the field.
6
7
  import type { Snippet } from 'svelte';
7
8
  import Button from '../atoms/Button.svelte';
8
9
  import Textarea from '../atoms/Textarea.svelte';
@@ -29,6 +30,8 @@
29
30
  attachLabel = 'Attach',
30
31
  leading,
31
32
  trailing,
33
+ toolbarStart,
34
+ toolbarEnd,
32
35
  class: klass = '',
33
36
  style: styleProp = ''
34
37
  }: {
@@ -53,6 +56,9 @@
53
56
  attachLabel?: string;
54
57
  leading?: Snippet;
55
58
  trailing?: Snippet;
59
+ /** Either one renders a row under the field: start slot left, end slot then send right. */
60
+ toolbarStart?: Snippet;
61
+ toolbarEnd?: Snippet;
56
62
  class?: string;
57
63
  style?: string;
58
64
  } = $props();
@@ -66,6 +72,7 @@
66
72
  const mode = $derived(submitOn === 'auto' ? (fine() ? 'enter' : 'mod-enter') : submitOn);
67
73
  const canSend = $derived(!busy && !disabled && (value.trim().length > 0 || attachments.length > 0));
68
74
  const hasAttach = $derived(!!onfiles || accept !== undefined);
75
+ const toolbar = $derived(!!toolbarStart || !!toolbarEnd);
69
76
 
70
77
  function submit() {
71
78
  if (!canSend) return;
@@ -129,13 +136,13 @@
129
136
  }
130
137
  </script>
131
138
 
132
- {#snippet groupLeading()}
139
+ {#snippet startControls()}
133
140
  {#if leading}{@render leading()}{/if}
134
141
  {#if hasAttach}
135
142
  <FileButton onfiles={addFiles} {accept} multiple iconOnly label={attachLabel} variant="ghost" box="sm" {disabled} />
136
143
  {/if}
137
144
  {/snippet}
138
- {#snippet groupTrailing()}
145
+ {#snippet endControls()}
139
146
  {#if trailing}{@render trailing()}{/if}
140
147
  <Button variant="primary" box="sm" loading={busy} disabled={!canSend} aria-label={sendLabel} title={sendLabel} onclick={submit}>
141
148
  <span aria-hidden="true">➤</span>
@@ -160,8 +167,8 @@
160
167
  <InputGroup
161
168
  align="end"
162
169
  {disabled}
163
- leading={leading || hasAttach ? groupLeading : undefined}
164
- trailing={groupTrailing}
170
+ leading={!toolbar && (leading || hasAttach) ? startControls : undefined}
171
+ trailing={toolbar ? undefined : endControls}
165
172
  >
166
173
  <Textarea
167
174
  bind:value
@@ -177,6 +184,18 @@
177
184
  {onpaste}
178
185
  />
179
186
  </InputGroup>
187
+ {#if toolbar}
188
+ <div class="toolbar">
189
+ <div class="toolbar-start">
190
+ {@render startControls()}
191
+ {#if toolbarStart}{@render toolbarStart()}{/if}
192
+ </div>
193
+ <div class="toolbar-end">
194
+ {#if toolbarEnd}{@render toolbarEnd()}{/if}
195
+ {@render endControls()}
196
+ </div>
197
+ </div>
198
+ {/if}
180
199
  <span class="hint">{mode === 'enter' ? 'Enter to send · Shift+Enter for a new line' : 'Ctrl/⌘+Enter to send'}</span>
181
200
  </div>
182
201
 
@@ -191,6 +210,25 @@
191
210
  outline: 2px dashed var(--accent);
192
211
  outline-offset: var(--sp-1);
193
212
  }
213
+ .toolbar {
214
+ display: flex;
215
+ align-items: center;
216
+ justify-content: space-between;
217
+ gap: var(--sp-2);
218
+ min-width: 0;
219
+ }
220
+ .toolbar-start,
221
+ .toolbar-end {
222
+ display: flex;
223
+ align-items: center;
224
+ flex-wrap: wrap;
225
+ gap: var(--sp-1);
226
+ min-width: 0;
227
+ }
228
+ .toolbar-end {
229
+ margin-inline-start: auto;
230
+ justify-content: flex-end;
231
+ }
194
232
  .hint {
195
233
  font-size: var(--fs-xs);
196
234
  color: var(--text-faint);
@@ -21,6 +21,9 @@ type $$ComponentProps = {
21
21
  attachLabel?: string;
22
22
  leading?: Snippet;
23
23
  trailing?: Snippet;
24
+ /** Either one renders a row under the field: start slot left, end slot then send right. */
25
+ toolbarStart?: Snippet;
26
+ toolbarEnd?: Snippet;
24
27
  class?: string;
25
28
  style?: string;
26
29
  };
@@ -0,0 +1,90 @@
1
+ <script lang="ts">
2
+ // Thin live-status row for a panel: a tone dot (spinner while busy), a label
3
+ // or custom children, and a trailing slot for elapsed time / counters.
4
+ import type { Snippet } from 'svelte';
5
+ import Dot from '../atoms/Dot.svelte';
6
+ import Spinner from '../atoms/Spinner.svelte';
7
+
8
+ type Tone = 'idle' | 'busy' | 'ok' | 'warn' | 'error';
9
+
10
+ const TONE_COLOR: Record<Exclude<Tone, 'busy'>, string> = {
11
+ idle: 'var(--text-faint)',
12
+ ok: 'var(--ok)',
13
+ warn: 'var(--warn)',
14
+ error: 'var(--danger)'
15
+ };
16
+
17
+ let {
18
+ tone = 'idle',
19
+ label,
20
+ children,
21
+ trailing,
22
+ class: klass = '',
23
+ style: styleProp = ''
24
+ }: {
25
+ tone?: Tone;
26
+ label?: string;
27
+ /** Replaces `label`. */
28
+ children?: Snippet;
29
+ trailing?: Snippet;
30
+ class?: string;
31
+ style?: string;
32
+ } = $props();
33
+ </script>
34
+
35
+ <div
36
+ data-tsu="StatusBar"
37
+ data-tone={tone}
38
+ class="status-bar {klass}"
39
+ class:idle={tone === 'idle'}
40
+ style={styleProp}
41
+ role="status"
42
+ aria-live="polite"
43
+ aria-busy={tone === 'busy' || undefined}
44
+ >
45
+ {#if tone === 'busy'}
46
+ <Spinner size={10} label={undefined} class="status-glyph" />
47
+ {:else}
48
+ <Dot color={TONE_COLOR[tone]} class="status-glyph" aria-hidden="true" />
49
+ {/if}
50
+ <span class="status-label">
51
+ {#if children}{@render children()}{:else}{label}{/if}
52
+ </span>
53
+ {#if trailing}<span class="status-trailing">{@render trailing()}</span>{/if}
54
+ </div>
55
+
56
+ <style>
57
+ .status-bar {
58
+ flex: none;
59
+ display: flex;
60
+ align-items: center;
61
+ gap: var(--sp-2);
62
+ min-width: 0;
63
+ padding: var(--sp-1) var(--sp-3);
64
+ font-size: var(--fs-xs);
65
+ color: var(--text-muted);
66
+ }
67
+ .status-bar.idle {
68
+ color: var(--text-faint);
69
+ }
70
+ .status-bar :global(.status-glyph) {
71
+ flex: none;
72
+ color: var(--accent);
73
+ }
74
+ .status-label {
75
+ flex: 1;
76
+ min-width: 0;
77
+ overflow: hidden;
78
+ text-overflow: ellipsis;
79
+ white-space: nowrap;
80
+ }
81
+ .status-trailing {
82
+ flex: none;
83
+ display: inline-flex;
84
+ align-items: center;
85
+ gap: var(--sp-2);
86
+ color: var(--text-faint);
87
+ font-variant-numeric: tabular-nums;
88
+ white-space: nowrap;
89
+ }
90
+ </style>
@@ -0,0 +1,14 @@
1
+ import type { Snippet } from 'svelte';
2
+ type Tone = 'idle' | 'busy' | 'ok' | 'warn' | 'error';
3
+ type $$ComponentProps = {
4
+ tone?: Tone;
5
+ label?: string;
6
+ /** Replaces `label`. */
7
+ children?: Snippet;
8
+ trailing?: Snippet;
9
+ class?: string;
10
+ style?: string;
11
+ };
12
+ declare const StatusBar: import("svelte").Component<$$ComponentProps, {}, "">;
13
+ type StatusBar = ReturnType<typeof StatusBar>;
14
+ export default StatusBar;
@@ -0,0 +1,148 @@
1
+ <script lang="ts" generics="T">
2
+ // Scrolling message feed: sticks to the bottom while the user is there,
3
+ // keeps the viewport still when older items are prepended, and offers a
4
+ // "jump to latest" pill once new items land while scrolled up.
5
+ import { type Snippet, tick, untrack } from 'svelte';
6
+ import type { Attachment } from 'svelte/attachments';
7
+ import Button from '../atoms/Button.svelte';
8
+ import LoadMore from '../molecules/LoadMore.svelte';
9
+
10
+ let {
11
+ items,
12
+ key,
13
+ item,
14
+ empty,
15
+ loadMore,
16
+ onloadmore,
17
+ loadMoreLabel = 'Load older',
18
+ follow = true,
19
+ jumpLabel = 'Jump to latest',
20
+ class: klass = '',
21
+ style: styleProp = ''
22
+ }: {
23
+ items: T[];
24
+ key: (item: T) => string | number;
25
+ item: Snippet<[T, number]>;
26
+ empty?: Snippet;
27
+ /** State of the LoadMore pill at the top; omit to hide it. */
28
+ loadMore?: 'idle' | 'loading' | 'error' | 'done';
29
+ /** Prepend older items; the scroll position is preserved. */
30
+ onloadmore?: () => void;
31
+ loadMoreLabel?: string;
32
+ /** Stick to the bottom while the user is at the bottom. */
33
+ follow?: boolean;
34
+ jumpLabel?: string;
35
+ class?: string;
36
+ style?: string;
37
+ } = $props();
38
+
39
+ const THRESHOLD = 80;
40
+
41
+ let scroller = $state<HTMLElement | null>(null);
42
+ let stuck = $state(true);
43
+ let pending = $state(false);
44
+ let prevFirst: string | number | undefined;
45
+ let prevLast: string | number | undefined;
46
+ let prevLength = 0;
47
+
48
+ const atBottom = (el: HTMLElement) => el.scrollHeight - el.scrollTop - el.clientHeight <= THRESHOLD;
49
+
50
+ export function scrollToBottom(behavior: ScrollBehavior = 'auto') {
51
+ const el = scroller;
52
+ if (el) el.scrollTo({ top: el.scrollHeight, behavior });
53
+ stuck = true;
54
+ pending = false;
55
+ }
56
+
57
+ function onscroll() {
58
+ if (!scroller) return;
59
+ stuck = atBottom(scroller);
60
+ if (stuck) pending = false;
61
+ }
62
+
63
+ $effect.pre(() => {
64
+ const el = scroller;
65
+ const first = items.length ? key(items[0]) : undefined;
66
+ const last = items.length ? key(items[items.length - 1]) : undefined;
67
+ const length = items.length;
68
+ if (!el) return;
69
+ const prepended = prevFirst !== undefined && first !== prevFirst && length > prevLength;
70
+ const appended = last !== undefined && last !== prevLast;
71
+ prevFirst = first;
72
+ prevLast = last;
73
+ prevLength = length;
74
+ const fromBottom = el.scrollHeight - el.scrollTop;
75
+ tick().then(() => {
76
+ if (prepended) {
77
+ el.scrollTop = el.scrollHeight - fromBottom;
78
+ } else if (appended) {
79
+ if (untrack(() => follow && stuck)) scrollToBottom();
80
+ else pending = true;
81
+ }
82
+ });
83
+ });
84
+
85
+ const pin: Attachment = (node) => {
86
+ if (typeof ResizeObserver === 'undefined') return;
87
+ const ro = new ResizeObserver(() => {
88
+ if (follow && stuck && scroller) scroller.scrollTop = scroller.scrollHeight;
89
+ });
90
+ ro.observe(node);
91
+ return () => ro.disconnect();
92
+ };
93
+ </script>
94
+
95
+ <div data-tsu="ConversationFeed" class="feed-wrap {klass}" style={styleProp}>
96
+ <div class="feed" bind:this={scroller} {onscroll}>
97
+ {#if loadMore}
98
+ <LoadMore pill state={loadMore} onload={onloadmore} label={loadMoreLabel} />
99
+ {/if}
100
+ {#if items.length === 0 && empty}
101
+ {@render empty()}
102
+ {/if}
103
+ <div class="feed-items" {@attach pin}>
104
+ {#each items as entry, i (key(entry))}
105
+ {@render item(entry, i)}
106
+ {/each}
107
+ </div>
108
+ </div>
109
+ {#if pending}
110
+ <div class="jump">
111
+ <Button pill size="sm" onclick={() => scrollToBottom('smooth')}>{jumpLabel} ↓</Button>
112
+ </div>
113
+ {/if}
114
+ </div>
115
+
116
+ <style>
117
+ .feed-wrap {
118
+ position: relative;
119
+ flex: 1;
120
+ display: flex;
121
+ flex-direction: column;
122
+ min-height: 0;
123
+ }
124
+ .feed {
125
+ flex: 1;
126
+ min-height: 0;
127
+ overflow: auto;
128
+ overscroll-behavior: contain;
129
+ display: flex;
130
+ flex-direction: column;
131
+ gap: var(--sp-3);
132
+ padding: var(--sp-3);
133
+ }
134
+ .feed-items {
135
+ display: flex;
136
+ flex-direction: column;
137
+ gap: var(--sp-3);
138
+ }
139
+ .jump {
140
+ position: absolute;
141
+ inset-inline-start: 50%;
142
+ bottom: var(--sp-3);
143
+ transform: translateX(-50%);
144
+ z-index: 1;
145
+ border-radius: var(--r-pill);
146
+ box-shadow: var(--shadow-md);
147
+ }
148
+ </style>
@@ -0,0 +1,44 @@
1
+ import { type Snippet } from 'svelte';
2
+ declare function $$render<T>(): {
3
+ props: {
4
+ items: T[];
5
+ key: (item: T) => string | number;
6
+ item: Snippet<[T, number]>;
7
+ empty?: Snippet;
8
+ /** State of the LoadMore pill at the top; omit to hide it. */
9
+ loadMore?: "idle" | "loading" | "error" | "done";
10
+ /** Prepend older items; the scroll position is preserved. */
11
+ onloadmore?: () => void;
12
+ loadMoreLabel?: string;
13
+ /** Stick to the bottom while the user is at the bottom. */
14
+ follow?: boolean;
15
+ jumpLabel?: string;
16
+ class?: string;
17
+ style?: string;
18
+ };
19
+ exports: {
20
+ scrollToBottom: (behavior?: ScrollBehavior) => void;
21
+ };
22
+ bindings: "";
23
+ slots: {};
24
+ events: {};
25
+ };
26
+ declare class __sveltets_Render<T> {
27
+ props(): ReturnType<typeof $$render<T>>['props'];
28
+ events(): ReturnType<typeof $$render<T>>['events'];
29
+ slots(): ReturnType<typeof $$render<T>>['slots'];
30
+ bindings(): "";
31
+ exports(): {
32
+ scrollToBottom: (behavior?: ScrollBehavior) => void;
33
+ };
34
+ }
35
+ interface $$IsomorphicComponent {
36
+ new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
37
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
38
+ } & ReturnType<__sveltets_Render<T>['exports']>;
39
+ <T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
40
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
41
+ }
42
+ declare const ConversationFeed: $$IsomorphicComponent;
43
+ type ConversationFeed<T> = InstanceType<typeof ConversationFeed<T>>;
44
+ export default ConversationFeed;
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ // Column layout for a chat panel: header, the feed (flexes), status row,
3
+ // composer. Layout only.
4
+ import type { Snippet } from 'svelte';
5
+
6
+ let {
7
+ header,
8
+ children,
9
+ status,
10
+ composer,
11
+ class: klass = '',
12
+ style: styleProp = ''
13
+ }: {
14
+ header?: Snippet;
15
+ /** The feed; fills the space between header and status. */
16
+ children: Snippet;
17
+ status?: Snippet;
18
+ composer?: Snippet;
19
+ class?: string;
20
+ style?: string;
21
+ } = $props();
22
+ </script>
23
+
24
+ <section data-tsu="ConversationPanel" class="panel {klass}" style={styleProp}>
25
+ {#if header}<header class="panel-header">{@render header()}</header>{/if}
26
+ <div class="panel-body">{@render children()}</div>
27
+ {#if status}<div class="panel-status">{@render status()}</div>{/if}
28
+ {#if composer}<div class="panel-composer">{@render composer()}</div>{/if}
29
+ </section>
30
+
31
+ <style>
32
+ .panel {
33
+ display: flex;
34
+ flex-direction: column;
35
+ flex: 1;
36
+ min-height: 0;
37
+ min-width: 0;
38
+ height: 100%;
39
+ background: var(--surface);
40
+ }
41
+ .panel-header {
42
+ flex: none;
43
+ display: flex;
44
+ align-items: center;
45
+ gap: var(--sp-2);
46
+ min-width: 0;
47
+ padding: var(--sp-2) var(--sp-3);
48
+ border-bottom: 1px solid var(--border);
49
+ }
50
+ .panel-body {
51
+ flex: 1;
52
+ display: flex;
53
+ flex-direction: column;
54
+ min-height: 0;
55
+ }
56
+ .panel-status {
57
+ flex: none;
58
+ border-top: 1px solid var(--border);
59
+ }
60
+ .panel-composer {
61
+ flex: none;
62
+ padding: var(--sp-2) var(--sp-3) var(--sp-3);
63
+ border-top: 1px solid var(--border);
64
+ }
65
+ .panel-status + .panel-composer {
66
+ border-top: 0;
67
+ }
68
+ </style>
@@ -0,0 +1,13 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ header?: Snippet;
4
+ /** The feed; fills the space between header and status. */
5
+ children: Snippet;
6
+ status?: Snippet;
7
+ composer?: Snippet;
8
+ class?: string;
9
+ style?: string;
10
+ };
11
+ declare const ConversationPanel: import("svelte").Component<$$ComponentProps, {}, "">;
12
+ type ConversationPanel = ReturnType<typeof ConversationPanel>;
13
+ export default ConversationPanel;
package/dist/index.d.ts CHANGED
@@ -84,6 +84,7 @@ export { default as SectionHeader } from './components/molecules/SectionHeader.s
84
84
  export { default as SegmentedControl, type SegmentOption, } from './components/molecules/SegmentedControl.svelte';
85
85
  export { default as SelectButton } from './components/molecules/SelectButton.svelte';
86
86
  export { default as SplitButton } from './components/molecules/SplitButton.svelte';
87
+ export { default as StatusBar } from './components/molecules/StatusBar.svelte';
87
88
  export { default as Tabs, type TabItem } from './components/molecules/Tabs.svelte';
88
89
  export { default as ThemePicker } from './components/molecules/ThemePicker.svelte';
89
90
  export { default as Timestamp } from './components/molecules/Timestamp.svelte';
@@ -92,11 +93,14 @@ export { default as Toggle } from './components/molecules/Toggle.svelte';
92
93
  export { default as Tooltip } from './components/molecules/Tooltip.svelte';
93
94
  export { default as Truncate } from './components/molecules/Truncate.svelte';
94
95
  export { default as WorkingDir } from './components/molecules/WorkingDir.svelte';
96
+ export { default as ConversationFeed } from './components/organisms/ConversationFeed.svelte';
97
+ export { default as ConversationPanel } from './components/organisms/ConversationPanel.svelte';
95
98
  export { type Column, default as DataTable, type RowTone, } from './components/organisms/DataTable.svelte';
96
99
  export { default as FilterSearchBar } from './components/organisms/FilterSearchBar.svelte';
97
100
  export { formatCount, hasCount } from './count';
98
101
  export { EMOJI_GROUPS, type EmojiEntry, type EmojiGroup, searchEmoji } from './emoji';
99
102
  export { FIELD_KEY, type FieldContext, getFieldContext, setFieldContext, warnUnlabelled, } from './field-context';
103
+ export { escapeHtml, type Highlight, type RenderMarkdownOptions, renderMarkdown, stripAnsi, } from './markdown';
100
104
  export * as filterQuery from './query';
101
105
  export { type AndNode, activeToken, compilePredicate, defaultOperator, type ExprNode, type FieldDef, type FieldType, type FilterNode, filters, findField, freeText, type LeafNode, type NotNode, OPERATORS, type Operator, type OperatorId, type OrNode, operatorByCode, operatorById, operatorsFor, parse, type Query, type QueryNode, resolveValues, type Schema, type Suggestion, type SuggestKind, type SuggestState, serialize, serializeFilter, singleQuery, suggest, suggestSingle, type TextNode, toSql, type ValueContext, type ValueOption, type ValueProvider, walk, } from './query';
102
106
  export { type OptionSection, sectionOptions } from './select-options';
package/dist/index.js CHANGED
@@ -92,6 +92,7 @@ export { default as SectionHeader } from './components/molecules/SectionHeader.s
92
92
  export { default as SegmentedControl, } from './components/molecules/SegmentedControl.svelte';
93
93
  export { default as SelectButton } from './components/molecules/SelectButton.svelte';
94
94
  export { default as SplitButton } from './components/molecules/SplitButton.svelte';
95
+ export { default as StatusBar } from './components/molecules/StatusBar.svelte';
95
96
  export { default as Tabs } from './components/molecules/Tabs.svelte';
96
97
  export { default as ThemePicker } from './components/molecules/ThemePicker.svelte';
97
98
  export { default as Timestamp } from './components/molecules/Timestamp.svelte';
@@ -101,11 +102,14 @@ export { default as Tooltip } from './components/molecules/Tooltip.svelte';
101
102
  export { default as Truncate } from './components/molecules/Truncate.svelte';
102
103
  export { default as WorkingDir } from './components/molecules/WorkingDir.svelte';
103
104
  // ---- organisms ----
105
+ export { default as ConversationFeed } from './components/organisms/ConversationFeed.svelte';
106
+ export { default as ConversationPanel } from './components/organisms/ConversationPanel.svelte';
104
107
  export { default as DataTable, } from './components/organisms/DataTable.svelte';
105
108
  export { default as FilterSearchBar } from './components/organisms/FilterSearchBar.svelte';
106
109
  export { formatCount, hasCount } from './count';
107
110
  export { EMOJI_GROUPS, searchEmoji } from './emoji';
108
111
  export { FIELD_KEY, getFieldContext, setFieldContext, warnUnlabelled, } from './field-context';
112
+ export { escapeHtml, renderMarkdown, stripAnsi, } from './markdown';
109
113
  // ---- query core (headless: schema / parser / AST / suggest / compilers) ----
110
114
  export * as filterQuery from './query';
111
115
  export { activeToken, compilePredicate, defaultOperator, filters, findField, freeText, OPERATORS, operatorByCode, operatorById, operatorsFor, parse, resolveValues, serialize, serializeFilter, singleQuery, suggest, suggestSingle, toSql, walk, } from './query';
@@ -0,0 +1,12 @@
1
+ export type Highlight = (code: string, lang: string) => string;
2
+ export type RenderMarkdownOptions = {
3
+ /** Render GFM pipe tables as `<table>`; default true. */
4
+ tables?: boolean;
5
+ /** Must return HTML-safe markup for the (unescaped) code body. */
6
+ highlight?: Highlight;
7
+ /** Turn bare http(s) URLs into links; default true. */
8
+ autolink?: boolean;
9
+ };
10
+ export declare function escapeHtml(s: string): string;
11
+ export declare function stripAnsi(s: string): string;
12
+ export declare function renderMarkdown(src: string, opts?: RenderMarkdownOptions): string;
@@ -0,0 +1,222 @@
1
+ // Markdown → HTML for chat text, meant for `Prose`. Everything is escaped
2
+ // first, then a fixed tag set is re-introduced, so no raw HTML survives.
3
+ // Highlighting is an optional hook returning safe HTML; no runtime deps.
4
+ export function escapeHtml(s) {
5
+ return s
6
+ .replace(/&/g, '&amp;')
7
+ .replace(/</g, '&lt;')
8
+ .replace(/>/g, '&gt;')
9
+ .replace(/"/g, '&quot;');
10
+ }
11
+ // Placeholder sentinels: control chars that never survive the C0 strip below.
12
+ const BLOCK_L = '\x0E';
13
+ const BLOCK_R = '\x0F';
14
+ const BLOCK_RE = new RegExp(`${BLOCK_L}(\\d+)${BLOCK_R}`, 'g');
15
+ const SPAN_L = '\x10';
16
+ const SPAN_R = '\x11';
17
+ const SPAN_RE = new RegExp(`${SPAN_L}(\\d+)${SPAN_R}`, 'g');
18
+ // Terminal escape sequences (the ansi-regex pattern), then the remaining C0
19
+ // controls except tab and newline, plus DEL.
20
+ const ANSI_RE =
21
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: sanitising control bytes is the point
22
+ /[\x1B\x9B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\x07)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;
23
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: sanitising control bytes is the point
24
+ const C0_RE = /[\x00-\x08\x0B-\x1F\x7F]/g;
25
+ export function stripAnsi(s) {
26
+ return s.replace(ANSI_RE, '').replace(C0_RE, '');
27
+ }
28
+ const SAFE_HREF = /^(?:https?:|mailto:)/i;
29
+ function safeHref(url) {
30
+ const trimmed = url.trim();
31
+ if (SAFE_HREF.test(trimmed))
32
+ return trimmed;
33
+ if (/^(?:\/(?!\/)|#)/.test(trimmed))
34
+ return trimmed;
35
+ return null;
36
+ }
37
+ function stash(blocks, html) {
38
+ return `${BLOCK_L}${blocks.push(html) - 1}${BLOCK_R}`;
39
+ }
40
+ function restore(s, blocks) {
41
+ return s.replace(BLOCK_RE, (_m, i) => blocks[Number(i)]);
42
+ }
43
+ function autolinkUrls(s) {
44
+ const saved = [];
45
+ s = s.replace(/<a\b[^>]*>[\s\S]*?<\/a>|<code\b[^>]*>[\s\S]*?<\/code>/g, (m) => `${SPAN_L}${saved.push(m) - 1}${SPAN_R}`);
46
+ s = s.replace(/\bhttps?:\/\/(?:&amp;|[^\s&<>"])+/g, (raw) => {
47
+ let url = raw;
48
+ let trail = '';
49
+ const punct = url.match(/[.,;:!?]+$/);
50
+ if (punct) {
51
+ trail = punct[0];
52
+ url = url.slice(0, -trail.length);
53
+ }
54
+ const close = url.slice(-1);
55
+ if ((close === ')' || close === ']') && !url.includes(close === ')' ? '(' : '[')) {
56
+ trail = close + trail;
57
+ url = url.slice(0, -1);
58
+ }
59
+ if (!url)
60
+ return raw;
61
+ return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>${trail}`;
62
+ });
63
+ return s.replace(SPAN_RE, (_m, i) => saved[Number(i)]);
64
+ }
65
+ // Inline passes on already-escaped text.
66
+ function inline(s, autolink) {
67
+ s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
68
+ s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
69
+ s = s.replace(/(^|[^*\w])\*([^*\n]+)\*/g, '$1<em>$2</em>');
70
+ s = s.replace(/(^|[^\w])_([^_\n]+)_(?!\w)/g, '$1<em>$2</em>');
71
+ s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>');
72
+ s = s.replace(/\[([^\]]+)\]\(([^\s)]+)\)/g, (m, text, url) => {
73
+ const href = safeHref(url);
74
+ if (!href)
75
+ return m;
76
+ const external = /^https?:/i.test(href) ? ' target="_blank" rel="noopener noreferrer"' : '';
77
+ return `<a href="${href}"${external}>${text}</a>`;
78
+ });
79
+ return autolink ? autolinkUrls(s) : s;
80
+ }
81
+ function splitRow(row) {
82
+ const cells = [];
83
+ let cur = '';
84
+ const t = row.trim().replace(/^\|/, '').replace(/\|$/, '');
85
+ for (let i = 0; i < t.length; i++) {
86
+ if (t[i] === '\\' && t[i + 1] === '|') {
87
+ cur += '|';
88
+ i++;
89
+ }
90
+ else if (t[i] === '|') {
91
+ cells.push(cur);
92
+ cur = '';
93
+ }
94
+ else
95
+ cur += t[i];
96
+ }
97
+ cells.push(cur);
98
+ return cells;
99
+ }
100
+ const TABLE_RE = /(?:^|\n)([ \t]*\|.+\|[ \t]*)\n([ \t]*\|(?:[ \t]*:?-+:?[ \t]*\|)+[ \t]*)\n((?:[ \t]*\|.*\|[ \t]*(?:\n|$))+)/g;
101
+ function table(header, delim, body, autolink) {
102
+ const aligns = splitRow(delim).map((c) => {
103
+ const l = c.trim().startsWith(':');
104
+ const r = c.trim().endsWith(':');
105
+ return r && l ? 'center' : r ? 'right' : l ? 'left' : '';
106
+ });
107
+ const cell = (txt, i, tag) => {
108
+ const a = aligns[i] ? ` style="text-align:${aligns[i]}"` : '';
109
+ return `<${tag}${a}>${inline(txt.trim(), autolink)}</${tag}>`;
110
+ };
111
+ const head = `<tr>${splitRow(header)
112
+ .map((c, i) => cell(c, i, 'th'))
113
+ .join('')}</tr>`;
114
+ const rows = body
115
+ .split('\n')
116
+ .filter((r) => r.trim())
117
+ .map((r) => `<tr>${splitRow(r)
118
+ .map((c, i) => cell(c, i, 'td'))
119
+ .join('')}</tr>`)
120
+ .join('');
121
+ return `<table><thead>${head}</thead><tbody>${rows}</tbody></table>`;
122
+ }
123
+ // Block pass over the escaped, inline-rendered lines. Produces paragraphs,
124
+ // headings, lists, blockquotes and rules; stashed blocks stay opaque.
125
+ function blocks(lines) {
126
+ const out = [];
127
+ let para = [];
128
+ let list = null;
129
+ let quote = [];
130
+ const flushPara = () => {
131
+ if (para.length)
132
+ out.push(`<p>${para.join('<br />')}</p>`);
133
+ para = [];
134
+ };
135
+ const flushList = () => {
136
+ if (list)
137
+ out.push(`<${list.kind}>${list.items.map((i) => `<li>${i}</li>`).join('')}</${list.kind}>`);
138
+ list = null;
139
+ };
140
+ const flushQuote = () => {
141
+ if (quote.length)
142
+ out.push(`<blockquote>${blocks(quote)}</blockquote>`);
143
+ quote = [];
144
+ };
145
+ const flushAll = () => {
146
+ flushPara();
147
+ flushList();
148
+ flushQuote();
149
+ };
150
+ for (const line of lines) {
151
+ const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*$/);
152
+ const item = line.match(/^\s{0,3}(?:([-*+])|(\d{1,9})[.)])\s+(.*)$/);
153
+ const quoted = line.match(/^\s{0,3}&gt;\s?(.*)$/);
154
+ const opaque = line.match(new RegExp(`^\\s*${BLOCK_L}\\d+${BLOCK_R}\\s*$`));
155
+ if (quoted) {
156
+ flushPara();
157
+ flushList();
158
+ quote.push(quoted[1]);
159
+ continue;
160
+ }
161
+ flushQuote();
162
+ if (!line.trim()) {
163
+ flushPara();
164
+ flushList();
165
+ continue;
166
+ }
167
+ if (opaque) {
168
+ flushAll();
169
+ out.push(line.trim());
170
+ continue;
171
+ }
172
+ if (/^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
173
+ flushAll();
174
+ out.push('<hr />');
175
+ continue;
176
+ }
177
+ if (heading) {
178
+ flushAll();
179
+ const level = heading[1].length;
180
+ out.push(`<h${level}>${heading[2]}</h${level}>`);
181
+ continue;
182
+ }
183
+ if (item) {
184
+ flushPara();
185
+ const kind = item[2] ? 'ol' : 'ul';
186
+ if (list && list.kind !== kind)
187
+ flushList();
188
+ if (!list)
189
+ list = { kind, items: [] };
190
+ list.items.push(item[3]);
191
+ continue;
192
+ }
193
+ if (list && /^\s{2,}\S/.test(line)) {
194
+ list.items[list.items.length - 1] += `<br />${line.trim()}`;
195
+ continue;
196
+ }
197
+ flushList();
198
+ para.push(line);
199
+ }
200
+ flushAll();
201
+ return out.join('');
202
+ }
203
+ export function renderMarkdown(src, opts = {}) {
204
+ const tables = opts.tables !== false;
205
+ const autolink = opts.autolink !== false;
206
+ const saved = [];
207
+ let s = stripAnsi(src.replace(/\r\n?/g, '\n'));
208
+ s = s.replace(/```([^\n`]*)\n?([\s\S]*?)```/g, (_m, info, code) => {
209
+ const lang = (info || '').trim().split(/\s+/)[0] ?? '';
210
+ const raw = code.replace(/\n$/, '');
211
+ const body = opts.highlight && lang ? opts.highlight(raw, lang) : escapeHtml(raw);
212
+ const attr = lang ? ` data-lang="${escapeHtml(lang)}"` : '';
213
+ return stash(saved, `<pre${attr}><code>${body}</code></pre>`);
214
+ });
215
+ s = escapeHtml(s);
216
+ if (tables) {
217
+ s = s.replace(TABLE_RE, (_m, header, delim, body) => `\n${stash(saved, table(header, delim, body, autolink))}\n`);
218
+ }
219
+ s = inline(s, autolink);
220
+ s = blocks(s.split('\n'));
221
+ return restore(s, saved);
222
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.61.1",
3
+ "version": "0.62.0",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",