@joewinke/jatui 0.35.0 → 0.36.2

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.
@@ -2,9 +2,10 @@ export type { ListenSegment, ListenChapter, ListenVoice, ListenSegmentPage, List
2
2
  /**
3
3
  * ListenControl — "listen to this page" as one compact control.
4
4
  *
5
- * A small pill that expands into a playback bar: play/pause, −15s/+15s and
6
- * previous/next sentence, speed (0.75–2×), a voice picker, a document-wide
7
- * progress bar with chapter/section ticks, time, close. Built for hosts
5
+ * A small pill that expands into a two-row playback bar: play/pause, the
6
+ * current sentence, one speed (0.75–2×) + voice menu, close; then
7
+ * previous/next sentence, −15s/+15s, a document-wide progress bar with
8
+ * chapter/section ticks, and time. Built for hosts
8
9
  * that synthesize audio per sentence (TTS): the host supplies an async
9
10
  * SEGMENT SOURCE — sentences/paragraphs with ids → audio URLs — and the
10
11
  * player starts on the first chunk while the rest synthesizes, hatching
@@ -22,6 +23,14 @@ export type { ListenSegment, ListenChapter, ListenVoice, ListenSegmentPage, List
22
23
  * reports them) lay the timeline out immediately; unknown ones are
23
24
  * measured on load and ESTIMATED until then, self-correcting as they land.
24
25
  *
26
+ * ── Layout: two rows (jatui-zl9dp) ──────────────────────────────────────
27
+ * Row 1 is what you're hearing: play/pause, the chapter chip + current
28
+ * sentence (it gets ALL the remaining width), ONE settings pill folding
29
+ * speed + voice into a small menu, close. Row 2 is where you are: prev
30
+ * sentence, −15s, +15s, next sentence, then the progress bar and the
31
+ * clock. The bar is `box-sizing: border-box` + `max-width: 100%` with
32
+ * `min-width: 0` down the flex chain, so it never overflows its host.
33
+ *
25
34
  * The transport row borrows VideoPlayer's `kind="audio"` / `transport=
26
35
  * "band"` shape (round primary play, ghost transport buttons, tabular
27
36
  * clock) and reuses its pure time formatting (`video/time.ts`); the
@@ -21,12 +21,28 @@
21
21
  max?: number;
22
22
  /** Tile size. Default 'md'. */
23
23
  size?: 'xs' | 'sm' | 'md' | 'lg';
24
- /** Show remove (×) buttons on hover; requires `onRemove`. */
24
+ /** Show remove (×) buttons; requires `onRemove`. Visibility follows `removeMode`. */
25
25
  removable?: boolean;
26
+ /**
27
+ * When the remove × is visible. 'hover' (default) reveals it on
28
+ * hover/focus and keeps it visible on coarse (touch) pointers — a
29
+ * hover-only control is unusable on touch (the ContextMenuTrigger
30
+ * pattern). 'always' pins it visible for touch-first flows.
31
+ */
32
+ removeMode?: 'hover' | 'always';
26
33
  /** Show the dashed add tile at the end; requires `onAdd`. */
27
34
  addable?: boolean;
28
35
  /** Tooltip / aria-label for the add tile. Default 'Add items'. */
29
36
  addLabel?: string;
37
+ /**
38
+ * Tailwind grid-cols classes for a responsive tile grid, e.g.
39
+ * 'grid-cols-2 sm:grid-cols-4'. When set, the root renders as a CSS grid
40
+ * (`grid {gridCols} gap-2`) instead of the fixed flex-wrap strip, and
41
+ * tiles stretch to fill their column (height still follows `size`).
42
+ */
43
+ gridCols?: string;
44
+ /** Extra classes for the root container, composed after the layout classes. */
45
+ class?: string;
30
46
  /** Fired when a thumbnail is clicked. */
31
47
  onSelect?: (item: ThumbnailItem) => void;
32
48
  /** Fired when a remove (×) button is clicked. */
@@ -46,12 +62,15 @@
46
62
  max = 8,
47
63
  size = 'md',
48
64
  removable = false,
65
+ removeMode = 'hover',
49
66
  addable = false,
50
67
  addLabel = 'Add items',
68
+ gridCols,
51
69
  onSelect,
52
70
  onRemove,
53
71
  onAdd,
54
- cell
72
+ cell,
73
+ class: className = ''
55
74
  }: Props = $props();
56
75
 
57
76
  const sizeClasses: Record<NonNullable<Props['size']>, string> = {
@@ -63,18 +82,26 @@
63
82
 
64
83
  const visible = $derived(items.slice(0, max));
65
84
  const overflow = $derived(Math.max(0, items.length - max));
85
+ /** Grid mode when the caller supplies grid-cols classes; tiles fill their column. */
86
+ const isGrid = $derived(Boolean(gridCols));
87
+ const fillClass = $derived(isGrid ? ' w-full' : '');
88
+ const removeVisibility = $derived(
89
+ removeMode === 'always'
90
+ ? 'opacity-100'
91
+ : 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100'
92
+ );
66
93
 
67
94
  function shortLabel(alt: string): string {
68
95
  return alt.length > 14 ? `${alt.slice(0, 14)}…` : alt;
69
96
  }
70
97
  </script>
71
98
 
72
- <div class="flex flex-wrap items-center gap-2">
99
+ <div class="{isGrid ? 'grid gap-2' : 'flex flex-wrap items-center gap-2'} {gridCols ?? ''} {className}">
73
100
  {#each visible as item (item.id)}
74
101
  <div class="group relative">
75
102
  <button
76
103
  type="button"
77
- class="{sizeClasses[size]} cursor-pointer overflow-hidden rounded border transition-all duration-200
104
+ class="{sizeClasses[size]}{fillClass} cursor-pointer overflow-hidden rounded border transition-all duration-200
78
105
  {removable ? 'border-2 border-accent' : 'border-base-300 hover:border-primary'}"
79
106
  onclick={() => onSelect?.(item)}
80
107
  title={item.alt}
@@ -96,7 +123,7 @@
96
123
  {#if removable && onRemove}
97
124
  <button
98
125
  type="button"
99
- class="absolute -top-1 -right-1 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-error text-error-content opacity-0 transition-opacity group-hover:opacity-100"
126
+ class="absolute -top-1 -right-1 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-error text-error-content {removeVisibility}"
100
127
  onclick={(e) => {
101
128
  e.stopPropagation();
102
129
  onRemove(item);
@@ -114,7 +141,7 @@
114
141
 
115
142
  {#if overflow > 0}
116
143
  <div
117
- class="{sizeClasses[size]} flex items-center justify-center rounded border border-base-300 bg-base-200"
144
+ class="{sizeClasses[size]}{fillClass} flex items-center justify-center rounded border border-base-300 bg-base-200"
118
145
  title="{overflow} more item{overflow === 1 ? '' : 's'}"
119
146
  >
120
147
  <span class="text-xs opacity-70">+{overflow}</span>
@@ -124,7 +151,7 @@
124
151
  {#if addable && onAdd}
125
152
  <button
126
153
  type="button"
127
- class="{sizeClasses[size]} flex scale-90 cursor-pointer items-center justify-center rounded border-2 border-dashed border-primary/30 bg-primary/5 transition-all duration-200 hover:border-primary/50 hover:bg-primary/10"
154
+ class="{sizeClasses[size]}{fillClass} flex scale-90 cursor-pointer items-center justify-center rounded border-2 border-dashed border-primary/30 bg-primary/5 transition-all duration-200 hover:border-primary/50 hover:bg-primary/10"
128
155
  onclick={onAdd}
129
156
  title={addLabel}
130
157
  aria-label={addLabel}
@@ -18,12 +18,28 @@ interface Props {
18
18
  max?: number;
19
19
  /** Tile size. Default 'md'. */
20
20
  size?: 'xs' | 'sm' | 'md' | 'lg';
21
- /** Show remove (×) buttons on hover; requires `onRemove`. */
21
+ /** Show remove (×) buttons; requires `onRemove`. Visibility follows `removeMode`. */
22
22
  removable?: boolean;
23
+ /**
24
+ * When the remove × is visible. 'hover' (default) reveals it on
25
+ * hover/focus and keeps it visible on coarse (touch) pointers — a
26
+ * hover-only control is unusable on touch (the ContextMenuTrigger
27
+ * pattern). 'always' pins it visible for touch-first flows.
28
+ */
29
+ removeMode?: 'hover' | 'always';
23
30
  /** Show the dashed add tile at the end; requires `onAdd`. */
24
31
  addable?: boolean;
25
32
  /** Tooltip / aria-label for the add tile. Default 'Add items'. */
26
33
  addLabel?: string;
34
+ /**
35
+ * Tailwind grid-cols classes for a responsive tile grid, e.g.
36
+ * 'grid-cols-2 sm:grid-cols-4'. When set, the root renders as a CSS grid
37
+ * (`grid {gridCols} gap-2`) instead of the fixed flex-wrap strip, and
38
+ * tiles stretch to fill their column (height still follows `size`).
39
+ */
40
+ gridCols?: string;
41
+ /** Extra classes for the root container, composed after the layout classes. */
42
+ class?: string;
27
43
  /** Fired when a thumbnail is clicked. */
28
44
  onSelect?: (item: ThumbnailItem) => void;
29
45
  /** Fired when a remove (×) button is clicked. */
package/dist/index.d.ts CHANGED
@@ -93,7 +93,7 @@ export { default as ResizablePanel } from './components/ResizablePanel.svelte';
93
93
  export { default as VoicePlayer } from './components/VoicePlayer.svelte';
94
94
  export { default as SpeechForm } from './components/SpeechForm.svelte';
95
95
  export { default as AudioRecorder } from './components/AudioRecorder.svelte';
96
- export type { AudioRecordingResult } from './utils/audioRecorderCore';
96
+ export type { AudioRecordingResult, AudioRecorderChunk } from './utils/audioRecorderCore';
97
97
  export { createAudioRecorder } from './utils/audioRecorder';
98
98
  export type { AudioRecorder, AudioRecorderCore, AudioRecorderError, AudioRecorderErrorKind, AudioRecorderFrame, AudioRecorderMode, AudioRecorderOptions, AudioRecorderPhase, AudioRecorderSnapshot } from './utils/audioRecorderCore';
99
99
  export { default as ThemeSelector } from './components/ThemeSelector.svelte';
@@ -172,7 +172,8 @@ export { default as PipelineColumn } from './components/pipeline/PipelineColumn.
172
172
  export { default as PipelineCard } from './components/pipeline/PipelineCard.svelte';
173
173
  export type { PipelineStage, StageColors, PipelineItem, PipelineMetric, StageMetrics, PipelineViewMode, PipelineTableColumn, PipelineDragConfig, PipelineConfig, PipelineStageChangeEvent, PipelineItemClickEvent } from './types/pipeline';
174
174
  export { DEFAULT_DRAG_CONFIG, DEFAULT_PIPELINE_CONFIG } from './types/pipeline';
175
- export { parseMentions, renderMentionsAsHTML, createMentionString, extractPlainText, findMentionAtCursor, findPartialMention, replaceMentionInContent, escapeHTML, countMentions, setMentionUrlGenerator } from './utils/mentionParser';
175
+ export { parseMentions, renderMentionsAsHTML, createMentionString, extractPlainText, findMentionAtCursor, findPartialMention, replaceMentionInContent, escapeHTML, countMentions, hasMentions, setMentionUrlGenerator, registerMentionType, extractAgentMentions, getUniqueMentionsByType, getMentionedUserIds, AGENT_MENTION_TYPE } from './utils/mentionParser';
176
+ export type { MentionMatch, MentionUrlGenerator, MentionTypeConfig } from './utils/mentionParser';
176
177
  export { default as CommentThread } from './components/CommentThread.svelte';
177
178
  export type { Comment, CommentEntityType, CommentVisibility, CommentCreateInput, CommentCallbacks } from './types/comments';
178
179
  export { COMMENT_ENTITY_TYPES, COMMENT_VISIBILITIES } from './types/comments';
@@ -187,6 +188,7 @@ export type { MarkupTool, Point, MarkupShape, ArrowShape, RectangleShape, Ellips
187
188
  export type { Annotation, AnnotationProfile, AnnotationThread, AnnotationCallbacks, } from './types/annotation';
188
189
  export { default as KeyboardShortcutsOverlay } from './components/KeyboardShortcutsOverlay.svelte';
189
190
  export type { KeyboardShortcut, SectionEntry as KeyboardShortcutSectionEntry, ShortcutSection as KeyboardShortcutSection, } from './components/KeyboardShortcutsOverlay.svelte';
191
+ export { keyboardShortcutHub, KEYBOARD_SHORTCUT_HUB_ID, type KeyboardShortcutHost, type ShortcutContribution, type ShortcutSectionLike, } from './utils/keyboardShortcutHub';
190
192
  export { createShortcutRegistry, getShortcutRegistry, formatShortcut, parseCombo as parseShortcutCombo, isTypingTarget, detectPlatform as detectShortcutPlatform, SIDEBAR_TOGGLE_KEY, normalizeCombo, comboFromEvent, validateCombo, localStorageBindingStore, BROWSER_RESERVED_COMBOS, } from './utils/keyboardShortcutRegistry';
191
193
  export type { ShortcutRegistry, ShortcutDefinition, ShortcutModifier, ShortcutPlatform, PushContextOptions as ShortcutContextOptions, RegistrySection as ShortcutRegistrySection, ToSectionsOptions as ShortcutSectionsOptions, ShortcutRegistryOptions, ShortcutEventTarget, ShortcutDeclaration, BindingOverrides, BindingStore, BindingInfo, ShortcutConflict, ShortcutConflictSeverity, FindConflictsTarget, AllConflictsOptions as ShortcutAllConflictsOptions, ReservedCombo, RebindResult, RebindOptions as ShortcutRebindOptions, RejectedBinding, RawShortcutHandler, ModifierState as ShortcutModifierState, } from './utils/keyboardShortcutRegistry';
192
194
  export { default as MapView } from './components/MapView.svelte';
@@ -200,7 +202,7 @@ export { staggerEntrance } from './actions/staggerEntrance';
200
202
  export type { StaggerEntranceOptions } from './actions/staggerEntrance';
201
203
  export { attention } from './actions/attention';
202
204
  export type { AttentionEffect, AttentionOptions } from './actions/attention';
203
- export { reveal, REVEAL_HIDDEN_CLASS, REVEAL_BASE_CLASS } from './actions/reveal';
205
+ export { reveal, REVEAL_HIDDEN_CLASS, REVEAL_BASE_CLASS, REVEAL_OFF_SELECTOR, REVEAL_MAX_DELAY_SECONDS } from './actions/reveal';
204
206
  export type { RevealOptions, RevealVariant } from './actions/reveal';
205
207
  export { default as ThumbnailGrid } from './components/ThumbnailGrid.svelte';
206
208
  export type { ThumbnailItem } from './components/ThumbnailGrid.svelte';
package/dist/index.js CHANGED
@@ -204,7 +204,7 @@ export { default as PipelineColumn } from './components/pipeline/PipelineColumn.
204
204
  export { default as PipelineCard } from './components/pipeline/PipelineCard.svelte';
205
205
  export { DEFAULT_DRAG_CONFIG, DEFAULT_PIPELINE_CONFIG } from './types/pipeline';
206
206
  // Utilities — mention parsing
207
- export { parseMentions, renderMentionsAsHTML, createMentionString, extractPlainText, findMentionAtCursor, findPartialMention, replaceMentionInContent, escapeHTML, countMentions, setMentionUrlGenerator } from './utils/mentionParser';
207
+ export { parseMentions, renderMentionsAsHTML, createMentionString, extractPlainText, findMentionAtCursor, findPartialMention, replaceMentionInContent, escapeHTML, countMentions, hasMentions, setMentionUrlGenerator, registerMentionType, extractAgentMentions, getUniqueMentionsByType, getMentionedUserIds, AGENT_MENTION_TYPE } from './utils/mentionParser';
208
208
  // Components — Comments (universal threaded comments)
209
209
  export { default as CommentThread } from './components/CommentThread.svelte';
210
210
  export { COMMENT_ENTITY_TYPES, COMMENT_VISIBILITIES } from './types/comments';
@@ -220,6 +220,7 @@ export { default as AnnotationPanel } from './components/AnnotationPanel.svelte'
220
220
  export { MARKUP_COLORS, DEFAULT_STROKE_WIDTH, renderShape, renderAllShapes, nextShapeId, } from './components/markup';
221
221
  // Components — Keyboard Navigation
222
222
  export { default as KeyboardShortcutsOverlay } from './components/KeyboardShortcutsOverlay.svelte';
223
+ export { keyboardShortcutHub, KEYBOARD_SHORTCUT_HUB_ID, } from './utils/keyboardShortcutHub';
223
224
  export { createShortcutRegistry, getShortcutRegistry, formatShortcut, parseCombo as parseShortcutCombo, isTypingTarget, detectPlatform as detectShortcutPlatform, SIDEBAR_TOGGLE_KEY, normalizeCombo, comboFromEvent, validateCombo, localStorageBindingStore, BROWSER_RESERVED_COMBOS, } from './utils/keyboardShortcutRegistry';
224
225
  // Components — Google Maps (DispatchMap fork + location primitives)
225
226
  export { default as MapView } from './components/MapView.svelte';
@@ -243,7 +244,7 @@ export { attention } from './actions/attention';
243
244
  // Scroll-reveal (jatui-m4uz3.3, chimaro port): IntersectionObserver
244
245
  // scroll-reveal whose variants live in styles/motion.css as jatui-reveal-*
245
246
  // classes on the --motion-* tokens — no shipped animista.css.
246
- export { reveal, REVEAL_HIDDEN_CLASS, REVEAL_BASE_CLASS } from './actions/reveal';
247
+ export { reveal, REVEAL_HIDDEN_CLASS, REVEAL_BASE_CLASS, REVEAL_OFF_SELECTOR, REVEAL_MAX_DELAY_SECONDS } from './actions/reveal';
247
248
  // Thumbnail grid (jatui-m4uz3.3, chimaro port): generic {id,url,alt} strip
248
249
  // with overflow counter, remove/add affordances and a cell snippet override.
249
250
  export { default as ThumbnailGrid } from './components/ThumbnailGrid.svelte';
@@ -13,5 +13,5 @@ export { createMotionLevelStore } from './store';
13
13
  export type { MotionState, MotionLevelStore, MotionLevelStoreOptions } from './store';
14
14
  export { glide } from '../transitions/glide';
15
15
  export type { GlideOrigin, GlideParams } from '../transitions/glide';
16
- export { reveal, REVEAL_HIDDEN_CLASS, REVEAL_BASE_CLASS } from '../actions/reveal';
16
+ export { reveal, REVEAL_HIDDEN_CLASS, REVEAL_BASE_CLASS, REVEAL_OFF_SELECTOR, REVEAL_MAX_DELAY_SECONDS } from '../actions/reveal';
17
17
  export type { RevealOptions, RevealVariant } from '../actions/reveal';
@@ -8,4 +8,4 @@ export { cubicBezier, motionEasing } from './easing';
8
8
  export { motionFade, motionScale, motionSlide, resolveMotionTiming } from './transitions';
9
9
  export { createMotionLevelStore } from './store';
10
10
  export { glide } from '../transitions/glide';
11
- export { reveal, REVEAL_HIDDEN_CLASS, REVEAL_BASE_CLASS } from '../actions/reveal';
11
+ export { reveal, REVEAL_HIDDEN_CLASS, REVEAL_BASE_CLASS, REVEAL_OFF_SELECTOR, REVEAL_MAX_DELAY_SECONDS } from '../actions/reveal';
@@ -1,5 +1,5 @@
1
1
  import type { AudioRecorderMode, AudioRecorderOptions, AudioRecorderPhase, AudioRecorderSnapshot } from './audioRecorderCore';
2
- export type { AudioRecorderError, AudioRecorderErrorKind, AudioRecorderFrame, AudioRecorderMode, AudioRecorderOptions, AudioRecorderPhase, AudioRecordingResult } from './audioRecorderCore';
2
+ export type { AudioRecorderChunk, AudioRecorderError, AudioRecorderErrorKind, AudioRecorderFrame, AudioRecorderMode, AudioRecorderOptions, AudioRecorderPhase, AudioRecordingResult } from './audioRecorderCore';
3
3
  export interface AudioRecorder {
4
4
  /** False when the platform has no getUserMedia/MediaRecorder (SSR, Node). */
5
5
  readonly supported: boolean;
@@ -15,6 +15,11 @@ export interface AudioRecorder {
15
15
  readonly elapsedMs: number;
16
16
  /** Last error, or null. */
17
17
  readonly error: AudioRecorderSnapshot['error'];
18
+ /** Live mic MediaStream while the recorder holds it (starting/recording),
19
+ * else null (jatui-ft2ot). Reactive — read it in templates/effects. For
20
+ * hosts that attach the raw stream (WebRTC, a custom MediaRecorder with
21
+ * requestData()-driven streaming). */
22
+ readonly stream: MediaStream | null;
18
23
  start(): Promise<void>;
19
24
  stop(): void;
20
25
  pause(): void;
@@ -13,6 +13,7 @@ export function createAudioRecorder(options = {}) {
13
13
  snap.sizeBytes = s.sizeBytes;
14
14
  snap.elapsedMs = s.elapsedMs;
15
15
  snap.error = s.error;
16
+ snap.stream = s.stream;
16
17
  });
17
18
 
18
19
  return {
@@ -47,6 +48,10 @@ export function createAudioRecorder(options = {}) {
47
48
  get error() {
48
49
  return snap.error;
49
50
  },
51
+
52
+ get stream() {
53
+ return snap.stream;
54
+ },
50
55
  start: () => core.start(),
51
56
  stop: () => core.stop(),
52
57
  pause: () => core.pause(),
@@ -57,11 +57,32 @@ export interface AudioRecorderFrame {
57
57
  /** Time-domain bytes (from getByteTimeDomainData) — raw PCM-ish shape. */
58
58
  time: Uint8Array;
59
59
  }
60
+ /** One MediaRecorder timeslice of audio, handed to `onChunk` as it arrives
61
+ * (jatui-ft2ot). The take keeps accumulating for `onStop` regardless — hosts
62
+ * that buffer chunks (restartable dictation, live transcription) consume
63
+ * both streams independently. */
64
+ export interface AudioRecorderChunk {
65
+ /** The chunk blob — one MediaRecorder `dataavailable` payload. */
66
+ blob: Blob;
67
+ /** MIME type of the recording (same value `onStop` reports). */
68
+ mimeType: string;
69
+ /** Cumulative bytes captured so far in this take (all chunks, not just this one). */
70
+ sizeBytes: number;
71
+ /** Pause-adjusted elapsed ms at the moment this chunk arrived. */
72
+ elapsedMs: number;
73
+ /** 0-based index of this chunk within the take. Resets on a new take. */
74
+ index: number;
75
+ }
60
76
  export interface AudioRecorderOptions {
61
77
  /** `'toggle'` (default) or `'push'` (hold-to-talk — see module header). */
62
78
  mode?: AudioRecorderMode;
63
79
  /** Auto-stop ceiling in ms. 0 (default) records until stopped manually. */
64
80
  maxDurationMs?: number;
81
+ /** Timeslice in ms passed to `MediaRecorder.start()` — chunks are emitted
82
+ * at least this often and delivered to `onChunk` (jatui-ft2ot). Default
83
+ * 1000 (the historical behavior). 0 records as a single blob until stop
84
+ * (no intermediate chunking). */
85
+ timesliceMs?: number;
65
86
  /** Fired once the mic stream is live and recording has started. */
66
87
  onStart?: () => void;
67
88
  onPause?: () => void;
@@ -77,6 +98,12 @@ export interface AudioRecorderOptions {
77
98
  /** Fired every animation frame while recording — analyser data for hosts
78
99
  * that draw their own waveform/level UI. Not fired while paused. */
79
100
  onFrame?: (frame: AudioRecorderFrame) => void;
101
+ /** Fired per MediaRecorder chunk as it arrives (every `timesliceMs`, plus
102
+ * a final shorter chunk at stop). The take still accumulates for `onStop` —
103
+ * hosts get the full take AND a live chunk stream (restartable dictation,
104
+ * streaming transcription). A throwing handler reports `handler-failed`
105
+ * but does NOT stop the recording. */
106
+ onChunk?: (chunk: AudioRecorderChunk) => void;
80
107
  }
81
108
  /** 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
109
  export interface AudioRecorderDeps {
@@ -104,11 +131,18 @@ export interface AudioRecorderSnapshot {
104
131
  elapsedMs: number;
105
132
  /** Last error, or null. */
106
133
  error: AudioRecorderError | null;
134
+ /** Live mic MediaStream while the recorder holds it (starting/recording),
135
+ * else null (jatui-ft2ot). For hosts that attach the raw stream — WebRTC,
136
+ * a custom MediaRecorder with requestData()-driven streaming. */
137
+ stream: MediaStream | null;
107
138
  }
108
139
  export interface AudioRecorderCore {
109
140
  /** False when the platform has no getUserMedia/MediaRecorder (SSR, Node). */
110
141
  readonly supported: boolean;
111
142
  readonly mode: AudioRecorderMode;
143
+ /** Live mic MediaStream while the recorder holds it, else null — same
144
+ * value as `snapshot().stream`, for hosts that read it imperatively. */
145
+ readonly stream: MediaStream | null;
112
146
  /** Current reactive-independent snapshot (the rune wrapper copies this into $state). */
113
147
  snapshot(): AudioRecorderSnapshot;
114
148
  /** Subscribe to snapshot changes. Returns an unsubscribe fn. */
@@ -61,7 +61,7 @@ export function computeElapsedMs(startedAt, pausedTotalMs, pauseStartedAt, now)
61
61
  return Math.max(0, now - startedAt - pausedMs);
62
62
  }
63
63
  export function createAudioRecorderCore(options = {}, deps = {}) {
64
- const { mode = 'toggle', maxDurationMs = 0, onStart, onPause, onResume, onStop, onCancel, onError, onFrame } = options;
64
+ const { mode = 'toggle', maxDurationMs = 0, timesliceMs = 1000, onStart, onPause, onResume, onStop, onCancel, onError, onFrame, onChunk } = options;
65
65
  // Lazy globalThis defaults — resolved per factory call, never at import time.
66
66
  const g = (v) => v;
67
67
  const getUserMedia = deps.getUserMedia ??
@@ -87,6 +87,8 @@ export function createAudioRecorderCore(options = {}, deps = {}) {
87
87
  let level = 0;
88
88
  let sizeBytes = 0;
89
89
  let error = null;
90
+ /** 0-based index of the next chunk in the current take. */
91
+ let chunkIndex = 0;
90
92
  // Recorder internals
91
93
  let recorder = null;
92
94
  let stream = null;
@@ -105,7 +107,15 @@ export function createAudioRecorderCore(options = {}, deps = {}) {
105
107
  let pendingCancel = false;
106
108
  const listeners = new Set();
107
109
  function snapshot() {
108
- return { phase, paused, level, sizeBytes, elapsedMs: computeElapsedMs(startedAt, pausedTotalMs, pauseStartedAt, now()), error };
110
+ return {
111
+ phase,
112
+ paused,
113
+ level,
114
+ sizeBytes,
115
+ elapsedMs: computeElapsedMs(startedAt, pausedTotalMs, pauseStartedAt, now()),
116
+ error,
117
+ stream
118
+ };
109
119
  }
110
120
  function notify() {
111
121
  const s = snapshot();
@@ -273,11 +283,32 @@ export function createAudioRecorderCore(options = {}, deps = {}) {
273
283
  }
274
284
  chunks = [];
275
285
  sizeBytes = 0;
286
+ chunkIndex = 0;
276
287
  recorder.ondataavailable = (e) => {
277
288
  if (e.data && e.data.size > 0) {
278
- chunks.push(e.data);
279
- sizeBytes += e.data.size;
289
+ const blob = e.data;
290
+ chunks.push(blob);
291
+ sizeBytes += blob.size;
292
+ const chunk = {
293
+ blob,
294
+ mimeType,
295
+ sizeBytes,
296
+ elapsedMs: elapsed(),
297
+ index: chunkIndex++
298
+ };
280
299
  notify();
300
+ if (onChunk) {
301
+ try {
302
+ onChunk(chunk);
303
+ }
304
+ catch (err) {
305
+ // A throwing chunk handler must not lose the take — report and keep capturing.
306
+ fail({
307
+ kind: 'handler-failed',
308
+ message: `Chunk kept, but the onChunk handler failed: ${err instanceof Error ? err.message : String(err)}`
309
+ });
310
+ }
311
+ }
281
312
  }
282
313
  };
283
314
  recorder.onstop = () => {
@@ -300,7 +331,7 @@ export function createAudioRecorderCore(options = {}, deps = {}) {
300
331
  analyser = null;
301
332
  }
302
333
  }
303
- recorder.start(1000);
334
+ recorder.start(timesliceMs > 0 ? timesliceMs : undefined);
304
335
  startedAt = now();
305
336
  pausedTotalMs = 0;
306
337
  pauseStartedAt = 0;
@@ -381,7 +412,10 @@ export function createAudioRecorderCore(options = {}, deps = {}) {
381
412
  if (wasCancelled)
382
413
  return;
383
414
  if (chunks.length === 0) {
384
- // Zero chunks (e.g. stopped before any data arrived) — fail loud, not a silent no-op.
415
+ // Zero chunks (e.g. stopped before any data arrived) — fail loud, not a
416
+ // silent no-op. Release the mic + audio graph like every other exit, so
417
+ // a failed take can't leave a hot microphone behind.
418
+ teardown();
385
419
  phase = 'idle';
386
420
  notify();
387
421
  fail({ kind: 'too-short', message: 'Recording was too short — nothing was captured. Try again.' });
@@ -434,6 +468,9 @@ export function createAudioRecorderCore(options = {}, deps = {}) {
434
468
  return supported;
435
469
  },
436
470
  mode,
471
+ get stream() {
472
+ return stream;
473
+ },
437
474
  snapshot,
438
475
  subscribe,
439
476
  start,
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Keyboard shortcut hub — the "one overlay" coordination layer for
3
+ * KeyboardShortcutsOverlay's host/contributor mode.
4
+ *
5
+ * Page-level overlay instances usually self-render (the pre-existing
6
+ * non-host fallback path). But an app shell can mount ONE "global" host
7
+ * overlay; every other overlay on the page then becomes a contributor:
8
+ * instead of self-rendering it contributes its sections (under a
9
+ * `sectionTitle`) to the host's panel and forwards its `open` requests to
10
+ * the host, so exactly one `?`-overlay exists no matter how many surfaces
11
+ * mount the component.
12
+ *
13
+ * Pure TypeScript (no Svelte), subscriber-based like ShortcutRegistry —
14
+ * reactivity lives in the overlay components, which re-read on notify.
15
+ *
16
+ * Usage (app shell):
17
+ *
18
+ * <KeyboardShortcutsOverlay mode="host" /> // owns the `?` toggle
19
+ *
20
+ * Usage (page / portal / contract page):
21
+ *
22
+ * <KeyboardShortcutsOverlay
23
+ * {sections}
24
+ * sectionTitle="Inbox"
25
+ * showHint={false}
26
+ * />
27
+ * // self-renders when no host exists; contributes when one does
28
+ */
29
+ export interface ShortcutContribution {
30
+ /** Stable id for the contributing surface (page route, portal name…). */
31
+ id: string;
32
+ /** Label under which the contributed sections appear in the host panel. */
33
+ sectionTitle: string;
34
+ /** Contributed sections (same shape as KeyboardShortcutsOverlay's `sections`). */
35
+ sections: ShortcutSectionLike[];
36
+ /** Registry snapshot (context-keyed sections) — optional alternative to `sections`. */
37
+ registrySections?: ShortcutSectionLike[];
38
+ }
39
+ export interface ShortcutSectionLike {
40
+ title: string;
41
+ shortcuts: {
42
+ key: string;
43
+ description: string;
44
+ color?: string;
45
+ }[];
46
+ }
47
+ export interface KeyboardShortcutHost {
48
+ /** Toggle the host overlay open/closed. */
49
+ toggle: () => void;
50
+ /** Open the host overlay (idempotent while already open). */
51
+ open: () => void;
52
+ }
53
+ declare class KeyboardShortcutHub {
54
+ private host;
55
+ private contributions;
56
+ private listeners;
57
+ /** True when a host overlay is mounted (contributing instances defer to it). */
58
+ get hasHost(): boolean;
59
+ /** Mount the host. Returns the unmount function. */
60
+ registerHost(host: KeyboardShortcutHost): () => void;
61
+ /** Contribute sections from a page-level overlay instance. Returns unregister. */
62
+ contribute(contribution: ShortcutContribution): () => void;
63
+ /** Contributed sections in registration order, labelled by sectionTitle. */
64
+ getContributions(): ShortcutContribution[];
65
+ /** Ask the host overlay to open. No-op when there is no host. */
66
+ requestOpen(): boolean;
67
+ /** Subscribe to hub changes (host mount/unmount, contributions). */
68
+ subscribe(listener: () => void): () => void;
69
+ private notify;
70
+ }
71
+ export declare const keyboardShortcutHub: KeyboardShortcutHub;
72
+ export declare const KEYBOARD_SHORTCUT_HUB_ID = "keyboard-shortcut-hub";
73
+ export {};
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Keyboard shortcut hub — the "one overlay" coordination layer for
3
+ * KeyboardShortcutsOverlay's host/contributor mode.
4
+ *
5
+ * Page-level overlay instances usually self-render (the pre-existing
6
+ * non-host fallback path). But an app shell can mount ONE "global" host
7
+ * overlay; every other overlay on the page then becomes a contributor:
8
+ * instead of self-rendering it contributes its sections (under a
9
+ * `sectionTitle`) to the host's panel and forwards its `open` requests to
10
+ * the host, so exactly one `?`-overlay exists no matter how many surfaces
11
+ * mount the component.
12
+ *
13
+ * Pure TypeScript (no Svelte), subscriber-based like ShortcutRegistry —
14
+ * reactivity lives in the overlay components, which re-read on notify.
15
+ *
16
+ * Usage (app shell):
17
+ *
18
+ * <KeyboardShortcutsOverlay mode="host" /> // owns the `?` toggle
19
+ *
20
+ * Usage (page / portal / contract page):
21
+ *
22
+ * <KeyboardShortcutsOverlay
23
+ * {sections}
24
+ * sectionTitle="Inbox"
25
+ * showHint={false}
26
+ * />
27
+ * // self-renders when no host exists; contributes when one does
28
+ */
29
+ const HUB_ID = 'keyboard-shortcut-hub';
30
+ class KeyboardShortcutHub {
31
+ host = null;
32
+ contributions = new Map();
33
+ listeners = new Set();
34
+ /** True when a host overlay is mounted (contributing instances defer to it). */
35
+ get hasHost() {
36
+ return this.host !== null;
37
+ }
38
+ /** Mount the host. Returns the unmount function. */
39
+ registerHost(host) {
40
+ if (this.host && this.host !== host) {
41
+ // Two hosts — the later one wins (last-mounted). Warn loudly: a
42
+ // page accidentally mounting a second global overlay is a bug.
43
+ console.warn('[keyboardShortcutHub] replacing an existing host overlay — did you mount the global overlay twice?');
44
+ }
45
+ this.host = host;
46
+ this.notify();
47
+ return () => {
48
+ if (this.host === host) {
49
+ this.host = null;
50
+ this.notify();
51
+ }
52
+ };
53
+ }
54
+ /** Contribute sections from a page-level overlay instance. Returns unregister. */
55
+ contribute(contribution) {
56
+ this.contributions.set(contribution.id, contribution);
57
+ this.notify();
58
+ return () => {
59
+ if (this.contributions.get(contribution.id) === contribution) {
60
+ this.contributions.delete(contribution.id);
61
+ this.notify();
62
+ }
63
+ };
64
+ }
65
+ /** Contributed sections in registration order, labelled by sectionTitle. */
66
+ getContributions() {
67
+ return [...this.contributions.values()];
68
+ }
69
+ /** Ask the host overlay to open. No-op when there is no host. */
70
+ requestOpen() {
71
+ if (!this.host)
72
+ return false;
73
+ this.host.open();
74
+ return true;
75
+ }
76
+ /** Subscribe to hub changes (host mount/unmount, contributions). */
77
+ subscribe(listener) {
78
+ this.listeners.add(listener);
79
+ return () => {
80
+ this.listeners.delete(listener);
81
+ };
82
+ }
83
+ notify() {
84
+ for (const l of this.listeners)
85
+ l();
86
+ }
87
+ }
88
+ export const keyboardShortcutHub = new KeyboardShortcutHub();
89
+ export const KEYBOARD_SHORTCUT_HUB_ID = HUB_ID;