@dorsk/tsumikit 0.61.1 → 0.63.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
@@ -206,6 +206,7 @@ sanctioned escape hatch for everything else.
206
206
  | `GitRef` | `--git-ref-tone`, `--git-ref-max-width` |
207
207
  | `EmptyState` | `--empty-tone` |
208
208
  | `Combobox` | `--cb-bg`, `--cb-border`, `--cb-radius`, `--cb-active-bg`, `--cb-min-width`, `--cb-max-height` |
209
+ | `Toggle` | `--toggle-accent`, `--toggle-size` |
209
210
  | `Popover` | `--pop-trigger-bg`, `--pop-trigger-fg`, `--pop-trigger-border`, `--pop-trigger-radius`, `--pop-trigger-size`, `--pop-trigger-pad`, `--pop-box`, `--pop-count-bg`, `--pop-count-fg`, `--pop-count-size` |
210
211
 
211
212
  `tests/css-custom-property-contract.test.js` reads this table and fails if a
@@ -295,6 +296,10 @@ shows a `N%` bubble while it is moved; `oninput` live, `onchange` on commit;
295
296
  `clearAtMax`/`uncappedLabel` express "no cap", `layout`/`stackBelow` put the
296
297
  readout above a full-width track, `capLabel`/`capValueText` localise the ARIA
297
298
  strings),
299
+ StatusBar (thin `role="status"` row: `tone` idle/busy/ok/warn/error dot or spinner, `label` or
300
+ `children`, `trailing`), ChatBubble (`timestamp`, `footer` snippet, `markdown` body rendered
301
+ through `renderMarkdown` into `Prose` when there are no `children`),
302
+ Composer (`toolbarStart`/`toolbarEnd` snippets move attach + send into a row under the field),
298
303
  Drawer (side-panel `<dialog>`: `side`, `width` clamped to the viewport, full-screen
299
304
  under 48rem; `nav` page column that turns into a horizontal strip on narrow
300
305
  screens, or `navMobile`; sticky `footer`; Escape / scrim / close button all close),
@@ -312,7 +317,8 @@ ArrowUp/Down, Enter/Tab (`selectOn`), Escape and blur; `open`/`index` are
312
317
  bindable, `onselect`/`onclose(reason)` report picks; `findTrigger`/`applyTrigger`
313
318
  are exported for trigger-char detection and token replacement).
314
319
 
315
- **Organisms:** DataTable (generic `<T>`, typed columns + cell snippets;
320
+ **Organisms:** ConversationPanel / ConversationFeed (chat column + sticky
321
+ feed, see below), DataTable (generic `<T>`, typed columns + cell snippets;
316
322
  `layout="fixed"` makes column widths authoritative, `Column.truncate` /
317
323
  `nowrap` / `hideBelow="sm|md|lg"` (container-query on the table's own box),
318
324
  `hideHeader` clips the header but keeps it for assistive tech, `rowTone(row)`
@@ -562,6 +568,49 @@ it is neither focusable nor announced. `Composer` inherits the behaviour.
562
568
  <AttachmentList tiles files={[{ name: 'cover.png', size: 1024, type: 'image/png', url }]} />
563
569
  ```
564
570
 
571
+ ### ConversationPanel, ConversationFeed, StatusBar
572
+
573
+ `ConversationPanel` is the chat column: `header`, the feed as `children`
574
+ (flexes), `status`, `composer`, filling its parent with token borders between
575
+ the rows. `ConversationFeed` (generic `<T>`) takes `items`, `key(item)` and an
576
+ `item(item, index)` snippet; `empty` renders when there is nothing. It sticks
577
+ to the bottom while the user is within 80px of it (`follow`, default true),
578
+ holds the viewport still when older items are prepended, and shows a
579
+ `jumpLabel` pill ("Jump to latest") when new items land while scrolled up.
580
+ `loadMore` idle | loading | error | done renders a `LoadMore` pill at the top
581
+ that calls `onloadmore`; `scrollToBottom(behavior?)` is exported on the
582
+ instance. `StatusBar` is the thin live row under the feed: `tone` idle |
583
+ busy (Spinner) | ok | warn | error, `label` or `children`, `trailing`.
584
+
585
+ `renderMarkdown(src, { tables?, autolink?, highlight? })` turns chat markdown
586
+ into HTML for `Prose`: everything is escaped first, then headings, emphasis,
587
+ inline/fenced code, lists, blockquotes, rules, GFM tables and links with safe
588
+ schemes only (`javascript:`/`data:` stay text). `highlight(code, lang)` is an
589
+ optional hook returning safe HTML for fenced blocks; `stripAnsi` and
590
+ `escapeHtml` are exported too. `ChatBubble` takes `markdown` directly, plus
591
+ `timestamp` (after the role pill) and a `footer` snippet. `Composer` gains
592
+ `toolbarStart` / `toolbarEnd`: either one moves attach and send into a row
593
+ under the field, start slot left, end slot then send right.
594
+
595
+ ```svelte
596
+ <ConversationPanel>
597
+ {#snippet header()}<Text weight="medium">Session</Text>{/snippet}
598
+ <ConversationFeed bind:this={feed} items={msgs} key={(m) => m.id} loadMore={more} onloadmore={loadOlder}>
599
+ {#snippet item(m)}
600
+ <ChatBubble role={m.role} timestamp={m.at} markdown={m.text}>
601
+ {#snippet footer()}<span>{m.ms}ms</span>{/snippet}
602
+ </ChatBubble>
603
+ {/snippet}
604
+ </ConversationFeed>
605
+ {#snippet status()}<StatusBar tone={busy ? 'busy' : 'ok'} label={busy ? 'Working…' : 'Listening'} />{/snippet}
606
+ {#snippet composer()}
607
+ <Composer onsubmit={send}>
608
+ {#snippet toolbarStart()}<Toggle pressed={pick} onclick={togglePick}>⌖ Pick</Toggle>{/snippet}
609
+ </Composer>
610
+ {/snippet}
611
+ </ConversationPanel>
612
+ ```
613
+
565
614
  ### Artwork
566
615
 
567
616
  `Artwork` is the one cover/thumbnail tile: `src` + `alt` (required) render a
@@ -715,6 +764,21 @@ triggers, and `SegmentedControl`. Each renders an exact
715
764
  original ghost icon-button default. Use `control` on `Button` or `Popover` when
716
765
  the roomier shared `--control-height` composer contract is required.
717
766
 
767
+ `variant="toggle"` renders the trigger as a `Toggle` chip — the component
768
+ itself, not a copy of its chrome — so a popover trigger sitting next to a
769
+ `Toggle` in a toolbar shares one box. `size` (`sm` default, `md`), `pill`,
770
+ `pressed` and `tone` behave as they do on `Toggle`; a `Toggle`'s height comes
771
+ from `--toggle-size`, never from its content, so an emoji, an `Icon` and text
772
+ all leave the chip the same height.
773
+
774
+ ```svelte
775
+ <Toggle pressed={auto} onclick={toggleAuto}><Icon name="zap" size={12} /> Auto</Toggle>
776
+ <Popover label="Pins" variant="toggle" pressed={pinned.length > 0} count={pinned.length}>
777
+ {#snippet trigger()}Pins{/snippet}
778
+ {#snippet children()}…{/snippet}
779
+ </Popover>
780
+ ```
781
+
718
782
  `Toolbar` implements the keyboard contract its `role="toolbar"` announces: the
719
783
  bar is a **single tab stop**, and `←`/`→` (plus `Home`/`End`) move between the
720
784
  controls that are actually visible — collapsed `data-overflow` children drop out
@@ -95,6 +95,7 @@
95
95
  sun: '<circle cx="12" cy="12" r="4" /><path d="M12 2v2" /><path d="M12 20v2" /><path d="m4.93 4.93 1.41 1.41" /><path d="m17.66 17.66 1.41 1.41" /><path d="M2 12h2" /><path d="M20 12h2" /><path d="m6.34 17.66-1.41 1.41" /><path d="m19.07 4.93-1.41 1.41" />',
96
96
  moon: '<path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" />',
97
97
  flame: '<path d="M12 3q1 4 4 6.5t3 5.5a1 1 0 0 1-14 0 5 5 0 0 1 1-3 1 1 0 0 0 5 0c0-2-1.5-3-1.5-5q0-2 2.5-4" />',
98
+ zap: '<path d="M13 2 4.093 12.688a1 1 0 0 0 .764 1.642H11l-1 7.67L18.907 11.31a1 1 0 0 0-.764-1.642H13z" />',
98
99
  loader: '<path d="M21 12a9 9 0 1 1-6.219-8.56" />',
99
100
 
100
101
  // — circled status —
@@ -78,6 +78,7 @@ declare const ICONS: {
78
78
  readonly sun: "<circle cx=\"12\" cy=\"12\" r=\"4\" /><path d=\"M12 2v2\" /><path d=\"M12 20v2\" /><path d=\"m4.93 4.93 1.41 1.41\" /><path d=\"m17.66 17.66 1.41 1.41\" /><path d=\"M2 12h2\" /><path d=\"M20 12h2\" /><path d=\"m6.34 17.66-1.41 1.41\" /><path d=\"m19.07 4.93-1.41 1.41\" />";
79
79
  readonly moon: "<path d=\"M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401\" />";
80
80
  readonly flame: "<path d=\"M12 3q1 4 4 6.5t3 5.5a1 1 0 0 1-14 0 5 5 0 0 1 1-3 1 1 0 0 0 5 0c0-2-1.5-3-1.5-5q0-2 2.5-4\" />";
81
+ readonly zap: "<path d=\"M13 2 4.093 12.688a1 1 0 0 0 .764 1.642H11l-1 7.67L18.907 11.31a1 1 0 0 0-.764-1.642H13z\" />";
81
82
  readonly loader: "<path d=\"M21 12a9 9 0 1 1-6.219-8.56\" />";
82
83
  readonly info: "<circle cx=\"12\" cy=\"12\" r=\"10\" /><path d=\"M12 16v-4\" /><path d=\"M12 8h.01\" />";
83
84
  readonly warning: "<path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\" /><path d=\"M12 9v4\" /><path d=\"M12 17h.01\" />";
@@ -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
  };
@@ -13,10 +13,11 @@
13
13
  import type { HTMLAttributes } from 'svelte/elements';
14
14
  import { formatCount, hasCount } from '../../count';
15
15
  import { place } from '../../floating';
16
+ import Toggle from './Toggle.svelte';
16
17
  import { HOVER_CLOSE_GRACE, HOVER_OPEN_DELAY, createHoverIntent, opensOnHover } from './popover-hover.js';
17
18
 
18
19
  type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
19
- type TriggerVariant = 'default' | 'primary' | 'ghost' | 'danger';
20
+ type TriggerVariant = 'default' | 'primary' | 'ghost' | 'danger' | 'toggle';
20
21
  type TriggerTone = 'none' | 'accent' | 'success' | 'info' | 'warn' | 'danger';
21
22
  type TriggerSize = 'sm' | 'md' | 'lg';
22
23
  type PanelRole = 'dialog' | 'menu' | 'listbox' | 'group';
@@ -42,8 +43,12 @@
42
43
  * you fully own (pair with `triggerClass`). */
43
44
  bare?: boolean;
44
45
  /** Button-compatible trigger chrome. Omitting every chrome prop preserves
45
- * the original ghost icon-button trigger. */
46
+ * the original ghost icon-button trigger. `toggle` renders the trigger as
47
+ * the `Toggle` chip itself, so a trigger and a Toggle side by side share
48
+ * one box — `size`, `pill` and `pressed` behave as they do on `Toggle`. */
46
49
  variant?: TriggerVariant;
50
+ /** `variant="toggle"` only: paint the chip in its "on" tint. */
51
+ pressed?: boolean;
47
52
  tone?: TriggerTone;
48
53
  size?: TriggerSize;
49
54
  /** Shared square box scale (`--box-xs/sm/md/lg`) for an icon-only trigger,
@@ -96,6 +101,7 @@
96
101
  triggerClass = '',
97
102
  bare = false,
98
103
  variant,
104
+ pressed = false,
99
105
  tone = 'none',
100
106
  size,
101
107
  box,
@@ -122,12 +128,29 @@
122
128
  ...rest
123
129
  }: Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own = $props();
124
130
 
131
+ // The Toggle chip is not restyled here, it is rendered: the trigger *is* a
132
+ // `Toggle`, so the two cannot drift apart. A link trigger keeps the canonical
133
+ // chrome — `Toggle` is always a button.
134
+ const toggleChrome = $derived(variant === 'toggle' && as === 'button');
125
135
  const canonicalChrome = $derived(
126
- variant !== undefined || tone !== 'none' || size !== undefined || control || block
136
+ !toggleChrome && (variant !== undefined || tone !== 'none' || size !== undefined || control || block)
127
137
  );
128
138
  const counted = $derived(hasCount(count));
129
139
  const countText = $derived(hasCount(count) ? formatCount(count, countMax) : '');
130
140
  const triggerName = $derived(counted ? `${label}, ${count}` : label);
141
+ const toneVar = $derived(
142
+ { none: undefined, accent: 'var(--accent)', success: 'var(--ok)', info: 'var(--info)', warn: 'var(--warn)', danger: 'var(--danger)' }[
143
+ tone
144
+ ]
145
+ );
146
+ // The count pill is absolutely positioned against the chip, which therefore
147
+ // needs to be its containing block; Popover's scoped CSS cannot reach an
148
+ // element rendered by `Toggle`, so the two declarations ride inline.
149
+ const toggleStyle = $derived(
150
+ [counted ? 'position:relative' : '', toneVar ? `--toggle-accent:${toneVar}` : '', styleProp]
151
+ .filter(Boolean)
152
+ .join(';')
153
+ );
131
154
 
132
155
  const id = `pop-${Math.random().toString(36).slice(2, 8)}`;
133
156
  let triggerEl = $state<HTMLElement | null>(null);
@@ -241,7 +264,30 @@
241
264
  }
242
265
  </script>
243
266
 
244
- <svelte:element
267
+ {#if toggleChrome}
268
+ <Toggle
269
+ {...rest}
270
+ {...triggerAttrs}
271
+ {pressed}
272
+ {pill}
273
+ size={size === 'md' ? 'md' : 'sm'}
274
+ data-tsu="Popover"
275
+ class="pop-trigger trigger-toggle {triggerClass} {klass}"
276
+ style={toggleStyle}
277
+ aria-label={triggerName}
278
+ aria-haspopup={haspopup}
279
+ aria-expanded={open}
280
+ onpointerenter={onPointerEnter}
281
+ onpointerleave={onPointerLeave}
282
+ {@attach (node) => {
283
+ triggerEl = node;
284
+ }}
285
+ >
286
+ {@render trigger()}
287
+ {#if counted}<span class="pop-count count-sm" aria-hidden="true">{countText}</span>{/if}
288
+ </Toggle>
289
+ {:else}
290
+ <svelte:element
245
291
  this={as}
246
292
  bind:this={triggerEl}
247
293
  {...rest}
@@ -280,6 +326,7 @@
280
326
  {@render trigger()}
281
327
  {#if counted}<span class="pop-count" aria-hidden="true">{countText}</span>{/if}
282
328
  </svelte:element>
329
+ {/if}
283
330
 
284
331
  <div
285
332
  bind:this={panelEl}
@@ -495,7 +542,8 @@
495
542
  pointer-events: none;
496
543
  }
497
544
  .trigger-sm .pop-count,
498
- .bare .pop-count {
545
+ .bare .pop-count,
546
+ .pop-count.count-sm {
499
547
  --pop-count-size: 1rem;
500
548
  font-size: calc(var(--fs-xs) * 0.85);
501
549
  }
@@ -1,7 +1,7 @@
1
1
  import { type Snippet } from 'svelte';
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
3
  type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
4
- type TriggerVariant = 'default' | 'primary' | 'ghost' | 'danger';
4
+ type TriggerVariant = 'default' | 'primary' | 'ghost' | 'danger' | 'toggle';
5
5
  type TriggerTone = 'none' | 'accent' | 'success' | 'info' | 'warn' | 'danger';
6
6
  type TriggerSize = 'sm' | 'md' | 'lg';
7
7
  type PanelRole = 'dialog' | 'menu' | 'listbox' | 'group';
@@ -28,8 +28,12 @@ type Own = {
28
28
  * you fully own (pair with `triggerClass`). */
29
29
  bare?: boolean;
30
30
  /** Button-compatible trigger chrome. Omitting every chrome prop preserves
31
- * the original ghost icon-button trigger. */
31
+ * the original ghost icon-button trigger. `toggle` renders the trigger as
32
+ * the `Toggle` chip itself, so a trigger and a Toggle side by side share
33
+ * one box — `size`, `pill` and `pressed` behave as they do on `Toggle`. */
32
34
  variant?: TriggerVariant;
35
+ /** `variant="toggle"` only: paint the chip in its "on" tint. */
36
+ pressed?: boolean;
33
37
  tone?: TriggerTone;
34
38
  size?: TriggerSize;
35
39
  /** Shared square box scale (`--box-xs/sm/md/lg`) for an icon-only trigger,
@@ -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;
@@ -54,16 +54,20 @@
54
54
  </button>
55
55
 
56
56
  <style>
57
+ /* The height is pinned to the `--toggle-size` box, never to the line box:
58
+ an emoji, a 12px Icon and plain text all leave the chip the same height. */
57
59
  .toggle {
58
60
  display: inline-flex;
59
61
  align-items: center;
60
62
  justify-content: center;
61
63
  gap: 4px;
62
- padding: 0.15rem var(--sp-2);
64
+ box-sizing: border-box;
65
+ height: var(--toggle-size, var(--box-xs));
66
+ padding: 0 var(--sp-2);
63
67
  border-radius: var(--r-sm);
64
68
  font-size: var(--fs-xs);
65
69
  font-weight: var(--fw-medium);
66
- line-height: 1.4;
70
+ line-height: 1;
67
71
  background: var(--bg-elevated-2);
68
72
  color: var(--text-muted);
69
73
  border: 1px solid var(--border);
@@ -82,7 +86,7 @@
82
86
  border-radius: var(--r-pill);
83
87
  }
84
88
  .toggle.md {
85
- padding: 0.3rem var(--sp-2);
89
+ height: var(--toggle-size, var(--control-height-compact));
86
90
  font-size: var(--fs-sm);
87
91
  }
88
92
  .toggle.grow {
@@ -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.63.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",