@joewinke/jatui 0.35.0 → 0.36.1

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
@@ -116,7 +116,7 @@ Item-by-item review surfaces (MOS review-ux, design mos-z8vgx). One `VerdictOpti
116
116
 
117
117
  | Component | Purpose | Key Props |
118
118
  |-----------|---------|-----------|
119
- | `ThumbnailGrid` | Generic thumbnail strip (chimaro port) — image tiles with text-tile fallback, +N overflow counter, remove/add affordances. Decoupled from any media library: pass `{id, url, alt}` items, or a `cell` snippet for full control of tile contents. | `items: ThumbnailItem[]`, `max`, `size` (`xs\|sm\|md\|lg`), `removable`+`onRemove`, `addable`+`onAdd`, `onSelect`, `cell` snippet |
119
+ | `ThumbnailGrid` | Generic thumbnail strip (chimaro port) — image tiles with text-tile fallback, +N overflow counter, remove/add affordances. Decoupled from any media library: pass `{id, url, alt}` items, or a `cell` snippet for full control of tile contents. `removeMode` — `'hover'` (default) reveals the × on hover/focus and keeps it visible on coarse (touch) pointers (ContextMenuTrigger pattern); `'always'` pins it for touch-first flows. `gridCols` (e.g. `'grid-cols-2 sm:grid-cols-4'`) switches the strip to a responsive CSS grid with column-filling tiles. | `items: ThumbnailItem[]`, `max`, `size` (`xs\|sm\|md\|lg`), `removable`+`onRemove`, `removeMode` (`hover\|always`), `addable`+`onAdd`, `onSelect`, `gridCols`, `class`, `cell` snippet |
120
120
  | `MediaWorkbench` | Full-featured image editor modal — upload, AI generate, AI edit, background removal, annotation canvas. **Preferred over `SmartImageEditor` for new image fields.** Two-column modal: preview left, 4 action tabs right (Generate, Edit, Remove BG, Annotate). Save uploads blob to `uploadEndpoint` or emits `onComplete`. Requires proxy endpoints from jst-23w1l.2. | `value` (bindable `string \| null`), `uploadEndpoint?` (default `/api/media`), `onComplete?`, `accept?` |
121
121
  | `MediaLightbox` | Full-screen media preview — z-9040 backdrop, arrow-key paging when given an array, `+/-/0` zoom, download action, Escape/backdrop close. **Use this for the general "just show it big" case** — replaces hand-rolled `fixed inset-0 z-[9040]` modals. For annotated / markup-overlaid image previews, use `ImageLightbox` instead. | `src?`, `filename?`, `kind?: 'image' \| 'video'`, `downloadUrl?`, `items?: MediaItem[]`, `initialIndex?`, `onClose?`, `zIndex?` |
122
122
  | `VideoPlayer` | **The default video surface — reach for this over a bare `<video controls>`.** One timeline band carrying a waveform-envelope scrubber, chapter flags (top edge), bookmark diamonds (bottom edge), diarized speaker colouring and scroll-to-zoom, plus an optional transcript pane (click to seek, active line auto-scroll, solo/skip per speaker, `T` toggle) and a "Talk time" legend (2+ speakers only; one voice gets no legend and no per-row name). **`kind="audio"` renders no stage**, and **`transport="band"`** (the audio default) drops the native `<video controls>` bar for a transport row on the band (play/pause, `now / total`, speed, mute + volume, fullscreen for video), so the band is the ONE timeline. **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and the full keyboard map (Space/K, arrows/J/L, vol/mute, −/= speed + Backspace reset, </> frame step, [/] chapters, B bookmark, ? help overlay), and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek, alt = speed) and zoom over the band. Internals live in `src/lib/video/` (band/keys/rate/deepLink/settings + contracts for audio/night/presence — see that README; gap audit vs the extension: `VIDEO-PLAYER-GAP-AUDIT.md`). | `src`, `kind?` (`'video' \| 'audio'`), `transport?` (`'native' \| 'band'`), `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `crossorigin?` (`'anonymous'` — what frame-reading features need), `syncUrlTime?` (`?t=` deep-link, opt-in), `transcriptPosition?` (`'below' \| 'side'`), `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
@@ -232,14 +232,19 @@ const rec = createAudioRecorder({
232
232
  mode: "push", // 'toggle' (default) | 'push' (hold-to-talk: pointerdown→start, pointerup→stop;
233
233
  // a stop during stream acquisition cancels silently, matching VoiceMicButton)
234
234
  maxDurationMs: 0, // auto-stop ceiling; 0 = until stopped
235
+ timesliceMs: 1000, // MediaRecorder.start(timeslice) — chunks fire onChunk; 0 = single blob until stop
235
236
  onStart?, onPause?, onResume?, onCancel?,
236
237
  onStop?, // (result: AudioRecordingResult) — blob/mimeType/durationSeconds/sizeBytes;
237
238
  // may be async, recorder stays 'processing' until it settles
238
239
  onError?, // (err: { kind, message }) — kind: unsupported|denied|no-mic|start-failed|too-short|handler-failed
239
240
  onFrame?, // ({ level, frequency, time }) per animation frame — draw your own visualization
241
+ onChunk?, // (chunk: AudioRecorderChunk) per timeslice — { blob, mimeType, sizeBytes (cumulative),
242
+ // elapsedMs (pause-adjusted), index }; the take still accumulates for onStop
243
+ // (Scribe: full take + restartable chunks)
240
244
  })
241
245
  // Reactive state (Svelte 5 getters): rec.phase ('idle'|'starting'|'recording'|'processing'|'done'),
242
- // rec.paused, rec.level (0–100), rec.elapsedMs, rec.sizeBytes, rec.error, rec.supported
246
+ // rec.paused, rec.level (0–100), rec.elapsedMs, rec.sizeBytes, rec.error, rec.supported,
247
+ // rec.stream (live MediaStream while recording, else null — for WebRTC / requestData()-driven streaming)
243
248
  // Methods: start() stop() pause() resume() cancel() reset() destroy()
244
249
  ```
245
250
 
@@ -334,7 +339,7 @@ const callbacks: AnnotationCallbacks = {
334
339
 
335
340
  | Component | Purpose | Key Props |
336
341
  |-----------|---------|-----------|
337
- | `KeyboardShortcutsOverlay` | `?`-triggered modal listing keyboard shortcuts. Ignores typing targets and Ctrl/Meta/Alt combos. Supports a flat `shortcuts` list, grouped `sections` with optional per-key color tinting, or a shortcut `registry` (lists what can fire right now, and pushes a modal context while open so page shortcuts don't fire behind it). App-specific sections (marks, list motions, cockpit panels) come in via `extraSections` — rendered after the primary content, before the meta rows, in every mode — plus an `extra` snippet for custom footer content. Precedence: `sections` > `registry` > `shortcuts`; `extraSections`/`extra` always append. | `shortcuts?: KeyboardShortcut[]`, `sections?: KeyboardShortcutSection[]`, `registry?: ShortcutRegistry`, `extraSections?: KeyboardShortcutSection[]`, `extra?: Snippet`, `title?`, `open` (bindable) |
342
+ | `KeyboardShortcutsOverlay` | `?`-triggered modal listing keyboard shortcuts. Ignores typing targets and Ctrl/Meta/Alt combos. Supports a flat `shortcuts` list, grouped `sections` with optional per-key color tinting, or a shortcut `registry` (lists what can fire right now, and pushes a modal context while open so page shortcuts don't fire behind it). App-specific sections (marks, list motions, cockpit panels) come in via `extraSections` — rendered after the primary content, before the meta rows, in every mode — plus an `extra` snippet for custom footer content. Precedence: `sections` > `registry` > `shortcuts`; `extraSections`/`extra` always append. Host/contributor mode: one `mode="host"` overlay is the page-wide `?` hub that aggregates every `mode="auto"` instance's sections under that instance's `sectionTitle`; contributors render nothing (no panel, no hint) and forward `open` to the host via `requestOpen()`. With no host mounted, `auto` self-renders (portal/contract-page fallback); `mode="standalone"` never defers. `showHint={false}` opts out of the closed-state press-`?` hint. | `shortcuts?: KeyboardShortcut[]`, `sections?: KeyboardShortcutSection[]`, `registry?: ShortcutRegistry`, `extraSections?: KeyboardShortcutSection[]`, `extra?: Snippet`, `title?`, `open` (bindable), `mode?: 'auto' | 'host' | 'standalone'`, `sectionTitle?: string`, `showHint?: boolean` |
338
343
 
339
344
  **Shortcut registry** (`createShortcutRegistry()`, or the lazy `getShortcutRegistry()` singleton) — the data source for the overlay, so apps stop hand-wiring a keydown handler plus a separate list for the overlay. Shortcuts register against a **context** and return an unregister fn (return it from `$effect`). Contexts form a stack: `global` is the bottom, `pushContext(name, { modal?, label? })` returns a pop fn, the top context shadows the same combo lower down, and a `modal` context suppresses everything beneath it (including `global`). Handled for you: no firing while typing in text inputs/textarea/select/contenteditable (opt in per shortcut with `allowInInput`), IME composition and `defaultPrevented` events are skipped, `mod` = ⌘ on macOS / Ctrl elsewhere, exact modifier matching (`?` doesn't need `shift`), and macOS Option combos fall back to `event.code`. Per shortcut: `enabled()`, `allowRepeat`, `preventDefault`, `group` (overlay section), `hidden`, `label` (keycap override).
340
345
 
@@ -2,12 +2,54 @@
2
2
  * Scroll-reveal action — reveals an element the first time it scrolls into
3
3
  * the viewport (IntersectionObserver, fires once, then unobserves).
4
4
  *
5
- * Ported from chimaro's `use:reveal` (jatui-m4uz3.3). The public API is kept
6
- * chimaro-compatible (animation / delay / threshold) so chimaro's local copy
7
- * is deletable in favor of this import — but the animista.css dependency is
8
- * NOT shipped: each named variant remaps onto a `jatui-reveal-*` class in
9
- * `jatui/styles/motion.css`, whose keyframes and timing are built entirely
10
- * from the `--motion-*` tokens.
5
+ * Ported from chimaro's `use:reveal` (jatui-m4uz3.3), then hardened with the
6
+ * MOS reveal fixes (jatui-jtip4, from mardukos's `src/lib/actions/reveal.ts`):
7
+ *
8
+ * 1. **threshold defaults to 0** (was 0.1). A tall element in a short
9
+ * viewport — a 9000px email preview in an 800px pane — can only ever
10
+ * intersect ~8%, so a fractional default made it sit at `opacity: 0`
11
+ * forever (the blank-reading-pane bug). Elements still opt into a
12
+ * fractional threshold explicitly.
13
+ * 2. **zero-area guard**: an entry whose `boundingClientRect` is 0×0 means
14
+ * the element sits inside a `display: none` subtree — that is not
15
+ * "below the fold", and waiting for an intersection that can't be
16
+ * trusted left routed panes permanently blank. Such entries reveal
17
+ * immediately *without* the entrance (fail visible).
18
+ * 3. **entrance classes are stripped on `animationend`**:
19
+ * `animation-fill-mode: both` keeps the animation's `transform` applied
20
+ * after it finishes, which silently turns the node into the containing
21
+ * block for `position: fixed` descendants (tiptap drag handles and other
22
+ * overlays land wrong). Removing `.jatui-reveal` + the variant class once
23
+ * the entrance has played restores a transform-free node. Bubbled
24
+ * `animationend`s from children and unrelated animations are ignored.
25
+ *
26
+ * MOS extras also ported:
27
+ *
28
+ * - **delay normalization + stagger cap** — `delay < 1` is seconds (the
29
+ * chimaro contract: `0.2` = 200ms); `delay >= 1` is read as milliseconds
30
+ * (ported call sites keep passing `200`/`500`). Either way the effective
31
+ * wait is capped at {@link REVEAL_MAX_DELAY_SECONDS} (0.3s), so a unit
32
+ * mix-up or an over-long stagger can never hold content invisible.
33
+ * - **`data-reveal="off"` surface switch** — any ancestor carrying the
34
+ * attribute opts its whole subtree out of reveal: the action no-ops and
35
+ * the content simply renders visible.
36
+ * - **paint-first (below-fold-only entrances)** — if the observer's very
37
+ * first delivery reports the element already intersecting (hydration or
38
+ * first layout: it was painted in view), it is revealed *without* the
39
+ * entrance instead of replaying an animation over content the user can
40
+ * already see. Only content that actually starts below the fold animates.
41
+ * The check rides the observer itself, so clipping by an inner scroller is
42
+ * respected (rows scrolled out of a scroll box still animate when the box
43
+ * scrolls them into view).
44
+ * - **live `prefers-reduced-motion` listener** — the media query is watched
45
+ * for the lifetime of the action; switching to reduce while nodes are
46
+ * still hidden reveals them immediately (no entrance) and unobserves.
47
+ *
48
+ * The public API stays chimaro-compatible (animation / delay / threshold) so
49
+ * chimaro's local copy stays deletable in favor of this import — but the
50
+ * animista.css dependency is NOT shipped: each named variant remaps onto a
51
+ * `jatui-reveal-*` class in `jatui/styles/motion.css`, whose keyframes and
52
+ * timing are built entirely from the `--motion-*` tokens.
11
53
  *
12
54
  * Usage:
13
55
  * <div use:reveal> → fade-in-bottom on scroll
@@ -15,23 +57,42 @@
15
57
  * <div use:reveal={{ delay: 0.2 }}> → 200ms staggered delay
16
58
  * <div use:reveal={{ threshold: 0.3 }}> → triggers at 30% visibility
17
59
  *
18
- * Note: `delay` is in SECONDS (chimaro's contract — CSS animation-delay
19
- * units), unlike staggerEntrance's ms-based options. Use fractional values
20
- * for staggers (0.2 = 200ms).
60
+ * Opt a subtree out entirely: `<section data-reveal="off">…</section>`.
21
61
  */
22
62
  export type RevealVariant = 'fade-in' | 'fade-in-bottom' | 'fade-in-top' | 'fade-in-left' | 'fade-in-right' | 'scale-in-center';
23
63
  export interface RevealOptions {
24
64
  /** Named entrance variant, rendered by a `jatui-reveal-*` class in motion.css. Default 'fade-in-bottom'. */
25
65
  animation?: RevealVariant;
26
- /** Delay in SECONDS before the entrance runs (fill mode holds the from-state during the wait). Default 0. */
66
+ /**
67
+ * Delay before the entrance runs. Values below 1 are SECONDS (chimaro's
68
+ * contract — CSS animation-delay units: 0.2 = 200ms); values ≥ 1 are read
69
+ * as MILLISECONDS (250 → 0.25s). The effective wait is capped at
70
+ * {@link REVEAL_MAX_DELAY_SECONDS} so content is never held invisible.
71
+ * Default 0.
72
+ */
27
73
  delay?: number;
28
- /** Fraction of the element that must be visible before revealing (0–1). Default 0.1. */
74
+ /**
75
+ * Fraction of the element that must be visible before revealing (0–1).
76
+ * Default 0 — any intersection counts, so tall elements in short
77
+ * viewports still reveal (a fractional default strands them at opacity 0).
78
+ */
29
79
  threshold?: number;
30
80
  }
31
81
  /** Class applied while the element is waiting to be revealed (opacity: 0). */
32
82
  export declare const REVEAL_HIDDEN_CLASS = "jatui-reveal-hidden";
33
83
  /** Base class carrying the token-driven duration/easing/fill. */
34
84
  export declare const REVEAL_BASE_CLASS = "jatui-reveal";
85
+ /**
86
+ * Selector that switches reveal off for a subtree: any ancestor carrying
87
+ * `data-reveal="off"` makes the action a no-op for nodes inside it.
88
+ */
89
+ export declare const REVEAL_OFF_SELECTOR = "[data-reveal=\"off\"]";
90
+ /**
91
+ * Cap (in seconds) on how long any reveal may hold its content hidden before
92
+ * the entrance starts — keeps unit mix-ups and long stagger trains from
93
+ * blanking content.
94
+ */
95
+ export declare const REVEAL_MAX_DELAY_SECONDS = 0.3;
35
96
  export declare function reveal(node: HTMLElement, options?: RevealOptions): {
36
97
  destroy(): void;
37
98
  };
@@ -2,40 +2,129 @@
2
2
  export const REVEAL_HIDDEN_CLASS = 'jatui-reveal-hidden';
3
3
  /** Base class carrying the token-driven duration/easing/fill. */
4
4
  export const REVEAL_BASE_CLASS = 'jatui-reveal';
5
+ /**
6
+ * Selector that switches reveal off for a subtree: any ancestor carrying
7
+ * `data-reveal="off"` makes the action a no-op for nodes inside it.
8
+ */
9
+ export const REVEAL_OFF_SELECTOR = '[data-reveal="off"]';
10
+ /**
11
+ * Cap (in seconds) on how long any reveal may hold its content hidden before
12
+ * the entrance starts — keeps unit mix-ups and long stagger trains from
13
+ * blanking content.
14
+ */
15
+ export const REVEAL_MAX_DELAY_SECONDS = 0.3;
5
16
  function variantClass(animation) {
6
17
  return `${REVEAL_BASE_CLASS}-${animation}`;
7
18
  }
19
+ /**
20
+ * Normalize a delay option to seconds under the stagger cap. `< 1` is
21
+ * seconds (chimaro contract), `≥ 1` is read as milliseconds — call sites
22
+ * ported from ms-based APIs pass `200` meaning 0.2s, not 200s.
23
+ */
24
+ function normalizeDelay(delay) {
25
+ if (!delay || delay <= 0)
26
+ return 0;
27
+ const seconds = delay >= 1 ? delay / 1000 : delay;
28
+ return Math.min(seconds, REVEAL_MAX_DELAY_SECONDS);
29
+ }
30
+ /** A 0×0 rect means the element is inside a `display: none` subtree. */
31
+ function isZeroArea(rect) {
32
+ return !!rect && rect.width === 0 && rect.height === 0;
33
+ }
8
34
  export function reveal(node, options) {
9
35
  const animation = options?.animation ?? 'fade-in-bottom';
10
- const threshold = options?.threshold ?? 0.1;
11
- // Respect prefers-reduced-motion: skip the animation entirely, show content.
12
- const prefersReducedMotion = typeof window !== 'undefined' &&
13
- window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
36
+ const threshold = options?.threshold ?? 0;
37
+ const variant = variantClass(animation);
14
38
  // No window (SSR) or no IntersectionObserver: stay visible, do nothing.
15
- const canObserve = !prefersReducedMotion &&
16
- typeof window !== 'undefined' &&
17
- typeof IntersectionObserver !== 'undefined';
18
- if (!canObserve) {
39
+ if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
40
+ return { destroy() { } };
41
+ }
42
+ // Surface switch: an ancestor with data-reveal="off" opts the whole
43
+ // subtree out — content renders visible with no entrance, ever.
44
+ if (node.closest?.(REVEAL_OFF_SELECTOR)) {
45
+ return { destroy() { } };
46
+ }
47
+ // Respect prefers-reduced-motion: skip the animation entirely, show content.
48
+ const motionQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)');
49
+ if (motionQuery?.matches) {
19
50
  return { destroy() { } };
20
51
  }
21
52
  node.classList.add(REVEAL_HIDDEN_CLASS);
22
- if (options?.delay) {
23
- node.style.animationDelay = `${options.delay}s`;
53
+ const delay = normalizeDelay(options?.delay);
54
+ if (delay > 0) {
55
+ node.style.animationDelay = `${delay}s`;
24
56
  }
57
+ // Strip the entrance classes once the animation has played: fill-mode:both
58
+ // would otherwise leave a computed transform on the node, making it the
59
+ // containing block for position:fixed descendants (overlays land wrong).
60
+ // Attached only when an entrance actually runs; bubbled animationends from
61
+ // descendants and unrelated animations are ignored.
62
+ const stripEntrance = (event) => {
63
+ if (event.target !== node)
64
+ return; // bubbled from a descendant
65
+ if (!event.animationName?.startsWith(`${REVEAL_BASE_CLASS}-`))
66
+ return; // unrelated animation
67
+ node.classList.remove(REVEAL_BASE_CLASS, variant);
68
+ node.removeEventListener('animationend', stripEntrance);
69
+ };
70
+ const bareReveal = () => {
71
+ // Fail visible: drop the hidden class with no entrance. The inline
72
+ // animation-delay is meaningless without the entrance — clear it too.
73
+ node.classList.remove(REVEAL_HIDDEN_CLASS);
74
+ if (delay > 0)
75
+ node.style.animationDelay = '';
76
+ observer.unobserve(node);
77
+ };
78
+ const entranceReveal = () => {
79
+ node.classList.remove(REVEAL_HIDDEN_CLASS);
80
+ node.classList.add(REVEAL_BASE_CLASS, variant);
81
+ node.addEventListener('animationend', stripEntrance);
82
+ observer.unobserve(node);
83
+ };
84
+ // Live prefers-reduced-motion: if the user switches to reduce while this
85
+ // node is still hidden, show it immediately (no entrance) and stop watching.
86
+ const onMotionChange = () => {
87
+ if (motionQuery?.matches)
88
+ bareReveal();
89
+ };
90
+ motionQuery?.addEventListener?.('change', onMotionChange);
91
+ // True until the observer's first delivery has been processed — an element
92
+ // already intersecting on that first look was painted in view, so it gets
93
+ // no entrance (paint-first: below-fold-only entrances).
94
+ let firstDelivery = true;
25
95
  const observer = new IntersectionObserver((entries) => {
26
96
  for (const entry of entries) {
97
+ // Zero-area guard: 0×0 = display:none subtree, not below-the-fold.
98
+ // Reveal without the entrance so a routed pane can never sit at
99
+ // opacity: 0 permanently.
100
+ if (isZeroArea(entry.boundingClientRect)) {
101
+ bareReveal();
102
+ return;
103
+ }
27
104
  if (entry.isIntersecting) {
28
- node.classList.remove(REVEAL_HIDDEN_CLASS);
29
- node.classList.add(REVEAL_BASE_CLASS, variantClass(animation));
30
- observer.unobserve(node);
105
+ if (firstDelivery) {
106
+ // Paint-first: visible at the observer's first look (hydration
107
+ // or first layout) → no entrance over already-seen content.
108
+ // Rides the observer, so inner-scroller clipping is respected.
109
+ bareReveal();
110
+ }
111
+ else {
112
+ entranceReveal();
113
+ }
114
+ return;
31
115
  }
32
116
  }
117
+ // First delivery said "not visible" — later intersections are real
118
+ // scroll-ins and get the entrance.
119
+ firstDelivery = false;
33
120
  }, { threshold });
34
121
  observer.observe(node);
35
122
  return {
36
123
  destroy() {
37
124
  observer.disconnect();
38
- node.classList.remove(REVEAL_HIDDEN_CLASS, REVEAL_BASE_CLASS, variantClass(animation));
125
+ node.removeEventListener('animationend', stripEntrance);
126
+ motionQuery?.removeEventListener?.('change', onMotionChange);
127
+ node.classList.remove(REVEAL_HIDDEN_CLASS, REVEAL_BASE_CLASS, variant);
39
128
  node.style.animationDelay = '';
40
129
  },
41
130
  };
@@ -2,7 +2,7 @@
2
2
  // The capture pipeline lives in the headless factory (jatui-is2ow.8) —
3
3
  // this component is its markup. Re-exported here so consumers importing
4
4
  // the type from the component keep working.
5
- export type { AudioRecordingResult } from '../utils/audioRecorderCore'
5
+ export type { AudioRecordingResult, AudioRecorderChunk } from '../utils/audioRecorderCore'
6
6
 
7
7
  /** m:ss zero-padded seconds — shared format for the timer display. */
8
8
  export function formatDuration(totalSeconds: number): string {
@@ -55,6 +55,10 @@
55
55
  variant?: 'small' | 'large'
56
56
  /** Auto-stop ceiling in ms. 0 (default) records until stopped manually. */
57
57
  maxDurationMs?: number
58
+ /** Timeslice in ms passed to MediaRecorder.start() — each chunk fires
59
+ * `onRecordChunk` as it arrives (restartable dictation, streaming
60
+ * transcription). Default 1000; 0 records a single blob until stop. */
61
+ timesliceMs?: number
58
62
  disabled?: boolean
59
63
  /** Fired once the mic stream is live and recording has started. */
60
64
  onRecordStart?: () => void
@@ -66,6 +70,14 @@
66
70
  onRecordStop?: (result: AudioRecordingResult) => void | Promise<void>
67
71
  /** Fired when the user cancels — no blob is produced. */
68
72
  onRecordCancel?: () => void
73
+ /** Fired per MediaRecorder chunk as it arrives (every `timesliceMs`, plus
74
+ * a final shorter chunk at stop). The take still accumulates for
75
+ * `onRecordStop` — full take AND a live chunk stream (jatui-ft2ot). */
76
+ onRecordChunk?: (chunk: AudioRecorderChunk) => void
77
+ /** Fired with the live mic MediaStream once recording starts, and with
78
+ * null when it is released — for hosts that attach the raw stream
79
+ * (WebRTC, a custom MediaRecorder). */
80
+ onRecordStream?: (stream: MediaStream | null) => void
69
81
  /** Fired on recorder errors (permission denied, no mic, start failure). */
70
82
  onError?: (message: string) => void
71
83
  class?: string
@@ -74,12 +86,15 @@
74
86
  let {
75
87
  variant = 'small',
76
88
  maxDurationMs = 0,
89
+ timesliceMs = 1000,
77
90
  disabled = false,
78
91
  onRecordStart,
79
92
  onRecordPause,
80
93
  onRecordResume,
81
94
  onRecordStop,
82
95
  onRecordCancel,
96
+ onRecordChunk,
97
+ onRecordStream,
83
98
  onError,
84
99
  class: className = ''
85
100
  }: Props = $props()
@@ -92,11 +107,13 @@
92
107
  const rec = createAudioRecorder({
93
108
  mode: 'toggle',
94
109
  maxDurationMs,
110
+ timesliceMs,
95
111
  onStart: onRecordStart,
96
112
  onPause: onRecordPause,
97
113
  onResume: onRecordResume,
98
114
  onStop: onRecordStop,
99
115
  onCancel: onRecordCancel,
116
+ onChunk: onRecordChunk,
100
117
  onError: (err) => {
101
118
  if (err.kind === 'denied') permissionDenied = true
102
119
  onError?.(err.message)
@@ -297,6 +314,11 @@
297
314
  }
298
315
  })
299
316
 
317
+ // Notify stream-access hosts on every acquire/release (jatui-ft2ot).
318
+ $effect(() => {
319
+ onRecordStream?.(rec.stream)
320
+ })
321
+
300
322
  // Space toggles pause/resume while recording (chimaro behaviour).
301
323
  function handleKeydown(e: KeyboardEvent) {
302
324
  if (e.code === 'Space' && phase === 'recording' && variant === 'large') {
@@ -1,4 +1,4 @@
1
- export type { AudioRecordingResult } from '../utils/audioRecorderCore';
1
+ export type { AudioRecordingResult, AudioRecorderChunk } from '../utils/audioRecorderCore';
2
2
  /** m:ss zero-padded seconds — shared format for the timer display. */
3
3
  export declare function formatDuration(totalSeconds: number): string;
4
4
  /** 1.2 KB / 3.4 MB style byte formatting for the size readout. */
@@ -8,6 +8,10 @@ interface Props {
8
8
  variant?: 'small' | 'large';
9
9
  /** Auto-stop ceiling in ms. 0 (default) records until stopped manually. */
10
10
  maxDurationMs?: number;
11
+ /** Timeslice in ms passed to MediaRecorder.start() — each chunk fires
12
+ * `onRecordChunk` as it arrives (restartable dictation, streaming
13
+ * transcription). Default 1000; 0 records a single blob until stop. */
14
+ timesliceMs?: number;
11
15
  disabled?: boolean;
12
16
  /** Fired once the mic stream is live and recording has started. */
13
17
  onRecordStart?: () => void;
@@ -19,6 +23,14 @@ interface Props {
19
23
  onRecordStop?: (result: AudioRecordingResult) => void | Promise<void>;
20
24
  /** Fired when the user cancels — no blob is produced. */
21
25
  onRecordCancel?: () => void;
26
+ /** Fired per MediaRecorder chunk as it arrives (every `timesliceMs`, plus
27
+ * a final shorter chunk at stop). The take still accumulates for
28
+ * `onRecordStop` — full take AND a live chunk stream (jatui-ft2ot). */
29
+ onRecordChunk?: (chunk: AudioRecorderChunk) => void;
30
+ /** Fired with the live mic MediaStream once recording starts, and with
31
+ * null when it is released — for hosts that attach the raw stream
32
+ * (WebRTC, a custom MediaRecorder). */
33
+ onRecordStream?: (stream: MediaStream | null) => void;
22
34
  /** Fired on recorder errors (permission denied, no mic, start failure). */
23
35
  onError?: (message: string) => void;
24
36
  class?: string;
@@ -21,6 +21,13 @@
21
21
  * `isTypingTarget`), for modifier combos and for non-printable keys.
22
22
  * The key also renders as a `<kbd>` hint on the button.
23
23
  *
24
+ * Per-action `disabled` and `loadingText` (upstreamed from mardukos
25
+ * BulkActionBar): when `disabled` is true the button renders dimmed and
26
+ * non-interactive; when `loadingText` is set the button is disabled and
27
+ * shows a spinner + that text instead of its label while the action is
28
+ * in flight — other actions stay interactive. Global `loading` +
29
+ * `loadingMessage` still take precedence over per-action state.
30
+ *
24
31
  * While `loadingMessage` is set, the buttons are replaced by a spinner +
25
32
  * message row (matches the IDE's BulkActionBar fork).
26
33
  *
@@ -34,7 +41,8 @@
34
41
  * selection={nav.selection}
35
42
  * actions={[
36
43
  * { label: 'Assign', onSelect: () => assignBulk(nav.selection) },
37
- * { label: 'Delete', onSelect: () => deleteBulk(nav.selection), destructive: true },
44
+ * { label: 'Delete', onSelect: () => deleteBulk(nav.selection), destructive: true, disabled: !canDelete },
45
+ * { label: 'Send', onSelect: () => sendBulk(nav.selection), loadingText: 'Sending…' },
38
46
  * ]}
39
47
  * onClear={() => nav.clearSelection()}
40
48
  * />
@@ -71,6 +79,11 @@
71
79
  destructive?: boolean;
72
80
  /** Semantic variant override — takes precedence over `destructive`. */
73
81
  variant?: ActionButton['variant'];
82
+ /** Dimmed, non-interactive button (upstreamed from mardukos). */
83
+ disabled?: boolean;
84
+ /** When set, the button is disabled and shows a spinner + this text
85
+ * while the action is in flight (upstreamed from mardukos). */
86
+ loadingText?: string | null;
74
87
  /** Unique key (defaults to label). */
75
88
  id?: string;
76
89
  }
@@ -135,6 +148,8 @@
135
148
  key: a.key,
136
149
  variant: a.variant ?? (a.destructive ? 'danger' : 'default'),
137
150
  onclick: a.onSelect,
151
+ disabled: a.disabled,
152
+ loadingText: a.loadingText,
138
153
  })),
139
154
  );
140
155
 
@@ -143,7 +158,7 @@
143
158
  // typing targets, modifier combos and non-printable keys never fire.
144
159
  $effect(() => {
145
160
  if (count === 0 || loading || loadingMessage) return;
146
- const hotkeyed = actions.filter((a) => a.key);
161
+ const hotkeyed = actions.filter((a) => a.key && !a.disabled && !a.loadingText);
147
162
  if (hotkeyed.length === 0) return;
148
163
  function onKeydown(event: KeyboardEvent) {
149
164
  const action = matchBulkHotkey(event, hotkeyed);
@@ -14,6 +14,11 @@ export interface BulkAction {
14
14
  destructive?: boolean;
15
15
  /** Semantic variant override — takes precedence over `destructive`. */
16
16
  variant?: ActionButton['variant'];
17
+ /** Dimmed, non-interactive button (upstreamed from mardukos). */
18
+ disabled?: boolean;
19
+ /** When set, the button is disabled and shows a spinner + this text
20
+ * while the action is in flight (upstreamed from mardukos). */
21
+ loadingText?: string | null;
17
22
  /** Unique key (defaults to label). */
18
23
  id?: string;
19
24
  }
@@ -3,6 +3,9 @@
3
3
  * Drawer Component
4
4
  *
5
5
  * Sliding panel overlay with backdrop, escape key, and header actions.
6
+ * Supports a `footer` snippet (pinned below the scrollable body —
7
+ * matches mardukos DrawerShell) and an arbitrary Tailwind `width`
8
+ * class (e.g. `'w-[42rem]'`) which takes precedence over `size`.
6
9
  */
7
10
 
8
11
  import { untrack, type Snippet } from 'svelte';
@@ -13,6 +16,10 @@
13
16
  title?: string;
14
17
  position?: 'left' | 'right';
15
18
  size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full';
19
+ /** Arbitrary Tailwind width class (e.g. `'w-[42rem]'`). Takes precedence
20
+ * over `size`. Pins the panel to a named breakpoint if combined with one,
21
+ * so you don't need `max-w-[100vw]` guards for mobile. */
22
+ width?: string;
16
23
  showCloseButton?: boolean;
17
24
  contentClass?: string;
18
25
  /**
@@ -37,6 +44,10 @@
37
44
  onclose?: () => void;
38
45
  class?: string;
39
46
  headerActions?: Snippet;
47
+ /** Pinned below the scrollable content area (matches mardukos DrawerShell
48
+ * `footer` — action rows / submit buttons that must stay visible while
49
+ * the body scrolls). */
50
+ footer?: Snippet;
40
51
  children?: Snippet;
41
52
  }
42
53
 
@@ -45,6 +56,7 @@
45
56
  title = '',
46
57
  position = 'right',
47
58
  size = 'md',
59
+ width,
48
60
  showCloseButton = true,
49
61
  contentClass = '',
50
62
  trapKeys = true,
@@ -53,6 +65,7 @@
53
65
  onclose,
54
66
  class: className = '',
55
67
  headerActions,
68
+ footer,
56
69
  children
57
70
  }: Props = $props();
58
71
 
@@ -118,7 +131,7 @@
118
131
  <div
119
132
  class="fixed top-0 {position === 'right'
120
133
  ? 'right-0'
121
- : 'left-0'} h-full bg-base-200 {position === 'right' ? 'border-l' : 'border-r'} border-base-300 {sizeClasses[size]} flex flex-col transition-all duration-300 {open
134
+ : 'left-0'} h-full bg-base-200 {position === 'right' ? 'border-l' : 'border-r'} border-base-300 {width || sizeClasses[size]} flex flex-col transition-all duration-300 {open
122
135
  ? 'z-[101] translate-x-0'
123
136
  : '-z-10 ' + (position === 'right' ? 'translate-x-full' : '-translate-x-full')} {className}"
124
137
  role="dialog"
@@ -157,4 +170,10 @@
157
170
  {@render children()}
158
171
  {/if}
159
172
  </div>
173
+
174
+ {#if footer}
175
+ <div class="flex-shrink-0 border-t border-base-content/10">
176
+ {@render footer()}
177
+ </div>
178
+ {/if}
160
179
  </div>
@@ -2,6 +2,9 @@
2
2
  * Drawer Component
3
3
  *
4
4
  * Sliding panel overlay with backdrop, escape key, and header actions.
5
+ * Supports a `footer` snippet (pinned below the scrollable body —
6
+ * matches mardukos DrawerShell) and an arbitrary Tailwind `width`
7
+ * class (e.g. `'w-[42rem]'`) which takes precedence over `size`.
5
8
  */
6
9
  import { type Snippet } from 'svelte';
7
10
  import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
@@ -10,6 +13,10 @@ interface Props {
10
13
  title?: string;
11
14
  position?: 'left' | 'right';
12
15
  size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full';
16
+ /** Arbitrary Tailwind width class (e.g. `'w-[42rem]'`). Takes precedence
17
+ * over `size`. Pins the panel to a named breakpoint if combined with one,
18
+ * so you don't need `max-w-[100vw]` guards for mobile. */
19
+ width?: string;
13
20
  showCloseButton?: boolean;
14
21
  contentClass?: string;
15
22
  /**
@@ -34,6 +41,10 @@ interface Props {
34
41
  onclose?: () => void;
35
42
  class?: string;
36
43
  headerActions?: Snippet;
44
+ /** Pinned below the scrollable content area (matches mardukos DrawerShell
45
+ * `footer` — action rows / submit buttons that must stay visible while
46
+ * the body scrolls). */
47
+ footer?: Snippet;
37
48
  children?: Snippet;
38
49
  }
39
50
  declare const Drawer: import("svelte").Component<Props, {}, "open">;
@@ -16,6 +16,10 @@
16
16
  * the matching keydown handling lives in the host/BulkActionBar). While
17
17
  * `loading` with a `loadingMessage` set, the buttons are replaced by a
18
18
  * spinner + message row (matching the IDE's fork, audit jat-1alfp #15).
19
+ *
20
+ * Per-action `disabled` renders the button dimmed and non-interactive;
21
+ * per-action `loadingText` disables it and swaps the label for a
22
+ * spinner + that text (mardukos fork) while other actions stay active.
19
23
  */
20
24
 
21
25
  import { fade } from 'svelte/transition';
@@ -33,6 +37,11 @@
33
37
  key?: string;
34
38
  /** Called when clicked */
35
39
  onclick: () => void;
40
+ /** Dimmed, non-interactive button (mardukos). */
41
+ disabled?: boolean;
42
+ /** When set, the button is disabled and shows a spinner + this text
43
+ * instead of the label (mardukos per-action loading). */
44
+ loadingText?: string | null;
36
45
  }
37
46
 
38
47
  export interface DropdownItem {
@@ -136,18 +145,24 @@
136
145
  type="button"
137
146
  class="fab-btn {variantClass(action.variant)}"
138
147
  onclick={action.onclick}
139
- disabled={loading}
140
- title={action.key ? `${action.label} (${action.key})` : undefined}
148
+ disabled={loading || action.disabled || !!action.loadingText}
149
+ aria-busy={loading || !!action.loadingText || undefined}
150
+ title={action.key && !action.disabled && !action.loadingText ? `${action.label} (${action.key})` : undefined}
141
151
  >
142
- {#if action.icon}
143
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
144
- <!-- svelte-ignore a11y_consider_explicit_label -->
145
- {@html action.icon}
146
- </svg>
147
- {/if}
148
- {action.label}
149
- {#if action.key}
150
- <kbd class="fab-key">{action.key}</kbd>
152
+ {#if action.loadingText}
153
+ <span class="fab-spinner"></span>
154
+ <span class="fab-loading-msg">{action.loadingText}</span>
155
+ {:else}
156
+ {#if action.icon}
157
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
158
+ <!-- svelte-ignore a11y_consider_explicit_label -->
159
+ {@html action.icon}
160
+ </svg>
161
+ {/if}
162
+ {action.label}
163
+ {#if action.key}
164
+ <kbd class="fab-key">{action.key}</kbd>
165
+ {/if}
151
166
  {/if}
152
167
  </button>
153
168
  {/each}
@@ -11,6 +11,11 @@ export interface ActionButton {
11
11
  key?: string;
12
12
  /** Called when clicked */
13
13
  onclick: () => void;
14
+ /** Dimmed, non-interactive button (mardukos). */
15
+ disabled?: boolean;
16
+ /** When set, the button is disabled and shows a spinner + this text
17
+ * instead of the label (mardukos per-action loading). */
18
+ loadingText?: string | null;
14
19
  }
15
20
  export interface DropdownItem {
16
21
  /** Display label */