@joewinke/jatui 0.23.0 → 0.25.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 (36) hide show
  1. package/CLAUDE.md +32 -2
  2. package/dist/components/AudioRecorder.svelte +84 -304
  3. package/dist/components/AudioRecorder.svelte.d.ts +1 -7
  4. package/dist/components/BulkActionBar.svelte +40 -0
  5. package/dist/components/BulkActionBar.svelte.d.ts +9 -0
  6. package/dist/components/ConfirmDialog.svelte +39 -35
  7. package/dist/components/CopyButton.svelte +5 -2
  8. package/dist/components/FloatingActionBar.svelte +55 -1
  9. package/dist/components/FloatingActionBar.svelte.d.ts +8 -1
  10. package/dist/components/InputDialog.svelte +49 -38
  11. package/dist/components/KeyboardShortcutsOverlay.svelte +54 -0
  12. package/dist/components/KeyboardShortcutsOverlay.svelte.d.ts +30 -0
  13. package/dist/components/bulkActionBarKeys.d.ts +40 -0
  14. package/dist/components/bulkActionBarKeys.js +38 -0
  15. package/dist/components/linked-columns/LinkedColumns.svelte +10 -22
  16. package/dist/components/linked-columns/LinkedColumns.svelte.d.ts +1 -9
  17. package/dist/components/linked-columns/types.d.ts +9 -0
  18. package/dist/components/linked-columns/types.js +1 -0
  19. package/dist/components/replay/ChapterTimeline.svelte +43 -25
  20. package/dist/components/replay/ChapterTimeline.svelte.d.ts +26 -1
  21. package/dist/components/session-nav/transcriptModel.d.ts +46 -0
  22. package/dist/components/session-nav/transcriptModel.js +143 -19
  23. package/dist/components/toast/ToastItem.svelte +47 -2
  24. package/dist/index.d.ts +8 -5
  25. package/dist/index.js +7 -2
  26. package/dist/stores/toast.d.ts +8 -0
  27. package/dist/stores/toast.js +2 -0
  28. package/dist/utils/audioRecorder.d.ts +28 -0
  29. package/dist/utils/audioRecorder.js +58 -0
  30. package/dist/utils/audioRecorderCore.d.ts +132 -0
  31. package/dist/utils/audioRecorderCore.js +447 -0
  32. package/dist/utils/clipboard.d.ts +25 -0
  33. package/dist/utils/clipboard.js +59 -0
  34. package/dist/utils/dateFormatters.d.ts +11 -2
  35. package/dist/utils/dateFormatters.js +22 -2
  36. package/package.json +1 -1
@@ -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"}\')',
@@ -151,7 +151,11 @@
151
151
  }
152
152
  }
153
153
 
154
- // DaisyUI alert class per type
154
+ // Collapsible details (toast.details)
155
+ let detailsOpen = $state(false);
156
+ const detailsId = $derived(`toast-details-${toast.id}`);
157
+
158
+ // DaisyUI alert class per type
155
159
  const alertClass: Record<string, string> = {
156
160
  info: 'alert-info',
157
161
  success: 'alert-success',
@@ -223,7 +227,40 @@
223
227
  ></span>
224
228
  {/if}
225
229
 
226
- <span class="flex-1 text-sm">{toast.message}</span>
230
+ <div class="flex-1 min-w-0">
231
+ <span class="text-sm">{toast.message}</span>
232
+
233
+ {#if toast.details}
234
+ <button
235
+ type="button"
236
+ class="toast-details-toggle btn btn-ghost btn-xs mt-0.5 h-auto min-h-0 gap-1 px-0 font-semibold opacity-70"
237
+ aria-expanded={detailsOpen}
238
+ aria-controls={detailsId}
239
+ onclick={() => (detailsOpen = !detailsOpen)}
240
+ >
241
+ <svg
242
+ xmlns="http://www.w3.org/2000/svg"
243
+ class="size-3"
244
+ class:rotate-90={detailsOpen}
245
+ viewBox="0 0 24 24"
246
+ fill="none"
247
+ stroke="currentColor"
248
+ stroke-width="2.5"
249
+ stroke-linecap="round"
250
+ stroke-linejoin="round"
251
+ >
252
+ <path d="m9 18 6-6-6-6" />
253
+ </svg>
254
+ Details
255
+ </button>
256
+ {#if detailsOpen}
257
+ <pre
258
+ id={detailsId}
259
+ class="toast-details mt-1 max-h-40 overflow-auto rounded border border-current/20 bg-base-content/5 p-2 font-mono text-xs whitespace-pre-wrap break-words"
260
+ >{toast.details}</pre>
261
+ {/if}
262
+ {/if}
263
+ </div>
227
264
 
228
265
  {#if toast.onUndo}
229
266
  <button
@@ -274,7 +311,15 @@
274
311
  to { transform: scaleX(0); }
275
312
  }
276
313
 
314
+ .toast-details-toggle svg {
315
+ transition: transform var(--motion-fast) var(--motion-glide);
316
+ }
317
+
277
318
  @media (prefers-reduced-motion: reduce) {
319
+ .toast-details-toggle svg {
320
+ transition: none;
321
+ }
322
+
278
323
  .toast-progress {
279
324
  animation: none;
280
325
  }
package/dist/index.d.ts CHANGED
@@ -6,9 +6,9 @@ export { collapse } from './actions/collapse';
6
6
  export type { CollapseOptions } from './actions/collapse';
7
7
  export { default as Collapse } from './components/Collapse.svelte';
8
8
  export { default as LinkedColumns } from './components/linked-columns/LinkedColumns.svelte';
9
- export type { ColumnLink } from './components/linked-columns/LinkedColumns.svelte';
9
+ export type { ColumnLink } from './components/linked-columns/types.js';
10
10
  export { default as ChapterTimeline } from './components/replay/ChapterTimeline.svelte';
11
- export type { ChapterBlock } from './components/replay/ChapterTimeline.svelte';
11
+ export type { ChapterBlock, ChapterStateVisual } from './components/replay/ChapterTimeline.svelte';
12
12
  export { default as TranscriptPlayer } from './components/TranscriptPlayer.svelte';
13
13
  export type { TranscriptSegment, TranscriptBookmark, TranscriptChapter, TranscriptSpeakerTalkTime, } from './components/TranscriptPlayer.svelte';
14
14
  export { default as VideoPlayer } from './components/VideoPlayer.svelte';
@@ -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';
@@ -83,7 +83,9 @@ export { default as ResizablePanel } from './components/ResizablePanel.svelte';
83
83
  export { default as VoicePlayer } from './components/VoicePlayer.svelte';
84
84
  export { default as SpeechForm } from './components/SpeechForm.svelte';
85
85
  export { default as AudioRecorder } from './components/AudioRecorder.svelte';
86
- export type { AudioRecordingResult } from './components/AudioRecorder.svelte';
86
+ export type { AudioRecordingResult } from './utils/audioRecorderCore';
87
+ export { createAudioRecorder } from './utils/audioRecorder';
88
+ export type { AudioRecorder, AudioRecorderCore, AudioRecorderError, AudioRecorderErrorKind, AudioRecorderFrame, AudioRecorderMode, AudioRecorderOptions, AudioRecorderPhase, AudioRecorderSnapshot } from './utils/audioRecorderCore';
87
89
  export { default as ThemeSelector } from './components/ThemeSelector.svelte';
88
90
  export { default as Sparkline } from './components/Sparkline.svelte';
89
91
  export { default as BurndownChart } from './components/BurndownChart.svelte';
@@ -128,7 +130,7 @@ export type { Milestone, MilestoneStatus } from './types/milestone';
128
130
  export type { LocationType, BookingStatus, OverrideType, Host, EventType, WeeklyAvailability, DateOverride, Booking, BookingAttendee, TimeSlot, BookingFormData, AvailabilityCounts } from './types/booking';
129
131
  export { DAYS_OF_WEEK, DAYS_SHORT, LOCATION_LABELS, LOCATION_ICONS } from './types/booking';
130
132
  export type { ImageVariantSpec, ImageProcessingSpec, VariantResult, VariantState, } from './types/smartImageEditor';
131
- export { normalizeTimestamp, parseTimestamp, formatRelativeTime, formatFullDate, formatShortDate, formatLastActivity, formatDate, getTimeSinceMs, getTimeSinceMinutes, isWithinMinutes, formatDateTime, taskAge, pacingColor, type PacingLevel, type PacingResult, } from './utils/dateFormatters';
133
+ export { normalizeTimestamp, parseTimestamp, formatRelativeTime, formatFullDate, formatShortDate, formatLastActivity, formatDate, getTimeSinceMs, getTimeSinceMinutes, isWithinMinutes, formatDateTime, taskAge, pacingColor, formatPacingDuration, type PacingLevel, type PacingResult, type PacingOptions, } from './utils/dateFormatters';
132
134
  export { statusColor, priorityColor, typeIcon, statusLabel, } from './utils/taskUtils';
133
135
  export { stripPhoneNumber, formatPhoneNumber, isCompletePhoneNumber, isValidPhoneNumber, updatePhoneInput } from './utils/phoneFormat';
134
136
  export { toE164, formatPhoneForDisplay, isValidPhone, listCountries, DEFAULT_COUNTRY, PRIORITY_COUNTRIES, type CountryOption, } from './utils/phone';
@@ -198,6 +200,7 @@ export type { GlideOrigin, GlideParams } from './transitions/glide';
198
200
  export { themeWipe } from './utils/themeWipe';
199
201
  export { viewTransition } from './utils/viewTransition';
200
202
  export { play as playSound, registerSound, isSoundEnabled, setSoundEnabled } from './utils/sound';
203
+ export { copyText, readClipboardText } from './utils/clipboard';
201
204
  export { splitText } from './utils/splitText';
202
205
  export type { SplitBy, SplitTextOptions, SplitTextResult } from './utils/splitText';
203
206
  export { default as ToastHost } from './components/toast/ToastHost.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';
@@ -102,6 +103,8 @@ export { default as ResizablePanel } from './components/ResizablePanel.svelte';
102
103
  export { default as VoicePlayer } from './components/VoicePlayer.svelte';
103
104
  export { default as SpeechForm } from './components/SpeechForm.svelte';
104
105
  export { default as AudioRecorder } from './components/AudioRecorder.svelte';
106
+ // Headless audio capture — the factory behind AudioRecorder (jatui-is2ow.8)
107
+ export { createAudioRecorder } from './utils/audioRecorder';
105
108
  // Components — from Headcount
106
109
  export { default as ThemeSelector } from './components/ThemeSelector.svelte';
107
110
  export { default as Sparkline } from './components/Sparkline.svelte';
@@ -137,7 +140,7 @@ export { default as AvailabilityModal } from './components/AvailabilityModal.sve
137
140
  export { default as BookingForm } from './components/BookingForm.svelte';
138
141
  export { DAYS_OF_WEEK, DAYS_SHORT, LOCATION_LABELS, LOCATION_ICONS } from './types/booking';
139
142
  // Utilities — date formatting
140
- export { normalizeTimestamp, parseTimestamp, formatRelativeTime, formatFullDate, formatShortDate, formatLastActivity, formatDate, getTimeSinceMs, getTimeSinceMinutes, isWithinMinutes, formatDateTime, taskAge, pacingColor, } from './utils/dateFormatters';
143
+ export { normalizeTimestamp, parseTimestamp, formatRelativeTime, formatFullDate, formatShortDate, formatLastActivity, formatDate, getTimeSinceMs, getTimeSinceMinutes, isWithinMinutes, formatDateTime, taskAge, pacingColor, formatPacingDuration, } from './utils/dateFormatters';
141
144
  // Utilities — task display helpers
142
145
  export { statusColor, priorityColor, typeIcon, statusLabel, } from './utils/taskUtils';
143
146
  // Utilities — phone formatting (legacy US-only)
@@ -224,6 +227,8 @@ export { themeWipe } from './utils/themeWipe';
224
227
  export { viewTransition } from './utils/viewTransition';
225
228
  // Sound kit (functional feedback, opt-in)
226
229
  export { play as playSound, registerSound, isSoundEnabled, setSoundEnabled } from './utils/sound';
230
+ // Clipboard (secure-context aware: execCommand fallback for plain-HTTP origins)
231
+ export { copyText, readClipboardText } from './utils/clipboard';
227
232
  // Text utilities
228
233
  export { splitText } from './utils/splitText';
229
234
  // Toast system
@@ -2,6 +2,12 @@ export type ToastType = 'info' | 'success' | 'warning' | 'error';
2
2
  export type ToastOptions = {
3
3
  type?: ToastType;
4
4
  message: string;
5
+ /** Extended detail text (e.g. a stack trace or error chain) — rendered by
6
+ * ToastHost behind a collapsible "Details" toggle. Multiline-safe. */
7
+ details?: string;
8
+ /** Arbitrary structured context carried on the Toast for programmatic
9
+ * access (getToasts()); not rendered by ToastHost. */
10
+ meta?: Record<string, unknown>;
5
11
  action?: {
6
12
  label: string;
7
13
  onClick: () => void;
@@ -13,6 +19,8 @@ export type Toast = {
13
19
  id: number;
14
20
  type: ToastType;
15
21
  message: string;
22
+ details?: string;
23
+ meta?: Record<string, unknown>;
16
24
  action?: {
17
25
  label: string;
18
26
  onClick: () => void;
@@ -15,6 +15,8 @@ export function showToast(options) {
15
15
  id,
16
16
  type: options.type ?? 'info',
17
17
  message: options.message,
18
+ details: options.details,
19
+ meta: options.meta,
18
20
  action: options.action,
19
21
  durationMs: options.durationMs ?? 5000,
20
22
  onUndo: options.onUndo
@@ -0,0 +1,28 @@
1
+ import type { AudioRecorderMode, AudioRecorderOptions, AudioRecorderPhase, AudioRecorderSnapshot } from './audioRecorderCore';
2
+ export type { AudioRecorderError, AudioRecorderErrorKind, AudioRecorderFrame, AudioRecorderMode, AudioRecorderOptions, AudioRecorderPhase, AudioRecordingResult } from './audioRecorderCore';
3
+ export interface AudioRecorder {
4
+ /** False when the platform has no getUserMedia/MediaRecorder (SSR, Node). */
5
+ readonly supported: boolean;
6
+ readonly mode: AudioRecorderMode;
7
+ readonly phase: AudioRecorderPhase;
8
+ /** True while recording is suspended (pause()). */
9
+ readonly paused: boolean;
10
+ /** 0–100 live mic level. */
11
+ readonly level: number;
12
+ /** Bytes captured so far. */
13
+ readonly sizeBytes: number;
14
+ /** Pause-adjusted wall-clock elapsed ms. */
15
+ readonly elapsedMs: number;
16
+ /** Last error, or null. */
17
+ readonly error: AudioRecorderSnapshot['error'];
18
+ start(): Promise<void>;
19
+ stop(): void;
20
+ pause(): void;
21
+ resume(): void;
22
+ cancel(): void;
23
+ /** Return a finished/errored recorder to idle so the host can record again. */
24
+ reset(): void;
25
+ /** Final teardown — releases mic + timers. Call from onDestroy. */
26
+ destroy(): void;
27
+ }
28
+ export declare function createAudioRecorder(options?: AudioRecorderOptions): AudioRecorder;
@@ -0,0 +1,58 @@
1
+ /* audioRecorder.svelte.js generated by Svelte v5.53.11 */
2
+ import * as $ from 'svelte/internal/client';
3
+ import { createAudioRecorderCore } from './audioRecorderCore';
4
+
5
+ export function createAudioRecorder(options = {}) {
6
+ const core = createAudioRecorderCore(options);
7
+ const snap = $.proxy(core.snapshot());
8
+
9
+ core.subscribe((s) => {
10
+ snap.phase = s.phase;
11
+ snap.paused = s.paused;
12
+ snap.level = s.level;
13
+ snap.sizeBytes = s.sizeBytes;
14
+ snap.elapsedMs = s.elapsedMs;
15
+ snap.error = s.error;
16
+ });
17
+
18
+ return {
19
+ get supported() {
20
+ return core.supported;
21
+ },
22
+
23
+ get mode() {
24
+ return core.mode;
25
+ },
26
+
27
+ get phase() {
28
+ return snap.phase;
29
+ },
30
+
31
+ get paused() {
32
+ return snap.paused;
33
+ },
34
+
35
+ get level() {
36
+ return snap.level;
37
+ },
38
+
39
+ get sizeBytes() {
40
+ return snap.sizeBytes;
41
+ },
42
+
43
+ get elapsedMs() {
44
+ return snap.elapsedMs;
45
+ },
46
+
47
+ get error() {
48
+ return snap.error;
49
+ },
50
+ start: () => core.start(),
51
+ stop: () => core.stop(),
52
+ pause: () => core.pause(),
53
+ resume: () => core.resume(),
54
+ cancel: () => core.cancel(),
55
+ reset: () => core.reset(),
56
+ destroy: () => core.destroy()
57
+ };
58
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * createAudioRecorderCore — headless audio-capture primitive (jatui-is2ow.8).
3
+ *
4
+ * The capture pipeline of `AudioRecorder.svelte` (permission request, MIME
5
+ * negotiation, MediaRecorder state machine, pause-aware wall-clock timing,
6
+ * AnalyserNode level meter, blob out) factored into a dependency-injected,
7
+ * DOM-free core so any host can drive it from its own UI — mic buttons,
8
+ * push-to-talk keys, overlays — without importing the component.
9
+ *
10
+ * NO transcription here by design: hosts that transcribe (the jat IDE's
11
+ * VoiceMicButton/VoiceInput, wave 3.5 of the adoption audit jat-1alfp) keep
12
+ * their POST-local capture shape and consume `onStop`'s blob payload.
13
+ *
14
+ * Two layers:
15
+ * • this plain module — every knob injectable via `deps` (tests drive a fake
16
+ * MediaRecorder backend in node:test, no DOM);
17
+ * • `audioRecorder.svelte.ts` — the `createAudioRecorder()` rune wrapper
18
+ * that wires the core's snapshot into `$state` so Svelte 5 templates react
19
+ * to it. Components and app code import the wrapper; tests import this.
20
+ *
21
+ * Modes:
22
+ * • `'toggle'` (default) — click-style: `start()` begins, `stop()` ends.
23
+ * • `'push'` — hold-to-talk: the host wires pointerdown → `start()`,
24
+ * pointerup → `stop()`. A `stop()` that arrives while the mic stream is
25
+ * still being acquired (a very quick tap) CANCELS — no blob, no error —
26
+ * matching the jat IDE VoiceMicButton's pointerup-before-stream behavior.
27
+ *
28
+ * Phases: idle → starting → recording ⇄(pause/resume) → processing → done,
29
+ * with `cancel()` from recording returning straight to idle and `reset()`
30
+ * returning a finished/errored recorder to idle so the host can go again.
31
+ *
32
+ * Timer discipline (lesson from MediaCapture, mos-ow4q4.37): elapsed time is
33
+ * derived from the wall clock minus accumulated pause, never an incremented
34
+ * counter — a throttled background tab can't freeze it.
35
+ */
36
+ export type AudioRecorderPhase = 'idle' | 'starting' | 'recording' | 'processing' | 'done';
37
+ export type AudioRecorderMode = 'toggle' | 'push';
38
+ /** Why a recording attempt failed. */
39
+ export type AudioRecorderErrorKind = 'unsupported' | 'denied' | 'no-mic' | 'start-failed' | 'too-short' | 'handler-failed';
40
+ export interface AudioRecorderError {
41
+ kind: AudioRecorderErrorKind;
42
+ message: string;
43
+ }
44
+ /** What a finished recording hands back to the host — mirrors MediaCaptureResult's shape for the audio kind. */
45
+ export interface AudioRecordingResult {
46
+ blob: Blob;
47
+ mimeType: string;
48
+ durationSeconds: number;
49
+ sizeBytes: number;
50
+ }
51
+ /** One animation frame of analyser data, for hosts that render a visualization. */
52
+ export interface AudioRecorderFrame {
53
+ /** 0–100 RMS level (same value exposed reactively as `level`). */
54
+ level: number;
55
+ /** Frequency-domain bytes (from getByteFrequencyData). */
56
+ frequency: Uint8Array;
57
+ /** Time-domain bytes (from getByteTimeDomainData) — raw PCM-ish shape. */
58
+ time: Uint8Array;
59
+ }
60
+ export interface AudioRecorderOptions {
61
+ /** `'toggle'` (default) or `'push'` (hold-to-talk — see module header). */
62
+ mode?: AudioRecorderMode;
63
+ /** Auto-stop ceiling in ms. 0 (default) records until stopped manually. */
64
+ maxDurationMs?: number;
65
+ /** Fired once the mic stream is live and recording has started. */
66
+ onStart?: () => void;
67
+ onPause?: () => void;
68
+ onResume?: () => void;
69
+ /** Fired with the finished recording. May return a Promise — the recorder
70
+ * stays in "processing" until it settles, so hosts can upload/transcribe
71
+ * without the user re-triggering a recording. */
72
+ onStop?: (result: AudioRecordingResult) => void | Promise<void>;
73
+ /** Fired when the recording is cancelled — no blob is produced. */
74
+ onCancel?: () => void;
75
+ /** Fired on recorder errors (permission denied, no mic, start failure, …). */
76
+ onError?: (error: AudioRecorderError) => void;
77
+ /** Fired every animation frame while recording — analyser data for hosts
78
+ * that draw their own waveform/level UI. Not fired while paused. */
79
+ onFrame?: (frame: AudioRecorderFrame) => void;
80
+ }
81
+ /** Injectable platform bindings — defaults resolve from globalThis lazily at factory time, so Node (no MediaRecorder) degrades to `supported: false` instead of throwing at import. */
82
+ export interface AudioRecorderDeps {
83
+ getUserMedia?: (constraints?: MediaStreamConstraints) => Promise<MediaStream>;
84
+ MediaRecorder?: any;
85
+ AudioContext?: any;
86
+ requestAnimationFrame?: (cb: (t: number) => void) => number;
87
+ cancelAnimationFrame?: (id: number) => void;
88
+ setTimeout?: typeof setTimeout;
89
+ clearTimeout?: typeof clearTimeout;
90
+ setInterval?: typeof setInterval;
91
+ clearInterval?: typeof clearInterval;
92
+ /** Wall clock — injectable so tests can advance time deterministically. */
93
+ now?: () => number;
94
+ }
95
+ export interface AudioRecorderSnapshot {
96
+ phase: AudioRecorderPhase;
97
+ /** True while recording is suspended (pause()). */
98
+ paused: boolean;
99
+ /** 0–100 live mic level. */
100
+ level: number;
101
+ /** Bytes captured so far. */
102
+ sizeBytes: number;
103
+ /** Pause-adjusted wall-clock elapsed ms. */
104
+ elapsedMs: number;
105
+ /** Last error, or null. */
106
+ error: AudioRecorderError | null;
107
+ }
108
+ export interface AudioRecorderCore {
109
+ /** False when the platform has no getUserMedia/MediaRecorder (SSR, Node). */
110
+ readonly supported: boolean;
111
+ readonly mode: AudioRecorderMode;
112
+ /** Current reactive-independent snapshot (the rune wrapper copies this into $state). */
113
+ snapshot(): AudioRecorderSnapshot;
114
+ /** Subscribe to snapshot changes. Returns an unsubscribe fn. */
115
+ subscribe(fn: (snapshot: AudioRecorderSnapshot) => void): () => void;
116
+ start(): Promise<void>;
117
+ stop(): void;
118
+ pause(): void;
119
+ resume(): void;
120
+ cancel(): void;
121
+ /** Return a finished/errored recorder to idle so the host can record again. */
122
+ reset(): void;
123
+ /** Final teardown — releases mic + timers. Safe to call from any phase. */
124
+ destroy(): void;
125
+ }
126
+ /** First supported recording MIME type, or '' when none are. */
127
+ export declare function pickAudioMimeType(isSupported?: (type: string) => boolean): string;
128
+ /** RMS of the time-domain data scaled to the recorder's 0–100 meter range. */
129
+ export declare function computeAudioLevel(timeData: ArrayLike<number> | null): number;
130
+ /** Elapsed from the wall clock, pause-adjusted — see module header note. */
131
+ export declare function computeElapsedMs(startedAt: number, pausedTotalMs: number, pauseStartedAt: number, now: number): number;
132
+ export declare function createAudioRecorderCore(options?: AudioRecorderOptions, deps?: AudioRecorderDeps): AudioRecorderCore;