@joewinke/jatui 0.24.0 → 0.26.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/CLAUDE.md CHANGED
@@ -59,7 +59,7 @@ Then import:
59
59
  | `Badge` | Status/label badge | `variant`, `size`, `children` (snippet) |
60
60
  | `StatusBadge` | Status indicator with dot | `status: StatusValue`, `label` |
61
61
  | `Card` | Content card container | `title`, `children` (snippet) |
62
- | `Modal` | Dialog overlay. `zIndex` accepts `number` OR any CSS `z-index` string, including `"var(--z-lightbox)"`. | `open` (bindable), `title`, `children` (snippet), `zIndex?: string \| number` |
62
+ | `Modal` | Dialog overlay. `zIndex` accepts `number` OR any CSS `z-index` string, including `"var(--z-lightbox)"`. **Escape is owned by the dialog element itself** (not the window handler) — Modal focuses the dialog on open, so `trapKeys`' stopPropagation would otherwise swallow Esc before the window listener sees it; a Modal opened inside a host Drawer closes before the drawer. The component ships its own open-state `.modal-box` opacity rule, so it renders under DaisyUI 5 with no host global override. | `open` (bindable), `title`, `children` (snippet), `zIndex?: string \| number`, `trapKeys?`, `closeOnEscape?` |
63
63
  | `ConfirmModal` | Yes/no confirmation dialog | `open` (bindable), `title`, `message`, `onConfirm`, `onCancel` |
64
64
  | `Drawer` | Slide-out side panel | `open` (bindable), `side`, `children` (snippet) |
65
65
  | `LoadingSpinner` | Spinner animation | `size`, `color` |
@@ -76,6 +76,14 @@ Then import:
76
76
  | `QuotaMeter` | "312 / 500 credits · resets in 4h 10m" bar — warning tint at `warnAt`, error at 100%. Presentational; the host owns the ledger. | `used`, `limit`, `resetsAt?`, `label?`, `unit?`, `warnAt?` (0.8), `size?` |
77
77
  | `VariantMatrixPicker` | Category/option chip grid for building a combinatorial variant matrix (harvested from vid-gen's FrameVariantGenerator, presets stripped — caller-supplied `categories`). Each category tab shows a live selection count and an additive/multiplicative badge: multiplicative selections cartesian-multiply the total, additive ones stack flat onto every combination. Live combination count in the footer; algebra exported separately as pure functions (`generateCombinations`, `countCombinations`, `variantCategoryMode`, `findVariantOption`, `generateVariantFilename`). | `categories: VariantCategory[]`, `selected` (bindable `VariantSelection`), `forceMode?`, `showCount?` (true), `countLabel?`, `onChange?`, `class?` |
78
78
 
79
+ ### Tables (subpath: `jatui/table`)
80
+
81
+ Requires peer `@tanstack/table-core`. `import { DataTable, flexRender, columnResize } from 'jatui/table'`.
82
+
83
+ | Component | Purpose | Key Props |
84
+ |-----------|---------|-----------|
85
+ | `DataTable` | TanStack-backed table (generic over the row type): sorting, selection, client pagination, skeleton. Grid-mode hooks per jat `docs/design/datatable-virtualisation-group-rows.md` §3.3: `getRowId` (keys selection/expansion/api by id, renders `data-row-id`); controlled sort — `bind:sorting`, `initialSorting`, `manualSorting` (caller pre-sorts `data`), `onSortingChange(next, {field, shiftKey})`; `rowProps(row, item)` → `{class, attrs, onclick, oncontextmenu, ondragover, ondrop}` (`attrs` for `data-nav-id`, aria); `onRowClick(row, e)` (skips clicks on interactive children); `headerProps(columnId)`; `expandedRow` snippet `(row, {colspan})` — click/Enter toggles unless `isExpanded(row)` makes it controlled; `empty` snippet; `variant` `zebra`/`plain`/`unstyled` + `tableClass`; `bind:api` → `orderedRowIds()`, `scrollToRow(id)`. Sortable headers are keyboard-operable. | `data`, `columns`, `sortable`, `selectable`, `paginated`, `getRowId?`, `sorting?` (bindable), `rowProps?`, `onRowClick?`, `expandedRow?`, `empty?`, `variant?`, `api?` (bindable) |
86
+
79
87
  ### Media
80
88
 
81
89
  | Component | Purpose | Key Props |
@@ -244,7 +252,7 @@ const callbacks: AnnotationCallbacks = {
244
252
  <KeyboardShortcutsOverlay registry={shortcuts} />
245
253
  ```
246
254
 
247
- **Sidebar convention: `SIDEBAR_TOGGLE_KEY` = `mod+\`** (Ctrl+\ / ⌘\). Do NOT use Ctrl+B: Chrome reserves it (bookmarks bar). Also avoid Ctrl+T/W/N, which the browser keeps. The overlay owns `?`, so don't register it. Helpers: `formatShortcut(def | "mod+k", platform?)` → `⌘K` / `Ctrl+K`, `isTypingTarget(el)`, `parseShortcutCombo`, `detectShortcutPlatform`.
255
+ **Sidebar convention: `SIDEBAR_TOGGLE_KEY` = `mod+\`** (Ctrl+\ / ⌘\). Do NOT use Ctrl+B: Chrome reserves it (bookmarks bar). Also avoid Ctrl+T/W/N, which the browser keeps. The overlay owns `?` by default, so don't register it — but if a host app already has its own global `?` handler registered first, the overlay respects `event.defaultPrevented` and stays out of the way (one overlay per keypress). Helpers: `formatShortcut(def | "mod+k", platform?)` → `⌘K` / `Ctrl+K`, `isTypingTarget(el)`, `parseShortcutCombo`, `detectShortcutPlatform`.
248
256
 
249
257
  ### Maps (Google Maps)
250
258
 
@@ -133,6 +133,11 @@
133
133
  }
134
134
 
135
135
  function onKeydown(e: KeyboardEvent) {
136
+ // Another listener already claimed this keypress (host app's own '?'
137
+ // handler, an open modal, …) — bail so one keypress can't toggle two
138
+ // overlays at once. Covers the '?' and Escape branches alike.
139
+ if (e.defaultPrevented) return;
140
+
136
141
  if (e.ctrlKey || e.metaKey || e.altKey) return;
137
142
 
138
143
  if (e.key === '?') {
@@ -124,6 +124,21 @@
124
124
  }
125
125
 
126
126
  function handleKeyDown(event: KeyboardEvent) {
127
+ // Escape is owned HERE on the dialog element, not by <svelte:window>.
128
+ // Modal focuses the dialog (tabindex=-1) on open, so in the default state
129
+ // keydown dispatches from the dialog itself — and the trapKeys
130
+ // stopPropagation below (which under Svelte 5 delegated events also
131
+ // blocks every window listener) would swallow Escape before the window
132
+ // handler could act: Esc did nothing unless focus was in a text field.
133
+ // Handling it at element level also guarantees a Modal opened inside a
134
+ // host Drawer closes before the drawer does.
135
+ if (open && event.key === 'Escape' && closeOnEscape) {
136
+ event.preventDefault();
137
+ event.stopPropagation();
138
+ close();
139
+ return;
140
+ }
141
+
127
142
  // Stop keydown events from bubbling to window while the modal is open.
128
143
  // This prevents page-level keyboard navigation (listNav-style handlers
129
144
  // on window) from reacting to arrow keys, j/k, vim chords, digit counts,
@@ -247,4 +262,21 @@
247
262
  align-items: center;
248
263
  justify-content: center;
249
264
  }
265
+
266
+ /*
267
+ * DaisyUI 5 puts .modal-box at opacity:0 (layer daisyui.l1.l2.l3) and only
268
+ * restores it from `.modal[open] .modal-box { opacity: 1 }` in the outer
269
+ * layer — a rule that assumes daisyUI's canonical markup where the box is a
270
+ * direct grid child of the dialog. jatui nests the box inside
271
+ * .modal-backdrop, and the restore rule loses the cascade: the panel stays
272
+ * invisible unless the host adds a global override (the jat IDE had to in
273
+ * app.css, jat-vte6e.17). Un-layered author styles beat @layer rules, so
274
+ * declaring the open state here makes the component render correctly on
275
+ * its own. Entrance motion stays owned by the in:fly transition on the box.
276
+ */
277
+ :global(dialog.modal[open] .modal-box) {
278
+ opacity: 1;
279
+ translate: 0;
280
+ scale: 1;
281
+ }
250
282
  </style>
@@ -34,6 +34,8 @@ export interface TranscriptItem {
34
34
  srcLines: number;
35
35
  /** id of the signal node that owns this item (assigned after parse) */
36
36
  signalId: string | null;
37
+ /** ISO write time of the first source message (null on line-parsed/live items) */
38
+ timestamp?: string | null;
37
39
  }
38
40
  export interface FileTouchedEntry {
39
41
  path: string;
@@ -65,11 +67,46 @@ export interface SignalNode {
65
67
  /** Net lines changed across all diffs for this block */
66
68
  linesAdded: number;
67
69
  linesRemoved: number;
70
+ /**
71
+ * Original signal JSON payload (full data for rich card rendering). Always
72
+ * set by the builders below; optional so SignalNodes constructed by hand
73
+ * (pre-0.25 callers) still type-check.
74
+ */
75
+ payload?: Record<string, unknown> | null;
76
+ /**
77
+ * True when this node represents the OPEN, in-progress turn — either a
78
+ * server-marked open block (`WorkBlockClient.open === true`) or the
79
+ * synthesized live node owning the trailing items past the last block.
80
+ * SessionNavigator uses this to render a ● live affordance. Optional so
81
+ * existing SignalNode consumers are unaffected.
82
+ */
83
+ isLive?: boolean;
68
84
  }
69
85
  export interface LinkedSession {
70
86
  items: TranscriptItem[];
71
87
  signals: SignalNode[];
72
88
  }
89
+ /** Tone drives the marker's hue; deliberately NOT a session-state key. */
90
+ export type MarkerTone = 'success' | 'error' | 'warn' | 'neutral' | 'info';
91
+ /** What a card detector declares when its event deserves a rail marker. */
92
+ export interface TimelineMarkerSpec {
93
+ /** glyph shown on the rail — keep it non-emoji so state glyphs stay distinct */
94
+ glyph: string;
95
+ /** short rail label, e.g. "subtask completed" */
96
+ label: string;
97
+ /** longer text for the title/tooltip */
98
+ detail?: string;
99
+ tone: MarkerTone;
100
+ }
101
+ /** A content event promoted onto the signal timeline, anchored to its row. */
102
+ export interface TimelineMarker extends TimelineMarkerSpec {
103
+ /** rail id — "mrk-<itemId>" */
104
+ id: string;
105
+ /** transcript item this marker anchors to (its `[data-row]`) */
106
+ itemId: number;
107
+ /** signal owning that item — clicking a marker inks this signal's ribbon */
108
+ signalId: string | null;
109
+ }
73
110
  export declare function rawTypeToState(raw: string): string;
74
111
  /** Build the linked model from rendered transcript lines. */
75
112
  export declare function buildLinkedSession(lines: string[]): LinkedSession;
@@ -99,6 +136,14 @@ export interface WorkBlockClient {
99
136
  }>;
100
137
  linesAdded?: number;
101
138
  linesRemoved?: number;
139
+ /**
140
+ * True when the server marks this block as the OPEN in-progress turn
141
+ * (populated by the navigator/sessionWork layer — sibling task jat-difet).
142
+ * Read defensively: may be undefined on blocks from older payloads. When
143
+ * true on the last block, that block's node becomes the live node (it
144
+ * absorbs any trailing items instead of synthesizing a separate one).
145
+ */
146
+ open?: boolean;
102
147
  }
103
148
  export interface WorkTranscriptItemClient {
104
149
  id: number;
@@ -107,6 +152,7 @@ export interface WorkTranscriptItemClient {
107
152
  detail: string[];
108
153
  srcLines: number;
109
154
  blockId: string | null;
155
+ timestamp?: string | null;
110
156
  }
111
157
  /**
112
158
  * Build a LinkedSession from the WorkBlock substrate (B1).
@@ -41,10 +41,36 @@ function parseSignalBody(body) {
41
41
  if (!m)
42
42
  return null;
43
43
  let label = '';
44
- const pick = body.match(/"(?:approach|question|reviewFocus|currentStep|summary|reason|taskTitle)"\s*:\s*"([^"]{3,90})/);
45
- if (pick)
46
- label = pick[1];
47
- return { rawType: m[1], label };
44
+ let payload = null;
45
+ // Try to extract and parse the JSON payload from the signal call
46
+ const jsonMatch = body.match(/jat-signal\s+\w+\s+'(\{[\s\S]*?\})'\s*$/) ||
47
+ body.match(/jat-signal\s+\w+\s+"(\{[\s\S]*?\})"\s*$/) ||
48
+ body.match(/jat-signal\s+\w+\s+(\{[\s\S]*?\})\s*$/);
49
+ if (jsonMatch) {
50
+ try {
51
+ payload = JSON.parse(jsonMatch[1]);
52
+ }
53
+ catch {
54
+ // ignore parse errors — payload stays null
55
+ }
56
+ }
57
+ if (payload) {
58
+ // Extract label from full payload
59
+ const p = payload;
60
+ const candidate = (Array.isArray(p.summary) ? p.summary[0] : undefined) ??
61
+ (typeof p.approach === 'string' ? p.approach : undefined) ??
62
+ (typeof p.taskTitle === 'string' ? p.taskTitle : undefined) ??
63
+ (typeof p.question === 'string' ? p.question : undefined) ??
64
+ (typeof p.currentStep === 'string' ? p.currentStep : undefined);
65
+ if (candidate)
66
+ label = candidate.slice(0, 90);
67
+ }
68
+ else {
69
+ const pick = body.match(/"(?:approach|question|reviewFocus|currentStep|summary|reason|taskTitle)"\s*:\s*"([^"]{3,90})/);
70
+ if (pick)
71
+ label = pick[1];
72
+ }
73
+ return { rawType: m[1], label, payload };
48
74
  }
49
75
  /** Build the linked model from rendered transcript lines. */
50
76
  export function buildLinkedSession(lines) {
@@ -64,7 +90,14 @@ export function buildLinkedSession(lines) {
64
90
  } // blank ends a block
65
91
  if (line.startsWith('› ')) {
66
92
  flush();
67
- cur = { kind: 'prompt', headline: line.slice(2), detail: [], srcLines: 1 };
93
+ const headline = line.slice(2);
94
+ // Claude Code's TUI draws a blank "› " input box while idle between
95
+ // turns; a capture that lands mid-frame leaks that empty prompt line
96
+ // into the transcript. Treat it like a blank line (below) rather than
97
+ // a zero-content prompt item — it never carries real content.
98
+ if (!headline.trim())
99
+ continue;
100
+ cur = { kind: 'prompt', headline, detail: [], srcLines: 1 };
68
101
  continue;
69
102
  }
70
103
  if (line.startsWith('⏺ ')) {
@@ -73,7 +106,7 @@ export function buildLinkedSession(lines) {
73
106
  if (sig) {
74
107
  // signal emission = boundary; consume it, don't render as an item
75
108
  flush();
76
- emits.push({ afterIdx: items.length - 1, rawType: sig.rawType, label: sig.label });
109
+ emits.push({ afterIdx: items.length - 1, rawType: sig.rawType, label: sig.label, payload: sig.payload });
77
110
  continue;
78
111
  }
79
112
  flush();
@@ -113,13 +146,33 @@ export function buildLinkedSession(lines) {
113
146
  }
114
147
  flush();
115
148
  // Build signal nodes from emission boundaries.
149
+ // Ownership is FORWARD: each signal owns the items that come AFTER it, up to
150
+ // (but not including) the next signal's emission point. The first signal also
151
+ // absorbs any preamble items that appear before it.
152
+ //
153
+ // Let B[i] = emits[i].afterIdx = last item index before signal[i].
154
+ //
155
+ // Partition (non-overlapping):
156
+ // sig[0] → [0 .. B[1]] preamble + first forward region
157
+ // sig[i>0] → [B[i]+1 .. B[i+1]] region after sig[i], before sig[i+1]
158
+ // sig[last] → [B[last]+1 .. N-1] region after last signal
159
+ //
160
+ // This ensures output printed AFTER a signal emission (e.g. the READY FOR
161
+ // REVIEW banner after `jat-signal review`) is attributed to that signal, not
162
+ // the subsequent one.
116
163
  const signals = [];
117
- let prevBoundary = -1;
164
+ const N = items.length;
118
165
  emits.forEach((e, i) => {
119
- const rowStart = Math.max(0, prevBoundary + 1);
120
- const rowEnd = Math.max(rowStart, e.afterIdx);
166
+ // rowStart: first signal starts at 0 (absorbs preamble items before it);
167
+ // later signals start at the item immediately after this signal's emission.
168
+ const rowStart = i === 0 ? 0 : e.afterIdx + 1;
169
+ // naturalEnd: extends to the next signal's pre-boundary, or to the last item.
170
+ const naturalEnd = i + 1 < emits.length ? emits[i + 1].afterIdx : N - 1;
171
+ // rowEnd: capped at N-1 (never out of bounds); may be < rowStart when this
172
+ // signal has no items after it (e.g. last line is the signal itself).
173
+ const rowEnd = Math.min(naturalEnd, N - 1);
121
174
  let lineCount = 0;
122
- for (let k = rowStart; k <= rowEnd && k < items.length; k++)
175
+ for (let k = rowStart; k <= rowEnd && k < N; k++)
123
176
  lineCount += items[k].srcLines;
124
177
  signals.push({
125
178
  id: `sig-${i}`,
@@ -135,9 +188,9 @@ export function buildLinkedSession(lines) {
135
188
  filesTouched: [],
136
189
  commits: [],
137
190
  linesAdded: 0,
138
- linesRemoved: 0
191
+ linesRemoved: 0,
192
+ payload: e.payload
139
193
  });
140
- prevBoundary = e.afterIdx;
141
194
  });
142
195
  for (const sig of signals) {
143
196
  for (let k = sig.rowStart; k <= sig.rowEnd && k < items.length; k++) {
@@ -168,9 +221,11 @@ export function buildLinkedSessionFromWork(blocks, items) {
168
221
  headline: it.headline,
169
222
  detail: it.detail,
170
223
  srcLines: it.srcLines,
171
- signalId: null // filled in below
224
+ signalId: null, // filled in below
225
+ timestamp: it.timestamp ?? null
172
226
  }));
173
- // Map WorkBlock → SignalNode
227
+ // Map WorkBlock → SignalNode. `isLive` is seeded from the server's open flag
228
+ // (read defensively — jat-difet adds it; may be undefined on older payloads).
174
229
  const signals = blocks.map((blk) => ({
175
230
  id: blk.id,
176
231
  state: rawTypeToState(blk.signal.type),
@@ -185,11 +240,80 @@ export function buildLinkedSessionFromWork(blocks, items) {
185
240
  filesTouched: blk.filesTouched,
186
241
  commits: blk.commits ?? [],
187
242
  linesAdded: blk.linesAdded ?? 0,
188
- linesRemoved: blk.linesRemoved ?? 0
243
+ linesRemoved: blk.linesRemoved ?? 0,
244
+ payload: blk.signal.payload,
245
+ isLive: blk.open === true
189
246
  }));
190
- // Assign signalId to transcript items via block ownership
247
+ const N = transcriptItems.length;
248
+ // Helper: synthesize the live node owning the open in-progress turn.
249
+ const makeLiveNode = (start, end, label) => {
250
+ let lineCount = 0;
251
+ for (let k = start; k <= end && k < N; k++)
252
+ lineCount += transcriptItems[k].srcLines;
253
+ return {
254
+ id: 'sig-live',
255
+ state: rawTypeToState('working'),
256
+ rawType: 'working',
257
+ label,
258
+ rowStart: start,
259
+ rowEnd: end,
260
+ lineCount: Math.max(1, lineCount),
261
+ blockId: null,
262
+ summary: null,
263
+ filesTouched: [],
264
+ commits: [],
265
+ linesAdded: 0,
266
+ linesRemoved: 0,
267
+ payload: null,
268
+ isLive: true
269
+ };
270
+ };
271
+ if (N > 0) {
272
+ if (signals.length === 0) {
273
+ // No blocks but live items exist → the whole transcript is the open
274
+ // turn. Give it a single live node so nothing is left unsignaled.
275
+ signals.push(makeLiveNode(0, N - 1, 'current'));
276
+ }
277
+ else {
278
+ // (#1 gap-fill) Guarantee a contiguous [0..N-1] partition so EVERY
279
+ // item ends up owning a signal:
280
+ // (a) first signal absorbs any preamble before its range
281
+ signals[0].rowStart = 0;
282
+ // (b) extend each signal's rowEnd to close the gap before the next
283
+ // signal's range (no-op when ranges already touch/overlap)
284
+ for (let i = 0; i < signals.length - 1; i++) {
285
+ const nextStart = signals[i + 1].rowStart;
286
+ if (signals[i].rowEnd < nextStart - 1) {
287
+ signals[i].rowEnd = nextStart - 1;
288
+ }
289
+ }
290
+ // (#2 live node) Trailing items past the last block's range are the
291
+ // OPEN in-progress turn — they must NOT be folded into the previous
292
+ // signal (e.g. a 'review'). Honor a server-marked open block first;
293
+ // otherwise synthesize a dedicated live node.
294
+ const lastSig = signals[signals.length - 1];
295
+ const lastBlockEnd = lastSig.rowEnd;
296
+ if (lastSig.isLive) {
297
+ // Server already marked the last block open → IT is the live node;
298
+ // extend it to absorb any trailing items.
299
+ lastSig.rowEnd = N - 1;
300
+ }
301
+ else if (lastBlockEnd < N - 1) {
302
+ // Synthesize a live node owning [lastBlockEnd+1 .. N-1]. Label from
303
+ // the live task title when available, else a neutral 'current'.
304
+ const p = lastSig.payload;
305
+ const title = p && typeof p.taskTitle === 'string' ? p.taskTitle.slice(0, 90) : null;
306
+ signals.push(makeLiveNode(lastBlockEnd + 1, N - 1, title ?? 'current'));
307
+ }
308
+ // else: the last block already reaches N-1 — nothing trailing.
309
+ }
310
+ }
311
+ // Assign signalId to transcript items via (gap-filled) ownership ranges.
312
+ // Clamp to bounds; later signals (incl. the live node) win on any overlap.
191
313
  for (const sig of signals) {
192
- for (let k = sig.rowStart; k <= sig.rowEnd && k < transcriptItems.length; k++) {
314
+ const start = Math.max(0, sig.rowStart);
315
+ const end = Math.min(sig.rowEnd, N - 1);
316
+ for (let k = start; k <= end; k++) {
193
317
  transcriptItems[k].signalId = sig.id;
194
318
  }
195
319
  }
@@ -202,7 +326,7 @@ export function buildLinkedSessionFromWork(blocks, items) {
202
326
  */
203
327
  export const SAMPLE_TRANSCRIPT_LINES = [
204
328
  '',
205
- '› /jat:start GentleCoast jat-7h2k',
329
+ '› /jat-start GentleCoast jat-7h2k',
206
330
  '⏺ Bash(get-current-session-id)',
207
331
  ' ⎿ 4f9c1a2e-7b3d-4c8a-9e1f-2a6b8c0d4e5f',
208
332
  '⏺ Bash(jat-signal starting \'{"agentName":"GentleCoast","taskTitle":"Add password reset flow"}\')',
@@ -256,7 +380,7 @@ export const SAMPLE_TRANSCRIPT_LINES = [
256
380
  '',
257
381
  '🔍 READY FOR REVIEW: jat-7h2k — password reset flow implemented, tested, rate-limited.',
258
382
  '',
259
- '› /jat:complete',
383
+ '› /jat-complete',
260
384
  '⏺ Bash(jat-step committing --task jat-7h2k)',
261
385
  ' ⎿ Committed: feat(jat-7h2k): add password reset flow',
262
386
  '⏺ Bash(jat-signal complete \'{"taskId":"jat-7h2k","summary":"Shipped password reset flow"}\')',
@@ -1,4 +1,5 @@
1
- <script lang="ts">
1
+ <script lang="ts" generics="TData">
2
+ import { tick } from 'svelte';
2
3
  import {
3
4
  createTable,
4
5
  getCoreRowModel,
@@ -9,12 +10,10 @@
9
10
  type RowSelectionState,
10
11
  type PaginationState,
11
12
  type TableOptionsResolved,
13
+ type Row,
12
14
  } from '@tanstack/table-core';
13
15
  import { flexRender } from './flexRender.js';
14
- import type { DataTableProps } from './types.js';
15
-
16
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
17
- type AnyData = any;
16
+ import type { DataTableProps, DataTableRowProps, DataTableSortEvent, GroupedItem } from './types.js';
18
17
 
19
18
  let {
20
19
  data,
@@ -27,13 +26,36 @@
27
26
  loadingRows = 5,
28
27
  onSelectionChange,
29
28
  class: className = '',
30
- }: DataTableProps = $props();
29
+ getRowId,
30
+ sorting = $bindable(),
31
+ initialSorting,
32
+ manualSorting = false,
33
+ onSortingChange,
34
+ rowProps,
35
+ onRowClick,
36
+ headerProps,
37
+ expandedRow,
38
+ isExpanded,
39
+ empty,
40
+ variant = 'zebra',
41
+ tableClass = '',
42
+ api = $bindable(),
43
+ }: DataTableProps<TData> = $props();
31
44
 
32
45
  // ── State ──────────────────────────────────────────────────────────────
33
- let sorting = $state<SortingState>([]);
46
+ // `sorting` is bindable: bound, the caller owns it; unbound, it is local
47
+ // state seeded from `initialSorting`.
48
+ // svelte-ignore state_referenced_locally
49
+ if (sorting === undefined) sorting = initialSorting ?? [];
50
+ const sortState = $derived<SortingState>(sorting ?? []);
34
51
  let rowSelection = $state<RowSelectionState>({});
35
52
  // svelte-ignore state_referenced_locally
36
53
  let pagination = $state<PaginationState>({ pageIndex: 0, pageSize });
54
+ /** Internally-owned expansion (only used when `isExpanded` is not supplied). */
55
+ let expandedIds = $state<Set<string>>(new Set());
56
+ /** The header click that triggered the sort change currently in flight. */
57
+ let pendingSortEvent: DataTableSortEvent | null = null;
58
+ let rootEl: HTMLDivElement | undefined = $state();
37
59
 
38
60
  // Reset to the first page whenever the data set changes.
39
61
  //
@@ -50,7 +72,7 @@
50
72
  pagination = { pageIndex: 0, pageSize };
51
73
  });
52
74
 
53
- const checkboxCol: ColumnDef<AnyData, unknown> = {
75
+ const checkboxCol: ColumnDef<TData, unknown> = {
54
76
  id: '__select',
55
77
  header: () => null,
56
78
  cell: () => null,
@@ -58,24 +80,26 @@
58
80
  size: 40,
59
81
  };
60
82
 
61
- const allColumns = $derived<ColumnDef<AnyData, unknown>[]>(
62
- selectable ? [checkboxCol, ...(columns as ColumnDef<AnyData, unknown>[])] : (columns as ColumnDef<AnyData, unknown>[])
63
- );
83
+ const allColumns = $derived<ColumnDef<TData, unknown>[]>(selectable ? [checkboxCol, ...columns] : columns);
64
84
 
65
85
  // ── Table instance ─────────────────────────────────────────────────────
66
86
  const table = $derived.by(() => {
67
- const opts: TableOptionsResolved<AnyData> = {
87
+ const opts: TableOptionsResolved<TData> = {
68
88
  data,
69
89
  columns: allColumns,
70
90
  state: {
71
- sorting,
91
+ sorting: sortState,
72
92
  rowSelection,
73
93
  ...(paginated ? { pagination } : {}),
74
94
  },
95
+ getRowId: getRowId ? (row: TData) => getRowId(row) : undefined,
75
96
  enableRowSelection: selectable,
76
97
  enableSorting: sortable,
98
+ manualSorting,
77
99
  onSortingChange: (updater) => {
78
- sorting = typeof updater === 'function' ? updater(sorting) : updater;
100
+ const next = typeof updater === 'function' ? updater(sortState) : updater;
101
+ sorting = next;
102
+ onSortingChange?.(next, pendingSortEvent ?? { field: next[0]?.id ?? '', shiftKey: false });
79
103
  },
80
104
  onRowSelectionChange: (updater) => {
81
105
  rowSelection = typeof updater === 'function' ? updater(rowSelection) : updater;
@@ -87,7 +111,7 @@
87
111
  : () => {},
88
112
  onStateChange: () => {},
89
113
  getCoreRowModel: getCoreRowModel(),
90
- getSortedRowModel: sortable ? getSortedRowModel() : undefined,
114
+ getSortedRowModel: sortable && !manualSorting ? getSortedRowModel() : undefined,
91
115
  getPaginationRowModel: paginated ? getPaginationRowModel() : undefined,
92
116
  renderFallbackValue: '',
93
117
  };
@@ -109,30 +133,128 @@
109
133
  const canPrevPage = $derived(table.getCanPreviousPage());
110
134
  const canNextPage = $derived(table.getCanNextPage());
111
135
 
136
+ const tableClasses = $derived(
137
+ [variant === 'unstyled' ? '' : variant === 'plain' ? 'table' : 'table table-zebra', 'w-full', tableClass]
138
+ .filter(Boolean)
139
+ .join(' ')
140
+ );
141
+
142
+ /** The table owns expansion: a row click / Enter / Space toggles the detail row. */
143
+ const internalExpand = $derived(!!expandedRow && !isExpanded);
144
+
112
145
  $effect(() => {
113
146
  if (!selectable || !onSelectionChange) return;
114
- const selected = table.getSelectedRowModel().rows.map((r: AnyData) => r.original);
147
+ const selected = table.getSelectedRowModel().rows.map((r) => r.original);
115
148
  onSelectionChange(selected);
116
149
  });
117
150
 
151
+ // ── Imperative api ─────────────────────────────────────────────────────
152
+ api = {
153
+ orderedRowIds: () => table.getPrePaginationRowModel().rows.map((r) => r.id),
154
+ scrollToRow: async (id, align = 'auto') => {
155
+ if (paginated) {
156
+ const index = table.getPrePaginationRowModel().rows.findIndex((r) => r.id === id);
157
+ if (index < 0) return;
158
+ const pageIndex = Math.floor(index / pagination.pageSize);
159
+ if (pageIndex !== pagination.pageIndex) {
160
+ pagination = { ...pagination, pageIndex };
161
+ await tick();
162
+ }
163
+ }
164
+ const el = rootEl?.querySelector<HTMLElement>(`tr[data-row-id="${CSS.escape(id)}"]`);
165
+ el?.scrollIntoView({ block: align === 'auto' ? 'nearest' : align });
166
+ },
167
+ };
168
+
118
169
  function sortDir(headerId: string): 'asc' | 'desc' | false {
119
- const s = sorting.find((item) => item.id === headerId);
170
+ const s = sortState.find((item) => item.id === headerId);
120
171
  return s ? (s.desc ? 'desc' : 'asc') : false;
121
172
  }
122
173
 
123
- function renderHeader(header: ReturnType<typeof table.getHeaderGroups>[0]['headers'][0]) {
174
+ type AnyHeader = ReturnType<typeof table.getHeaderGroups>[0]['headers'][0];
175
+
176
+ function toggleSort(header: AnyHeader, e: MouseEvent | KeyboardEvent) {
177
+ pendingSortEvent = { field: header.column.id, shiftKey: e.shiftKey };
178
+ try {
179
+ header.column.getToggleSortingHandler()?.(e);
180
+ } finally {
181
+ pendingSortEvent = null;
182
+ }
183
+ }
184
+
185
+ function onHeaderKeydown(header: AnyHeader, e: KeyboardEvent) {
186
+ if (e.key !== 'Enter' && e.key !== ' ') return;
187
+ e.preventDefault();
188
+ toggleSort(header, e);
189
+ }
190
+
191
+ function renderHeader(header: AnyHeader) {
124
192
  return flexRender(header.column.columnDef.header, header.getContext());
125
193
  }
126
194
 
127
- function renderCell(cell: ReturnType<ReturnType<typeof table.getRowModel>['rows'][0]['getVisibleCells']>[0]) {
195
+ function renderCell(cell: ReturnType<Row<TData>['getVisibleCells']>[0]) {
128
196
  return flexRender(cell.column.columnDef.cell, cell.getContext());
129
197
  }
198
+
199
+ // ── Row helpers ────────────────────────────────────────────────────────
200
+ function rowItem(row: Row<TData>, index: number, count: number): GroupedItem<TData> {
201
+ const position = count === 1 ? 'only' : index === 0 ? 'first' : index === count - 1 ? 'last' : 'middle';
202
+ return { kind: 'row', row: row.original, id: row.id, depth: 0, groupPath: [], position };
203
+ }
204
+
205
+ function rowIsExpanded(row: Row<TData>): boolean {
206
+ if (!expandedRow) return false;
207
+ return isExpanded ? isExpanded(row.original) : expandedIds.has(row.id);
208
+ }
209
+
210
+ function toggleExpanded(id: string) {
211
+ const next = new Set(expandedIds);
212
+ if (next.has(id)) next.delete(id);
213
+ else next.add(id);
214
+ expandedIds = next;
215
+ }
216
+
217
+ /** Clicks on these inside a row belong to the control, not to the row. */
218
+ const INTERACTIVE = 'input, button, a, select, textarea, label, [contenteditable]:not([contenteditable="false"])';
219
+
220
+ function isInteractiveTarget(e: Event): boolean {
221
+ const target = e.target as Element | null;
222
+ const tr = e.currentTarget as Element;
223
+ const hit = target?.closest?.(INTERACTIVE);
224
+ return !!hit && hit !== tr && tr.contains(hit);
225
+ }
226
+
227
+ function onRowClickInternal(row: Row<TData>, rp: DataTableRowProps, e: MouseEvent) {
228
+ if (isInteractiveTarget(e)) return;
229
+ rp.onclick?.(e);
230
+ onRowClick?.(row.original, e);
231
+ if (internalExpand) toggleExpanded(row.id);
232
+ }
233
+
234
+ function onRowKeydown(row: Row<TData>, e: KeyboardEvent) {
235
+ if (!internalExpand || e.target !== e.currentTarget) return;
236
+ if (e.key !== 'Enter' && e.key !== ' ') return;
237
+ e.preventDefault();
238
+ toggleExpanded(row.id);
239
+ }
240
+
241
+ /** Normalise `rowProps().attrs`: booleans become `""`/omitted (aria-* stay "true"/"false"). */
242
+ function rowAttrs(rp: DataTableRowProps): Record<string, string | number | undefined> {
243
+ const out: Record<string, string | number | undefined> = {};
244
+ for (const [k, v] of Object.entries(rp.attrs ?? {})) {
245
+ if (typeof v === 'boolean') out[k] = k.startsWith('aria-') ? String(v) : v ? '' : undefined;
246
+ else out[k] = v;
247
+ }
248
+ return out;
249
+ }
250
+
251
+ const hasRowHandlers = $derived(!!rowProps || !!onRowClick || internalExpand);
130
252
  </script>
131
253
 
132
- <div class="datatable-root {className}">
254
+ <div class="datatable-root {className}" bind:this={rootEl}>
133
255
  {#if loading}
134
256
  <div class="overflow-x-auto">
135
- <table class="table table-zebra w-full">
257
+ <table class={tableClasses}>
136
258
  <thead>
137
259
  <tr>
138
260
  {#each allColumns as _}
@@ -155,7 +277,7 @@
155
277
  </div>
156
278
  {:else}
157
279
  <div class="overflow-x-auto">
158
- <table class="table table-zebra w-full">
280
+ <table class={tableClasses}>
159
281
  <thead>
160
282
  {#each headerGroups as headerGroup}
161
283
  <tr>
@@ -175,10 +297,15 @@
175
297
  {@const isSortable = sortable && header.column.getCanSort()}
176
298
  {@const dir = sortDir(header.column.id)}
177
299
  {@const rendered = renderHeader(header)}
300
+ {@const hp = headerProps?.(header.column.id)}
178
301
  <th
179
302
  style:width={header.getSize() !== 150 ? `${header.getSize()}px` : undefined}
303
+ class={hp?.class}
180
304
  class:dt-th-sortable={isSortable}
181
- onclick={isSortable ? () => header.column.toggleSorting() : undefined}
305
+ tabindex={isSortable ? 0 : undefined}
306
+ onclick={isSortable ? (e) => toggleSort(header, e) : undefined}
307
+ onkeydown={isSortable ? (e) => onHeaderKeydown(header, e) : undefined}
308
+ oncontextmenu={hp?.oncontextmenu}
182
309
  aria-sort={dir === 'asc' ? 'ascending' : dir === 'desc' ? 'descending' : undefined}
183
310
  >
184
311
  <span class="dt-th-label">
@@ -212,11 +339,33 @@
212
339
  <tbody>
213
340
  {#if rows.length === 0}
214
341
  <tr>
215
- <td colspan={allColumns.length} class="dt-empty">No data</td>
342
+ {#if empty}
343
+ <td colspan={allColumns.length} class="dt-empty-custom">{@render empty()}</td>
344
+ {:else}
345
+ <td colspan={allColumns.length} class="dt-empty">No data</td>
346
+ {/if}
216
347
  </tr>
217
348
  {:else}
218
- {#each rows as row}
219
- <tr class:dt-row-selected={row.getIsSelected()}>
349
+ {#each rows as row, ri (row.id)}
350
+ {@const rp = rowProps?.(row.original, rowItem(row, ri, rows.length)) ?? {}}
351
+ {@const attrs = rowAttrs(rp)}
352
+ {@const expanded = rowIsExpanded(row)}
353
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
354
+ <tr
355
+ {...attrs}
356
+ class={rp.class}
357
+ class:dt-row-selected={row.getIsSelected()}
358
+ class:dt-row-clickable={!!rp.onclick || !!onRowClick || internalExpand}
359
+ class:dt-row-expanded={expanded}
360
+ data-row-id={getRowId ? row.id : undefined}
361
+ aria-expanded={expandedRow ? expanded : undefined}
362
+ tabindex={internalExpand ? (attrs.tabindex ?? 0) : attrs.tabindex}
363
+ onclick={hasRowHandlers ? (e) => onRowClickInternal(row, rp, e) : undefined}
364
+ onkeydown={internalExpand ? (e) => onRowKeydown(row, e) : undefined}
365
+ oncontextmenu={rp.oncontextmenu}
366
+ ondragover={rp.ondragover}
367
+ ondrop={rp.ondrop}
368
+ >
220
369
  {#each row.getVisibleCells() as cell}
221
370
  {#if cell.column.id === '__select'}
222
371
  <td class="dt-select-cell">
@@ -241,12 +390,16 @@
241
390
  {/if}
242
391
  {/each}
243
392
  </tr>
393
+ {#if expanded && expandedRow}
394
+ <tr class="dt-expanded-detail" data-detail-for={getRowId ? row.id : undefined}>
395
+ <td colspan={allColumns.length}>{@render expandedRow(row.original, { colspan: allColumns.length })}</td>
396
+ </tr>
397
+ {/if}
244
398
  {/each}
245
399
  {/if}
246
400
  </tbody>
247
401
  </table>
248
402
  </div>
249
-
250
403
  {#if paginated && pageCount > 1}
251
404
  <div class="dt-pagination">
252
405
  <span class="dt-page-info">
@@ -327,6 +480,14 @@
327
480
  background-color: color-mix(in oklch, var(--color-primary) 6%, transparent) !important;
328
481
  }
329
482
 
483
+ .dt-row-clickable {
484
+ cursor: pointer;
485
+ }
486
+ .dt-row-clickable:focus-visible {
487
+ outline: 2px solid var(--color-primary);
488
+ outline-offset: -2px;
489
+ }
490
+
330
491
  .dt-select-cell {
331
492
  width: 2.5rem;
332
493
  text-align: center;
@@ -1,4 +1,25 @@
1
1
  import type { DataTableProps } from './types.js';
2
- declare const DataTable: import("svelte").Component<DataTableProps<any>, {}, "">;
3
- type DataTable = ReturnType<typeof DataTable>;
2
+ declare function $$render<TData>(): {
3
+ props: DataTableProps<TData>;
4
+ exports: {};
5
+ bindings: "sorting" | "api";
6
+ slots: {};
7
+ events: {};
8
+ };
9
+ declare class __sveltets_Render<TData> {
10
+ props(): ReturnType<typeof $$render<TData>>['props'];
11
+ events(): ReturnType<typeof $$render<TData>>['events'];
12
+ slots(): ReturnType<typeof $$render<TData>>['slots'];
13
+ bindings(): "sorting" | "api";
14
+ exports(): {};
15
+ }
16
+ interface $$IsomorphicComponent {
17
+ new <TData>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<TData>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<TData>['props']>, ReturnType<__sveltets_Render<TData>['events']>, ReturnType<__sveltets_Render<TData>['slots']>> & {
18
+ $$bindings?: ReturnType<__sveltets_Render<TData>['bindings']>;
19
+ } & ReturnType<__sveltets_Render<TData>['exports']>;
20
+ <TData>(internal: unknown, props: ReturnType<__sveltets_Render<TData>['props']> & {}): ReturnType<__sveltets_Render<TData>['exports']>;
21
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
22
+ }
23
+ declare const DataTable: $$IsomorphicComponent;
24
+ type DataTable<TData> = InstanceType<typeof DataTable<TData>>;
4
25
  export default DataTable;
@@ -2,4 +2,4 @@ export { default as DataTable } from './DataTable.svelte';
2
2
  export { flexRender } from './flexRender';
3
3
  export { columnResize } from './columnResize';
4
4
  export type { ColumnResizeOptions } from './columnResize';
5
- export type { DataTableProps, SelectionEvent, ColumnDef, Row } from './types';
5
+ export type { DataTableProps, DataTableApi, DataTableRowProps, DataTableHeaderProps, DataTableSortEvent, DataTableVariant, GroupedItem, SelectionEvent, ColumnDef, Row, SortingState } from './types';
@@ -1,5 +1,63 @@
1
- import type { ColumnDef, Row } from '@tanstack/table-core';
2
- export type { ColumnDef, Row };
1
+ import type { Snippet } from 'svelte';
2
+ import type { ColumnDef, Row, SortingState } from '@tanstack/table-core';
3
+ export type { ColumnDef, Row, SortingState };
4
+ /**
5
+ * One entry in the table's render list. Today DataTable renders flat rows, so
6
+ * every item is `kind: 'row'` with `depth: 0`; the `group` variant is the shape
7
+ * the grouped render list (`groupRows`, design doc
8
+ * jat/docs/design/datatable-virtualisation-group-rows.md §3.2) will produce, so
9
+ * `rowProps` callers written now keep working when grouping lands.
10
+ */
11
+ export type GroupedItem<T = any> = {
12
+ kind: 'group';
13
+ key: string;
14
+ path: string[];
15
+ depth: number;
16
+ label: string;
17
+ rows: T[];
18
+ count: number;
19
+ collapsed: boolean;
20
+ } | {
21
+ kind: 'row';
22
+ row: T;
23
+ id: string;
24
+ depth: number;
25
+ groupPath: string[];
26
+ position: 'first' | 'middle' | 'last' | 'only';
27
+ };
28
+ /** What `rowProps` may put on a body `<tr>`. */
29
+ export interface DataTableRowProps {
30
+ class?: string;
31
+ /** Extra attributes (`data-*`, `aria-*`, `tabindex`…). `false` omits the attribute. */
32
+ attrs?: Record<string, string | number | boolean | undefined>;
33
+ onclick?: (e: MouseEvent) => void;
34
+ oncontextmenu?: (e: MouseEvent) => void;
35
+ ondragover?: (e: DragEvent) => void;
36
+ ondrop?: (e: DragEvent) => void;
37
+ }
38
+ /** What `headerProps` may put on a header `<th>`. */
39
+ export interface DataTableHeaderProps {
40
+ class?: string;
41
+ oncontextmenu?: (e: MouseEvent) => void;
42
+ }
43
+ /** Context passed alongside a sorting change: which header, and whether it was a shift-click. */
44
+ export interface DataTableSortEvent {
45
+ field: string;
46
+ shiftKey: boolean;
47
+ }
48
+ /** Imperative handle, exposed through `bind:api`. */
49
+ export interface DataTableApi {
50
+ /** Scroll a row into view (jumping to its page first when paginated). */
51
+ scrollToRow(id: string, align?: 'auto' | 'start' | 'center'): void;
52
+ /** Row ids in render order across ALL pages — what keyboard nav should walk. */
53
+ orderedRowIds(): string[];
54
+ }
55
+ /**
56
+ * Table skin. `zebra` (default) = DaisyUI `table table-zebra`; `plain` drops the
57
+ * striping; `unstyled` drops the DaisyUI table classes entirely so the host's
58
+ * own CSS owns the look.
59
+ */
60
+ export type DataTableVariant = 'zebra' | 'plain' | 'unstyled';
3
61
  export interface DataTableProps<TData = any> {
4
62
  /** Row data array */
5
63
  data: TData[];
@@ -19,8 +77,53 @@ export interface DataTableProps<TData = any> {
19
77
  loadingRows?: number;
20
78
  /** Emitted when the selection set changes */
21
79
  onSelectionChange?: (rows: TData[]) => void;
22
- /** Additional table class */
80
+ /** Additional class on the root wrapper */
23
81
  class?: string;
82
+ /**
83
+ * Stable row identity (e.g. a task id). Keys selection, expansion and `api`
84
+ * by id instead of index, so a poll that reorders `data` keeps state on the
85
+ * right rows. Also renders `data-row-id` on each body `<tr>`.
86
+ */
87
+ getRowId?: (row: TData) => string;
88
+ /** Controlled sort state — `bind:sorting`. Unbound, the table owns it. */
89
+ sorting?: SortingState;
90
+ /** Starting sort state when `sorting` is not supplied (e.g. restored from localStorage). */
91
+ initialSorting?: SortingState;
92
+ /**
93
+ * true = DataTable does not sort; pass `data` already sorted and react to
94
+ * header clicks through `onSortingChange`. Headers still show the sort state.
95
+ */
96
+ manualSorting?: boolean;
97
+ /** Fires on every header-driven sort change — persist it here. */
98
+ onSortingChange?: (next: SortingState, ev: DataTableSortEvent) => void;
99
+ /** Per-row class / attributes / handlers for the body `<tr>`. */
100
+ rowProps?: (row: TData, item: GroupedItem<TData>) => DataTableRowProps;
101
+ /**
102
+ * Shorthand for `rowProps(...).onclick`. Not fired for clicks that land on an
103
+ * interactive child (input, button, link, select, textarea, label,
104
+ * contenteditable) — so the select checkbox and inline editors stay inert.
105
+ */
106
+ onRowClick?: (row: TData, e: MouseEvent) => void;
107
+ /** Per-column class / context-menu handler for the header `<th>`. */
108
+ headerProps?: (columnId: string) => DataTableHeaderProps;
109
+ /**
110
+ * Detail row rendered under an expanded row, in one full-width cell.
111
+ * Without `isExpanded`, the table owns expansion: a row click (or Enter /
112
+ * Space on the focused row) toggles it.
113
+ */
114
+ expandedRow?: Snippet<[TData, {
115
+ colspan: number;
116
+ }]>;
117
+ /** Controlled expansion — when given, the caller owns which rows are open. */
118
+ isExpanded?: (row: TData) => boolean;
119
+ /** Replaces the default "No data" cell when `data` is empty. */
120
+ empty?: Snippet;
121
+ /** `zebra` (default) | `plain` | `unstyled` — see DataTableVariant. */
122
+ variant?: DataTableVariant;
123
+ /** Extra class on the `<table>` element (e.g. `table-sm`). */
124
+ tableClass?: string;
125
+ /** Imperative handle — `bind:api`. */
126
+ api?: DataTableApi;
24
127
  }
25
128
  export interface SelectionEvent<TData> {
26
129
  rows: TData[];
package/dist/index.d.ts CHANGED
@@ -25,7 +25,7 @@ export { default as Flythrough } from './components/Flythrough.svelte';
25
25
  export { default as LessonEmbed } from './components/LessonEmbed.svelte';
26
26
  export type { Collection, CollectionCut, CollectionBeat, } from './components/LessonEmbed.svelte';
27
27
  export { buildLinkedSession, buildLinkedSessionFromWork, rawTypeToState, sampleLinkedSession, SAMPLE_TRANSCRIPT_LINES, } from './components/session-nav/transcriptModel';
28
- export type { ItemKind, TranscriptItem, FileTouchedEntry, SignalNode, LinkedSession, WorkBlockClient, WorkTranscriptItemClient, } from './components/session-nav/transcriptModel';
28
+ export type { ItemKind, TranscriptItem, FileTouchedEntry, SignalNode, LinkedSession, WorkBlockClient, WorkTranscriptItemClient, MarkerTone, TimelineMarkerSpec, TimelineMarker, } from './components/session-nav/transcriptModel';
29
29
  export { default as UserAvatar } from './components/UserAvatar.svelte';
30
30
  export { default as SidebarUserFooter } from './components/SidebarUserFooter.svelte';
31
31
  export { default as TaskTypeIcon } from './components/TaskTypeIcon.svelte';
package/dist/index.js CHANGED
@@ -44,7 +44,8 @@ export { qrMatrix, qrCanvas } from './video/advisors/qr';
44
44
  // LessonEmbed renders a demo-assemble collection.json (cut picker + player).
45
45
  export { default as Flythrough } from './components/Flythrough.svelte';
46
46
  export { default as LessonEmbed } from './components/LessonEmbed.svelte';
47
- // Data models — Session transcript (from JAT IDE, jat-jj79f.16 G1)
47
+ // Data models — Session transcript (from JAT IDE, jat-jj79f.16 G1; resynced
48
+ // with the IDE's payload/timestamp/live-node/F13-marker model in jatui-ucmkz)
48
49
  export { buildLinkedSession, buildLinkedSessionFromWork, rawTypeToState, sampleLinkedSession, SAMPLE_TRANSCRIPT_LINES, } from './components/session-nav/transcriptModel';
49
50
  // Components — Universal
50
51
  export { default as UserAvatar } from './components/UserAvatar.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",