@joewinke/jatui 0.38.6 → 0.40.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.
Files changed (98) hide show
  1. package/CLAUDE.md +54 -5
  2. package/THIRD_PARTY_NOTICES.md +67 -0
  3. package/dist/actions/scrollEdge.d.ts +55 -0
  4. package/dist/actions/scrollEdge.js +144 -0
  5. package/dist/artifact-blocks/blocks/diagram/DiagramBlock.svelte.d.ts +1 -1
  6. package/dist/artifact-blocks/blocks/diagram/schema.d.ts +1 -1
  7. package/dist/components/BottomSheet.svelte +7 -6
  8. package/dist/components/BottomSheet.svelte.d.ts +2 -0
  9. package/dist/components/BulkActionBar.svelte +32 -2
  10. package/dist/components/BulkActionBar.svelte.d.ts +5 -1
  11. package/dist/components/CloseButton.svelte +75 -0
  12. package/dist/components/CloseButton.svelte.d.ts +29 -0
  13. package/dist/components/Drawer.svelte +10 -11
  14. package/dist/components/Drawer.svelte.d.ts +2 -8
  15. package/dist/components/FloatingActionBar.svelte +102 -34
  16. package/dist/components/FloatingActionBar.svelte.d.ts +9 -0
  17. package/dist/components/IconButton.svelte +200 -0
  18. package/dist/components/IconButton.svelte.d.ts +46 -0
  19. package/dist/components/LinkButton.svelte +117 -0
  20. package/dist/components/LinkButton.svelte.d.ts +32 -0
  21. package/dist/components/MediaExplorer.svelte +249 -0
  22. package/dist/components/MediaExplorer.svelte.d.ts +37 -0
  23. package/dist/components/MediaLightbox.svelte +2 -7
  24. package/dist/components/Modal.svelte +2 -10
  25. package/dist/components/NotificationCenter.svelte +481 -0
  26. package/dist/components/NotificationCenter.svelte.d.ts +29 -0
  27. package/dist/components/PinInput.svelte +94 -31
  28. package/dist/components/PinInput.svelte.d.ts +4 -0
  29. package/dist/components/SearchDropdown.svelte +54 -8
  30. package/dist/components/SearchDropdown.svelte.d.ts +12 -0
  31. package/dist/components/TextArea.svelte +67 -5
  32. package/dist/components/TextArea.svelte.d.ts +4 -6
  33. package/dist/components/ThemeSelector.svelte +29 -9
  34. package/dist/components/ThemeSelector.svelte.d.ts +6 -0
  35. package/dist/components/ThemeToggle.svelte +28 -3
  36. package/dist/components/ThemeToggle.svelte.d.ts +9 -0
  37. package/dist/components/agent/AgentLog.svelte +217 -0
  38. package/dist/components/agent/AgentLog.svelte.d.ts +72 -0
  39. package/dist/components/agent/AgentLogRow.svelte +281 -0
  40. package/dist/components/agent/AgentLogRow.svelte.d.ts +21 -0
  41. package/dist/components/agent/AgentTaskList.svelte +607 -0
  42. package/dist/components/agent/AgentTaskList.svelte.d.ts +40 -0
  43. package/dist/components/agent/AgentThinking.svelte +280 -0
  44. package/dist/components/agent/AgentThinking.svelte.d.ts +22 -0
  45. package/dist/components/agent/ComposerGlow.svelte +265 -0
  46. package/dist/components/agent/ComposerGlow.svelte.d.ts +18 -0
  47. package/dist/components/agent/ContextMeter.svelte +277 -0
  48. package/dist/components/agent/ContextMeter.svelte.d.ts +21 -0
  49. package/dist/components/agent/Questionnaire.svelte +767 -0
  50. package/dist/components/agent/Questionnaire.svelte.d.ts +29 -0
  51. package/dist/components/agent/ShimmerText.svelte +100 -0
  52. package/dist/components/agent/ShimmerText.svelte.d.ts +19 -0
  53. package/dist/components/agent/StreamedText.svelte +135 -0
  54. package/dist/components/agent/StreamedText.svelte.d.ts +13 -0
  55. package/dist/components/agent/agentLog.d.ts +22 -0
  56. package/dist/components/agent/agentLog.js +26 -0
  57. package/dist/components/agent/agentTaskList.d.ts +90 -0
  58. package/dist/components/agent/agentTaskList.js +93 -0
  59. package/dist/components/agent/agentThinking.d.ts +46 -0
  60. package/dist/components/agent/agentThinking.js +78 -0
  61. package/dist/components/agent/contextMeter.d.ts +97 -0
  62. package/dist/components/agent/contextMeter.js +156 -0
  63. package/dist/components/agent/questionnaire.d.ts +104 -0
  64. package/dist/components/agent/questionnaire.js +225 -0
  65. package/dist/components/agent/revealCore.d.ts +74 -0
  66. package/dist/components/agent/revealCore.js +160 -0
  67. package/dist/components/agent/revealTicker.d.ts +34 -0
  68. package/dist/components/agent/revealTicker.js +73 -0
  69. package/dist/components/agent/streamedText.d.ts +28 -0
  70. package/dist/components/agent/streamedText.js +59 -0
  71. package/dist/components/basic/Breadcrumb.svelte +226 -11
  72. package/dist/components/basic/Breadcrumb.svelte.d.ts +27 -1
  73. package/dist/components/basic/breadcrumbCollapse.d.ts +19 -0
  74. package/dist/components/basic/breadcrumbCollapse.js +12 -0
  75. package/dist/components/graph/GraphCanvas.svelte.d.ts +2 -2
  76. package/dist/components/messaging/MessageInput.svelte +34 -4
  77. package/dist/components/messaging/MessageInput.svelte.d.ts +7 -0
  78. package/dist/components/messaging/MessageItem.svelte +2 -2
  79. package/dist/components/notificationCenter.d.ts +65 -0
  80. package/dist/components/notificationCenter.js +110 -0
  81. package/dist/components/progress/Steps.svelte +269 -5
  82. package/dist/components/progress/Steps.svelte.d.ts +16 -1
  83. package/dist/components/progress/stepsSummary.d.ts +14 -0
  84. package/dist/components/progress/stepsSummary.js +32 -0
  85. package/dist/index.d.ts +48 -1
  86. package/dist/index.js +39 -1
  87. package/dist/selection/clickSelection.d.ts +89 -0
  88. package/dist/selection/clickSelection.js +152 -0
  89. package/dist/selection/createClickSelection.d.ts +79 -0
  90. package/dist/selection/createClickSelection.js +134 -0
  91. package/dist/styles/tokens.css +80 -1
  92. package/dist/utils/pinInput.d.ts +33 -0
  93. package/dist/utils/pinInput.js +63 -0
  94. package/dist/utils/textareaRows.d.ts +23 -0
  95. package/dist/utils/textareaRows.js +13 -0
  96. package/dist/utils/themeWipe.d.ts +36 -17
  97. package/dist/utils/themeWipe.js +145 -35
  98. package/package.json +3 -2
@@ -76,7 +76,7 @@ declare function $$render<N extends Node = Node, E extends Edge = Edge>(): {
76
76
  screenToFlowPosition: (p: import("@xyflow/system").XYPosition) => import("@xyflow/system").XYPosition;
77
77
  getViewport: () => Viewport;
78
78
  };
79
- bindings: "viewport" | "nodes" | "edges";
79
+ bindings: "viewport" | "edges" | "nodes";
80
80
  slots: {};
81
81
  events: {};
82
82
  };
@@ -84,7 +84,7 @@ declare class __sveltets_Render<N extends Node = Node, E extends Edge = Edge> {
84
84
  props(): ReturnType<typeof $$render<N, E>>['props'];
85
85
  events(): ReturnType<typeof $$render<N, E>>['events'];
86
86
  slots(): ReturnType<typeof $$render<N, E>>['slots'];
87
- bindings(): "viewport" | "nodes" | "edges";
87
+ bindings(): "viewport" | "edges" | "nodes";
88
88
  exports(): {
89
89
  /**
90
90
  * Imperative handle (`bind:this={canvas}` → `canvas.api.fitView()`). Every
@@ -4,6 +4,13 @@
4
4
  Rich text input for composing and sending messages.
5
5
  Supports mentions, attachments, and drag/drop.
6
6
  All searches and uploads are via callbacks.
7
+
8
+ Agent composers (opt-in, jatui-lltq9): `busy` + `onStop` swap the send
9
+ button for a same-size Stop button (aria-label "Stop generating") while a
10
+ reply is generating; Escape inside the input also stops (an open mention
11
+ list still takes Escape first). Sending is held while busy — the draft is
12
+ kept. `statusTab` renders a slim strip attached to the input's top edge
13
+ (e.g. model · message count). Pair with ComposerGlow for the rim light.
7
14
  -->
8
15
 
9
16
  <script lang="ts">
@@ -18,6 +25,7 @@
18
25
  } from '../../utils/mentionParser'
19
26
  import MentionAutocomplete from './MentionAutocomplete.svelte'
20
27
  import QuotedMessageDisplay from './QuotedMessageDisplay.svelte'
28
+ import type { Snippet } from 'svelte'
21
29
 
22
30
  interface Props {
23
31
  placeholder?: string
@@ -37,6 +45,12 @@
37
45
  ontyping?: (data: { isTyping: boolean }) => void
38
46
  onquoteremove?: () => void
39
47
  onsearchmentions?: (query: string) => Promise<MentionItem[]>
48
+ /** A reply is generating: the send slot shows Stop, Escape stops, sending is held. */
49
+ busy?: boolean
50
+ /** Called by the Stop button / Escape while `busy`. */
51
+ onStop?: () => void
52
+ /** Slim strip attached to the input's top edge (status chrome). */
53
+ statusTab?: Snippet
40
54
  }
41
55
 
42
56
  let {
@@ -52,6 +66,9 @@
52
66
  ontyping,
53
67
  onquoteremove,
54
68
  onsearchmentions,
69
+ busy = false,
70
+ onStop,
71
+ statusTab,
55
72
  }: Props = $props()
56
73
 
57
74
  let selectedPriority = $state<'low' | 'normal' | 'high' | 'urgent'>('normal')
@@ -203,6 +220,7 @@
203
220
 
204
221
  function handleKeyDown(event: KeyboardEvent) {
205
222
  if (showMentionAutocomplete && ['ArrowUp', 'ArrowDown', 'Tab', 'Enter', 'Escape'].includes(event.key)) return
223
+ if (event.key === 'Escape' && busy && onStop) { event.preventDefault(); event.stopPropagation(); onStop(); return }
206
224
  if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendMessage() }
207
225
  }
208
226
 
@@ -255,7 +273,7 @@
255
273
 
256
274
  function sendMessage() {
257
275
  const content = inputText.trim()
258
- if ((!content && attachments.length === 0) || disabled) return
276
+ if ((!content && attachments.length === 0) || disabled || busy) return
259
277
 
260
278
  onsend?.({
261
279
  content,
@@ -368,6 +386,12 @@
368
386
  </div>
369
387
  {/if}
370
388
 
389
+ {#if statusTab}
390
+ <div class="self-start -mb-2 ml-3 max-w-[calc(100%-1.5rem)] truncate px-2.5 py-0.5 text-xs text-base-content/60 bg-base-200 border border-b-0 border-base-300 rounded-t-lg">
391
+ {@render statusTab()}
392
+ </div>
393
+ {/if}
394
+
371
395
  <div class="flex items-end gap-2 {compact ? 'p-2' : 'p-3'} border border-base-300 rounded-lg focus-within:border-primary transition-colors">
372
396
  {#if !compact}
373
397
  <button type="button" class="btn btn-ghost btn-sm btn-circle flex-shrink-0" onclick={handleFileUpload} title="Attach file" {disabled}>📎</button>
@@ -412,9 +436,15 @@
412
436
  </select>
413
437
  {/if}
414
438
 
415
- <button type="button" class="btn btn-primary {compact ? 'btn-xs' : 'btn-sm'} btn-circle flex-shrink-0" onclick={sendMessage} disabled={disabled || (!inputText.trim() && attachments.length === 0) || isOverLimit} title="Send message (Enter)">
416
- ➤
417
- </button>
439
+ {#if busy}
440
+ <button type="button" class="btn btn-primary {compact ? 'btn-xs' : 'btn-sm'} btn-circle flex-shrink-0" onclick={() => onStop?.()} disabled={!onStop} aria-label="Stop generating" title="Stop generating (Esc)">
441
+ <svg viewBox="0 0 16 16" class="w-3 h-3" fill="currentColor" aria-hidden="true"><rect x="3" y="3" width="10" height="10" rx="2" /></svg>
442
+ </button>
443
+ {:else}
444
+ <button type="button" class="btn btn-primary {compact ? 'btn-xs' : 'btn-sm'} btn-circle flex-shrink-0" onclick={sendMessage} disabled={disabled || (!inputText.trim() && attachments.length === 0) || isOverLimit} title="Send message (Enter)">
445
+ ➤
446
+ </button>
447
+ {/if}
418
448
  </div>
419
449
 
420
450
  <div class="text-xs text-base-content/50">
@@ -1,4 +1,5 @@
1
1
  import type { QuotedMessage, PendingAttachment, MentionItem, AttachmentCallbacks } from '../../types/messaging';
2
+ import type { Snippet } from 'svelte';
2
3
  interface Props {
3
4
  placeholder?: string;
4
5
  disabled?: boolean;
@@ -19,6 +20,12 @@ interface Props {
19
20
  }) => void;
20
21
  onquoteremove?: () => void;
21
22
  onsearchmentions?: (query: string) => Promise<MentionItem[]>;
23
+ /** A reply is generating: the send slot shows Stop, Escape stops, sending is held. */
24
+ busy?: boolean;
25
+ /** Called by the Stop button / Escape while `busy`. */
26
+ onStop?: () => void;
27
+ /** Slim strip attached to the input's top edge (status chrome). */
28
+ statusTab?: Snippet;
22
29
  }
23
30
  declare const MessageInput: import("svelte").Component<Props, {
24
31
  focus: () => void;
@@ -290,7 +290,7 @@
290
290
  {#if !isConsecutive}
291
291
  <Avatar name={message.senderName} avatarUrl={message.senderAvatar} size="small" />
292
292
  {:else}
293
- <div class="text-xs text-base-content/40 opacity-0 group-hover:opacity-100 transition-opacity text-center pt-0.5">
293
+ <div class="text-xs text-base-content/40 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity text-center pt-0.5">
294
294
  {formatTime(message.createdAt).split(' ')[1] || ''}
295
295
  </div>
296
296
  {/if}
@@ -299,7 +299,7 @@
299
299
  <!-- Content -->
300
300
  <div class="flex-1 min-w-0 relative">
301
301
  <!-- Floating action buttons -->
302
- <div class="absolute right-0 {!isConsecutive ? 'top-0' : 'top-1'} opacity-0 group-hover:opacity-100 transition-opacity bg-base-200 rounded-lg border border-base-300 p-1 z-10">
302
+ <div class="absolute right-0 {!isConsecutive ? 'top-0' : 'top-1'} opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity bg-base-200 rounded-lg border border-base-300 p-1 z-10">
303
303
  <div class="flex items-center">
304
304
  {#if reactionCallbacks && currentUserId}
305
305
  <div class="flex items-center gap-1 mr-1">
@@ -0,0 +1,65 @@
1
+ export type NotificationStatus = 'info' | 'success' | 'warning' | 'error';
2
+ export interface NotificationAction {
3
+ label: string;
4
+ onSelect: (item: NotificationItem) => void;
5
+ /** Button weight. Default 'secondary' (a quiet outline button). */
6
+ variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
7
+ }
8
+ export interface NotificationItem {
9
+ id: string;
10
+ title: string;
11
+ description?: string;
12
+ /** When it happened — Date, ISO/DB timestamp string, or epoch ms. */
13
+ timestamp: Date | string | number;
14
+ /** Host-owned read flag. Missing / false = unread. */
15
+ read?: boolean;
16
+ /** Free-form category the tabs filter on (defaults: 'mentions', 'system'). */
17
+ category?: string;
18
+ /** Status glyph shown when there is no avatar. */
19
+ status?: NotificationStatus;
20
+ avatar?: {
21
+ name: string;
22
+ src?: string | null;
23
+ };
24
+ actions?: NotificationAction[];
25
+ /** Opening the item follows this link. */
26
+ href?: string;
27
+ }
28
+ export interface NotificationTab {
29
+ id: string;
30
+ label: string;
31
+ /** Which items the tab shows. Omitted = every item. */
32
+ filter?: (item: NotificationItem) => boolean;
33
+ }
34
+ /** All / Mentions / System, filtered by `category` ('mention(s)' / 'system'). */
35
+ export declare const DEFAULT_NOTIFICATION_TABS: NotificationTab[];
36
+ export type DateGroupKey = 'today' | 'yesterday' | 'week' | 'earlier';
37
+ export declare const DATE_GROUP_LABELS: Record<DateGroupKey, string>;
38
+ /** Epoch ms for a timestamp, or NaN when it cannot be parsed. */
39
+ export declare function toTimestampMs(ts: Date | string | number | null | undefined): number;
40
+ /**
41
+ * Which date bucket a timestamp falls in, by LOCAL calendar day relative to
42
+ * `now`: today, yesterday, the 5 days before that ("This week" = within the
43
+ * last 7 calendar days), else earlier. Future and unparseable timestamps land
44
+ * in 'today' / 'earlier' respectively.
45
+ */
46
+ export declare function dateGroupKey(ts: Date | string | number, now?: Date | number): DateGroupKey;
47
+ export interface NotificationGroup<T extends NotificationItem = NotificationItem> {
48
+ key: DateGroupKey;
49
+ label: string;
50
+ items: T[];
51
+ }
52
+ /**
53
+ * Newest-first items bucketed under Today / Yesterday / This week / Earlier.
54
+ * Empty buckets are dropped; ties keep input order (stable sort); items with
55
+ * an unparseable timestamp sort last.
56
+ */
57
+ export declare function groupNotificationsByDate<T extends NotificationItem>(items: readonly T[], now?: Date | number): NotificationGroup<T>[];
58
+ /** Unread = host says not read AND not optimistically read locally. */
59
+ export declare function isNotificationUnread(item: NotificationItem, localRead?: ReadonlySet<string>): boolean;
60
+ export declare function unreadIds(items: readonly NotificationItem[], localRead?: ReadonlySet<string>): string[];
61
+ export declare function filterByTab<T extends NotificationItem>(items: readonly T[], tab: NotificationTab | undefined): T[];
62
+ /** Item count per tab id (what the tab chips show). */
63
+ export declare function tabCounts(items: readonly NotificationItem[], tabs: readonly NotificationTab[]): Record<string, number>;
64
+ /** The header sentence: "3 unread" / "No unread notifications". */
65
+ export declare function unreadSummary(count: number): string;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * NotificationCenter — pure data helpers (DOM-free, unit-tested in
3
+ * tests/unit/notificationCenter.test.ts).
4
+ *
5
+ * The data model is adapted from BoardUI's notification-center.tsx
6
+ * (MIT License — Copyright (c) 2026 Mertcan Dundar Esmergul (BoardUI)):
7
+ * `category` / `status` / `avatar` / `actions[]` and the All / Mentions /
8
+ * System tab split. jatui changes: real timestamps (Date | string | epoch ms)
9
+ * grouped into calendar date buckets, configurable tabs, host-owned read state.
10
+ */
11
+ import { parseTimestamp } from '../utils/dateFormatters';
12
+ const inCategory = (...names) => (item) => item.category != null && names.includes(item.category.toLowerCase());
13
+ /** All / Mentions / System, filtered by `category` ('mention(s)' / 'system'). */
14
+ export const DEFAULT_NOTIFICATION_TABS = [
15
+ { id: 'all', label: 'All' },
16
+ { id: 'mentions', label: 'Mentions', filter: inCategory('mentions', 'mention') },
17
+ { id: 'system', label: 'System', filter: inCategory('system') }
18
+ ];
19
+ export const DATE_GROUP_LABELS = {
20
+ today: 'Today',
21
+ yesterday: 'Yesterday',
22
+ week: 'This week',
23
+ earlier: 'Earlier'
24
+ };
25
+ const DATE_GROUP_ORDER = ['today', 'yesterday', 'week', 'earlier'];
26
+ /** Epoch ms for a timestamp, or NaN when it cannot be parsed. */
27
+ export function toTimestampMs(ts) {
28
+ if (ts == null || ts === '')
29
+ return NaN;
30
+ if (typeof ts === 'number')
31
+ return Number.isFinite(ts) ? ts : NaN;
32
+ const d = parseTimestamp(ts);
33
+ return d ? d.getTime() : NaN;
34
+ }
35
+ /** Local midnight of the day containing `ms`. */
36
+ function startOfDay(ms) {
37
+ const d = new Date(ms);
38
+ d.setHours(0, 0, 0, 0);
39
+ return d.getTime();
40
+ }
41
+ /**
42
+ * Which date bucket a timestamp falls in, by LOCAL calendar day relative to
43
+ * `now`: today, yesterday, the 5 days before that ("This week" = within the
44
+ * last 7 calendar days), else earlier. Future and unparseable timestamps land
45
+ * in 'today' / 'earlier' respectively.
46
+ */
47
+ export function dateGroupKey(ts, now = Date.now()) {
48
+ const ms = toTimestampMs(ts);
49
+ if (Number.isNaN(ms))
50
+ return 'earlier';
51
+ const today = startOfDay(typeof now === 'number' ? now : now.getTime());
52
+ const day = startOfDay(ms);
53
+ if (day >= today)
54
+ return 'today';
55
+ // Calendar-day difference, rounded so DST shifts (23h / 25h days) don't skew it.
56
+ const days = Math.round((today - day) / 86_400_000);
57
+ if (days === 1)
58
+ return 'yesterday';
59
+ if (days < 7)
60
+ return 'week';
61
+ return 'earlier';
62
+ }
63
+ /**
64
+ * Newest-first items bucketed under Today / Yesterday / This week / Earlier.
65
+ * Empty buckets are dropped; ties keep input order (stable sort); items with
66
+ * an unparseable timestamp sort last.
67
+ */
68
+ export function groupNotificationsByDate(items, now = Date.now()) {
69
+ const sorted = items
70
+ .map((item, i) => ({ item, i, ms: toTimestampMs(item.timestamp) }))
71
+ .sort((a, b) => {
72
+ const am = Number.isNaN(a.ms) ? -Infinity : a.ms;
73
+ const bm = Number.isNaN(b.ms) ? -Infinity : b.ms;
74
+ return bm - am || a.i - b.i;
75
+ });
76
+ const buckets = new Map();
77
+ for (const { item } of sorted) {
78
+ const key = dateGroupKey(item.timestamp, now);
79
+ let list = buckets.get(key);
80
+ if (!list)
81
+ buckets.set(key, (list = []));
82
+ list.push(item);
83
+ }
84
+ return DATE_GROUP_ORDER.filter((k) => buckets.has(k)).map((key) => ({
85
+ key,
86
+ label: DATE_GROUP_LABELS[key],
87
+ items: buckets.get(key)
88
+ }));
89
+ }
90
+ /** Unread = host says not read AND not optimistically read locally. */
91
+ export function isNotificationUnread(item, localRead) {
92
+ return item.read !== true && !(localRead?.has(item.id) ?? false);
93
+ }
94
+ export function unreadIds(items, localRead) {
95
+ return items.filter((it) => isNotificationUnread(it, localRead)).map((it) => it.id);
96
+ }
97
+ export function filterByTab(items, tab) {
98
+ return tab?.filter ? items.filter((it) => tab.filter(it)) : items.slice();
99
+ }
100
+ /** Item count per tab id (what the tab chips show). */
101
+ export function tabCounts(items, tabs) {
102
+ const out = {};
103
+ for (const tab of tabs)
104
+ out[tab.id] = tab.filter ? items.filter(tab.filter).length : items.length;
105
+ return out;
106
+ }
107
+ /** The header sentence: "3 unread" / "No unread notifications". */
108
+ export function unreadSummary(count) {
109
+ return count === 0 ? 'No unread notifications' : `${count} unread`;
110
+ }
@@ -1,4 +1,24 @@
1
+ <!--
2
+ Steps — a numbered step track with an animated connector fill.
3
+
4
+ Opt-in extras (jatui-lltq9; none of them change the default output):
5
+ collapsible a summary header button ("3 steps left") that folds the
6
+ track away; the summary stays live while folded, and the
7
+ folded header also shows the active step's label + ring.
8
+ activeProgress 0–1 → a mini ProgressRing on the active step's node.
9
+ orientation 'vertical' stacks the steps (labels + descriptions beside
10
+ the nodes) — the shape for agent-progress / todo cards.
11
+ onFinished fires once, `completionDelay` ms after `current` passes the
12
+ last step (re-arms if `current` drops back), so a host can
13
+ hand the surface back to the agent's answer.
14
+ Drive `current` from real events; there is no time-simulated stepping.
15
+ -->
1
16
  <script lang="ts">
17
+ import { untrack, type Snippet } from 'svelte';
18
+ import ProgressRing from './ProgressRing.svelte';
19
+ import { motionSlide } from '../../motion/transitions';
20
+ import { clampStepProgress, isStepsFinished, stepsSummary } from './stepsSummary';
21
+
2
22
  interface Step {
3
23
  label?: string;
4
24
  description?: string;
@@ -10,6 +30,20 @@
10
30
  color?: 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'error' | 'info';
11
31
  size?: 'sm' | 'md' | 'lg';
12
32
  class?: string;
33
+ /** Render a summary header button that folds the steps away. */
34
+ collapsible?: boolean;
35
+ /** Folded state (bindable). Only meaningful with `collapsible`. */
36
+ collapsed?: boolean;
37
+ /** Header copy. Default: "N steps left" / "All steps done". */
38
+ summary?: string | Snippet;
39
+ /** Layout. Default 'horizontal'. */
40
+ orientation?: 'horizontal' | 'vertical';
41
+ /** 0–1 progress of the active step → a mini ring on its node. */
42
+ activeProgress?: number;
43
+ /** Hold (ms) after the last step completes before `onFinished`. Default 0. */
44
+ completionDelay?: number;
45
+ /** Fired once after `current` passes the last step (+ `completionDelay`). */
46
+ onFinished?: () => void;
13
47
  }
14
48
 
15
49
  let {
@@ -17,7 +51,14 @@
17
51
  current = 1,
18
52
  color = 'primary',
19
53
  size = 'md',
20
- class: className = ''
54
+ class: className = '',
55
+ collapsible = false,
56
+ collapsed = $bindable(false),
57
+ summary,
58
+ orientation = 'horizontal',
59
+ activeProgress,
60
+ completionDelay = 0,
61
+ onFinished
21
62
  }: Props = $props();
22
63
 
23
64
  const normalized = $derived(
@@ -53,17 +94,45 @@
53
94
  const nodePx = $derived(nodeSize[size]);
54
95
  const fontSize: Record<NonNullable<Props['size']>, string> = { sm: '0.6875rem', md: '0.8125rem', lg: '1rem' };
55
96
  const labelSize = $derived(fontSize[size]);
97
+
98
+ // ── Opt-in extras ────────────────────────────────────────────────────
99
+ const uid = $props.id();
100
+ const bodyId = `steps-body-${uid}`;
101
+ const vertical = $derived(orientation === 'vertical');
102
+ const finished = $derived(isStepsFinished(count, current));
103
+ const ringValue = $derived(
104
+ activeProgress == null ? null : Math.round(clampStepProgress(activeProgress) * 100)
105
+ );
106
+ const summaryText = $derived(typeof summary === 'string' ? summary : stepsSummary(count, current));
107
+ const activeLabel = $derived(finished ? undefined : normalized[current - 1]?.label);
108
+
109
+ // onFinished: once per "run" — re-arms when current drops back to a step.
110
+ let finishedFired = false;
111
+ $effect(() => {
112
+ if (!finished) {
113
+ finishedFired = false;
114
+ return;
115
+ }
116
+ if (finishedFired) return;
117
+ const delay = Math.max(0, completionDelay);
118
+ const timer = setTimeout(() => {
119
+ finishedFired = true;
120
+ untrack(() => onFinished)?.();
121
+ }, delay);
122
+ return () => clearTimeout(timer);
123
+ });
56
124
  </script>
57
125
 
126
+ {#snippet track()}
58
127
  <div
59
- class="steps-wrap {className}"
128
+ class="steps-wrap {collapsible ? '' : className}{vertical ? ' steps-wrap--vertical' : ''}"
60
129
  role="list"
61
130
  aria-label="Progress steps"
62
131
  style="--fill:{fillColor};--fg:{fgColor};--node:{nodePx}px"
63
132
  >
64
133
  <!-- Connector track -->
65
134
  <div class="steps-track" aria-hidden="true">
66
- <div class="steps-track-fill" style="transform:scaleX({trackFill})"></div>
135
+ <div class="steps-track-fill" style="transform:{vertical ? 'scaleY' : 'scaleX'}({trackFill})"></div>
67
136
  </div>
68
137
 
69
138
  <!-- Step nodes -->
@@ -78,7 +147,7 @@
78
147
  role="listitem"
79
148
  aria-current={isActive ? 'step' : undefined}
80
149
  >
81
- <div class="step-node" style="font-size:{labelSize}">
150
+ <div class="step-node" class:step-node--ring={isActive && ringValue != null} style="font-size:{labelSize}">
82
151
  {#if isComplete}
83
152
  <!-- Check mark drawn in on complete -->
84
153
  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="step-check" aria-hidden="true">
@@ -86,14 +155,71 @@
86
155
  </svg>
87
156
  {:else}
88
157
  <span>{stepNum}</span>
158
+ {/if}{#if isActive && ringValue != null}
159
+ <span class="step-ring">
160
+ <ProgressRing
161
+ value={ringValue}
162
+ size={nodePx}
163
+ strokeWidth={size === 'sm' ? 2 : 3}
164
+ {color}
165
+ showValue={false}
166
+ label="{step.label ?? `Step ${stepNum}`} progress"
167
+ />
168
+ </span>
89
169
  {/if}
90
170
  </div>
91
- {#if step.label}
171
+ {#if vertical}
172
+ {#if step.label || step.description}
173
+ <span class="step-text">
174
+ {#if step.label}<span class="step-label" style="font-size:{labelSize}">{step.label}</span>{/if}
175
+ {#if step.description}<span class="step-desc">{step.description}</span>{/if}
176
+ </span>
177
+ {/if}
178
+ {:else if step.label}
92
179
  <span class="step-label" style="font-size:{labelSize}">{step.label}</span>
93
180
  {/if}
94
181
  </div>
95
182
  {/each}
96
183
  </div>
184
+ {/snippet}
185
+
186
+ {#if collapsible}
187
+ <div class="steps-fold {className}" style="--fill:{fillColor}">
188
+ <button
189
+ type="button"
190
+ class="steps-fold-header"
191
+ aria-expanded={!collapsed}
192
+ aria-controls={bodyId}
193
+ onclick={() => (collapsed = !collapsed)}
194
+ >
195
+ <svg class="steps-fold-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
196
+ <polyline points="6,4 10,8 6,12" />
197
+ </svg>
198
+ <span class="steps-fold-summary">
199
+ {#if typeof summary === 'function'}{@render summary()}{:else}{summaryText}{/if}
200
+ </span>
201
+ {#if collapsed && activeLabel}
202
+ <span class="steps-fold-current">{activeLabel}</span>
203
+ {/if}
204
+ {#if collapsed && ringValue != null && !finished}
205
+ <span class="steps-fold-ring" aria-hidden="true">
206
+ <ProgressRing value={ringValue} size={16} strokeWidth={2} {color} showValue={false} />
207
+ </span>
208
+ {/if}
209
+ </button>
210
+ <!-- Live summary for assistive tech — the header text itself is not a live region. -->
211
+ <span class="sr-only" aria-live="polite">{summaryText}</span>
212
+ <div id={bodyId} class="steps-fold-body">
213
+ {#if !collapsed}
214
+ <div class="steps-fold-inner" transition:motionSlide>
215
+ {@render track()}
216
+ </div>
217
+ {/if}
218
+ </div>
219
+ </div>
220
+ {:else}
221
+ {@render track()}
222
+ {/if}
97
223
 
98
224
  <style>
99
225
  .steps-wrap {
@@ -136,6 +262,7 @@
136
262
  }
137
263
 
138
264
  .step-node {
265
+ position: relative;
139
266
  width: var(--node);
140
267
  height: var(--node);
141
268
  border-radius: 9999px;
@@ -160,6 +287,18 @@
160
287
  transform: scale(1.18);
161
288
  }
162
289
 
290
+ /* Active node with a progress ring: the ring IS the border. */
291
+ .step--active .step-node--ring {
292
+ border-color: transparent;
293
+ }
294
+
295
+ .step-ring {
296
+ position: absolute;
297
+ inset: -2px;
298
+ display: flex;
299
+ pointer-events: none;
300
+ }
301
+
163
302
  .step--complete .step-node {
164
303
  background: var(--fill);
165
304
  border-color: var(--fill);
@@ -184,6 +323,127 @@
184
323
  color: var(--color-base-content);
185
324
  }
186
325
 
326
+ /* ── Vertical orientation ─────────────────────────────────────────── */
327
+ .steps-wrap--vertical {
328
+ flex-direction: column;
329
+ align-items: stretch;
330
+ justify-content: flex-start;
331
+ gap: 0.875rem;
332
+ padding-top: 0;
333
+ }
334
+
335
+ .steps-wrap--vertical .steps-track {
336
+ top: calc(var(--node) / 2);
337
+ bottom: calc(var(--node) / 2);
338
+ left: calc(var(--node) / 2);
339
+ right: auto;
340
+ width: 3px;
341
+ height: auto;
342
+ transform: translateX(-50%);
343
+ }
344
+
345
+ .steps-wrap--vertical .steps-track-fill {
346
+ width: 100%;
347
+ transform-origin: top;
348
+ }
349
+
350
+ .steps-wrap--vertical .step {
351
+ flex-direction: row;
352
+ align-items: flex-start;
353
+ gap: 0.75rem;
354
+ }
355
+
356
+ .steps-wrap--vertical .step-node {
357
+ flex-shrink: 0;
358
+ }
359
+
360
+ .step-text {
361
+ display: flex;
362
+ flex-direction: column;
363
+ gap: 0.125rem;
364
+ min-width: 0;
365
+ min-height: var(--node);
366
+ justify-content: center;
367
+ }
368
+
369
+ .steps-wrap--vertical .step-label {
370
+ text-align: left;
371
+ max-width: none;
372
+ }
373
+
374
+ .step-desc {
375
+ font-size: 0.75rem;
376
+ line-height: 1.3;
377
+ color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
378
+ }
379
+
380
+ /* ── Collapsible header ───────────────────────────────────────────── */
381
+ .steps-fold {
382
+ width: 100%;
383
+ }
384
+
385
+ .steps-fold-header {
386
+ display: flex;
387
+ align-items: center;
388
+ gap: 0.5rem;
389
+ width: 100%;
390
+ min-height: 2.25rem;
391
+ padding: 0.375rem 0.5rem;
392
+ border-radius: var(--jatui-radius-field, var(--radius-field, 0.25rem));
393
+ font-size: 0.875rem;
394
+ font-weight: 600;
395
+ text-align: left;
396
+ color: var(--color-base-content);
397
+ background: transparent;
398
+ cursor: pointer;
399
+ transition: background var(--motion-fast, 150ms) var(--motion-glide);
400
+ }
401
+
402
+ .steps-fold-header:hover {
403
+ background: color-mix(in oklch, var(--color-base-content) 6%, transparent);
404
+ }
405
+
406
+ .steps-fold-header:focus-visible {
407
+ outline: 2px solid var(--fill);
408
+ outline-offset: 2px;
409
+ }
410
+
411
+ .steps-fold-chevron {
412
+ width: 1rem;
413
+ height: 1rem;
414
+ flex-shrink: 0;
415
+ color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
416
+ transform: rotate(90deg);
417
+ transition: transform var(--motion-fast, 150ms) var(--motion-glide);
418
+ }
419
+
420
+ .steps-fold-header[aria-expanded='false'] .steps-fold-chevron {
421
+ transform: rotate(0deg);
422
+ }
423
+
424
+ .steps-fold-summary {
425
+ font-variant-numeric: tabular-nums;
426
+ }
427
+
428
+ .steps-fold-current {
429
+ min-width: 0;
430
+ overflow: hidden;
431
+ text-overflow: ellipsis;
432
+ white-space: nowrap;
433
+ font-weight: 400;
434
+ color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
435
+ }
436
+
437
+ .steps-fold-ring {
438
+ display: inline-flex;
439
+ margin-left: auto;
440
+ flex-shrink: 0;
441
+ }
442
+
443
+ .steps-fold-inner {
444
+ padding: 0.5rem 0.25rem 0.25rem;
445
+ }
446
+
187
447
  @media (prefers-reduced-motion: reduce) {
188
448
  .steps-track-fill {
189
449
  transition: none;
@@ -191,5 +451,9 @@
191
451
  .step-node {
192
452
  transition: none;
193
453
  }
454
+ .steps-fold-chevron,
455
+ .steps-fold-header {
456
+ transition: none;
457
+ }
194
458
  }
195
459
  </style>