@joewinke/jatui 0.55.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -86,9 +86,9 @@ Then import:
86
86
  | `ActivityHeatmap` | GitHub-style activity-per-day calendar (calls/day, tasks closed/day): 7 weekday rows × week columns, default range = last 12 months, `weekStart`, 4-step `color-mix` ramp from one theme `tone` (no hex), one floating tooltip, legend + summary ("1,204 calls in the last year"). `role="grid"` with ONE tab stop: arrows move by day (↑↓) / week (←→), PgUp/PgDn 4 weeks, Home/End row ends, mod+Home/End range ends, Enter/Space select. Pure layout helpers exported (`buildHeatmap`, `moveHeatFocus`). | `data` (`{date, count}[]` or `getDate`/`getCount`), `start?`/`end?`, `weekStart?`, `unit?` ('call' or ['call','calls']), `formatLabel?`, `tone?`, `levels?`, `levelOf?`, `compact?`, `showLegend?`/`showWeekdays?`/`showMonths?`, `summary?`, `selected` (bindable), `onSelectDay?(date, count)`, `locale?` |
87
87
  | `StatusBadge` | Status indicator with dot | `status: StatusValue`, `label` |
88
88
  | `Card` | Content card container | `title`, `children` (snippet) |
89
- | `Modal` | Dialog overlay. `zIndex` accepts `number` OR any CSS `z-index` string, including `"var(--z-lightbox)"`. **Escape is owned by the dialog element itself** (not the window handler) — Modal focuses the dialog on open, so `trapKeys`' stopPropagation would otherwise swallow Esc before the window listener sees it; a Modal opened inside a host Drawer closes before the drawer. The component ships its own open-state `.modal-box` opacity rule, so it renders under DaisyUI 5 with no host global override. | `open` (bindable), `title`, `children` (snippet), `zIndex?: string \| number`, `trapKeys?`, `closeOnEscape?` |
89
+ | `Modal` | Dialog overlay. `zIndex` accepts `number` OR any CSS `z-index` string, including `"var(--z-lightbox)"`. **On open, focus goes to the first fillable field** (WAI-ARIA APG): a `[data-autofocus]`/`autofocus` element, else the first enabled **rendered** text-entry control (readOnly/disabled textareas excluded, unrendered candidates skipped), else the dialog itself — `initialFocus` overrides the order (`'dialog'` restores the old container focus); the focus landing is verified, else the container is focused. When the dialog is the fallback focus, **Escape is owned by the dialog element itself** (not the window handler), so `trapKeys`' stopPropagation can't swallow Esc; a Modal opened inside a host Drawer closes before the drawer. The component ships its own open-state `.modal-box` opacity rule, so it renders under DaisyUI 5 with no host global override. | `open` (bindable), `title`, `children` (snippet), `zIndex?: string \| number`, `trapKeys?`, `closeOnEscape?`, `initialFocus?` |
90
90
  | `ConfirmModal` | Yes/no confirmation dialog | `open` (bindable), `title`, `message`, `onConfirm`, `onCancel` |
91
- | `Drawer` | Slide-out side panel. Header ✕ is a `CloseButton`; the body carries `use:scrollEdge` (opt out with `scrollEdge={false}`). | `open` (bindable), `side`, `scrollEdge?`, `children` (snippet) |
91
+ | `Drawer` | Slide-out side panel. Header ✕ is a `CloseButton`; the body carries `use:scrollEdge` (opt out with `scrollEdge={false}`). On open it focuses the first fillable field ([data-autofocus]/autofocus, else first enabled **rendered** text-entry control, else the panel) — `initialFocus` (`null` opts out). | `open` (bindable), `side`, `initialFocus?`, `scrollEdge?`, `children` (snippet) |
92
92
  | `LoadingSpinner` | Spinner animation | `size`, `color` |
93
93
  | `Sparkline` | Inline mini chart | `data: number[]`, `width`, `height`, `color` |
94
94
  | `LazyImage` | Image with lazy loading + placeholder | `src`, `alt`, `placeholder` |
@@ -106,11 +106,11 @@ Then import:
106
106
  | `BatchJobTable` | Generic queue runner (harvested from vid-gen's BatchGenerator) — `<BatchJobTable generics T>` over bindable `JobRow<T>[]`; host supplies async `run(data, {signal, attempt, reportProgress})`. Concurrency cap, optional per-run `processLimit` (quota budget), rolling-average ETA, per-row status badge / progress / elapsed / retry / skip / cancel / reorder — each cell a snippet override (`statusCell`, `progressCell`, `elapsedCell`, `actionsCell`), host data columns via `headers` + `cells`, extra toolbar content via `toolbar`. Uses `showToast` / `showConfirm` (mount `ToastHost` + `ConfirmDialog`). Exposes `start()`/`pause()`/`cancel()`/`retryFailed()`/`clearFinished()`. Pure scheduling helpers exported too (`nextJobsToStart`, `moveJob`, `summarizeJobs`, `toJobRows`). | `jobs` (bindable), `run`, `concurrencyLimit?` (3), `processLimit?`, `autoStart?`, `notify?`, `confirmDestructive?`, `onJobDone?`, `onJobFailed?`, `onBatchDone?` |
107
107
  | `QuotaMeter` | "312 / 500 credits · resets in 4h 10m" bar — warning tint at `warnAt`, error at 100%. Presentational; the host owns the ledger. | `used`, `limit`, `resetsAt?`, `label?`, `unit?`, `warnAt?` (0.8), `size?` |
108
108
  | `VariantMatrixPicker` | Category/option chip grid for building a combinatorial variant matrix (harvested from vid-gen's FrameVariantGenerator, presets stripped — caller-supplied `categories`). Each category tab shows a live selection count and an additive/multiplicative badge: multiplicative selections cartesian-multiply the total, additive ones stack flat onto every combination. Live combination count in the footer; algebra exported separately as pure functions (`generateCombinations`, `countCombinations`, `variantCategoryMode`, `findVariantOption`, `generateVariantFilename`). | `categories: VariantCategory[]`, `selected` (bindable `VariantSelection`), `forceMode?`, `showCount?` (true), `countLabel?`, `onChange?`, `class?` |
109
- | `BottomSheet` | Mobile bottom sheet (upstreamed from the JAT IDE — jatui-rl7w6.2): drag handle with pointer capture, backdrop close, optional header. `snap='dual'` enables the inspector pattern — peek → expanded snap heights with flick-momentum release (fast flick ≥ 0.5 px/ms wins over position; flick down from peek dismisses) and `visualViewport` keyboard avoidance so content clears the soft keyboard. | `open` (bindable), `title?`, `zIndex?` (70), `fullHeight?`, `snap?` ('single'\|'dual'), `peekFraction?` (0.4), `expandedFraction?` (0.9), `onclose?`, `children` (snippet) |
109
+ | `BottomSheet` | Mobile bottom sheet (upstreamed from the JAT IDE — jatui-rl7w6.2): drag handle with pointer capture, backdrop close, optional header. `snap='dual'` enables the inspector pattern — peek → expanded snap heights with flick-momentum release (fast flick ≥ 0.5 px/ms wins over position; flick down from peek dismisses) and `visualViewport` keyboard avoidance so content clears the soft keyboard. | `open` (bindable), `title?`, `zIndex?` (70), `fullHeight?`, `snap?` ('single'\|'dual'), `peekFraction?` (0.4), `expandedFraction?` (0.9), `onclose?`, `initialFocus?` ('first-input': [data-autofocus]/autofocus, else first enabled **rendered** text-entry control, else the panel; `null` opts out), `children` (snippet) |
110
110
 
111
111
  ### Agent surfaces (BoardUI wave 2)
112
112
 
113
- "Agent is working" UI. Free BoardUI items (agent-thinking, agent-log, composer-loader, notification-center) were ported under MIT — see `THIRD_PARTY_NOTICES.md`; AgentTaskList, Questionnaire and ContextMeter are clean-room. Demos: `/components/agent-thinking`, `agent-log`, `agent-task-list`, `questionnaire`, `context-meter`, `notification-center`, `composer-glow`, `streamed-text`.
113
+ "Agent is working" UI. Free BoardUI items (agent-thinking, agent-log, composer-loader, notification-center) were ported under MIT — see `THIRD_PARTY_NOTICES.md`; AgentTaskList, Questionnaire and ContextMeter are clean-room. Demos: `/components/agent-thinking`, `agent-log`, `agent-task-list`, `questionnaire`, `context-meter`, `notification-center`, `composer-glow`, `streamed-text`, `chat-transcript`.
114
114
 
115
115
  | Component | Purpose | Key Props |
116
116
  |-----------|---------|-----------|
@@ -124,6 +124,7 @@ Then import:
124
124
  | `NotificationCenter` | Inbox panel: title + "N unread" + "Mark all read"; a tab list with count chips (default All / Mentions / System by `category`; `bind:tab` or uncontrolled `defaultTab`; arrows / Home / End switch tabs). Items are newest first under Today / Yesterday / This week / Earlier headers (`groupNotificationsByDate`, `now` injectable). Roving focus via `createListNav`, scoped to the list: j/k/arrows move, Enter opens → `onOpen(item)` or follows `href`. Opening an item or clicking one of its actions marks it read. The host owns read state (`read`); the component also tracks read ids locally, so it works uncontrolled. The unread dot is announced (sr-only "Unread"). Status glyph or `Avatar`; `<time>` via `formatRelativeTime`, refreshed each minute. Empty state via `Empty` ("You're all caught up"). | `items: NotificationItem[]`, `tabs?: {id, label, filter?}[]`, `tab` (bindable), `defaultTab?`, `onTabChange?`, `onOpen?`, `onMarkRead?(ids)`, `onMarkAllRead?` (omitted → `onMarkRead` gets every unread id), `groupByDate?` (true), `now?`, `maxHeight?` ('32rem'), `title?`, `emptyTitle?`, `emptyDescription?` |
125
125
  | `ComposerGlow` | Opt-in ambient rim light around a composer while the agent works: an SVG `<rect rx>` stroke with a pathLength=100 dash laps the box in three layers (crisp line, glow, bloom clipped inside; SVG `feGaussianBlur`, so iOS stays soft); a ResizeObserver keeps the viewBox equal to the px box. Token colours only (info → primary → `color-mix` midpoint → secondary → accent). Fades in/out on `active`; the lap runs only while visible. Under `data-motion="calm"` / reduced motion there is no frozen arc: it shows a static primary focus-ring tint. Paints a `--color-base-100` surface, so the wrapped composer must be transparent. | `active`, `speed?` (4.5 s/lap), `intensity?` ('subtle'\|'normal'\|'vivid'), `radius?` (px or CSS; default box radius), `class?`, `children` |
126
126
  | `StreamedText` | Streamed assistant text revealed per paragraph/line, never per token: an append-only splitter (`splitStreamedText`, index keys, code fences kept whole) mounts each block once with a short fade + un-blur on motion tokens, after which it only grows. History doesn't animate; reduced motion is static; a subtle caret shows while `streaming`. `renderBlock` lets the host render markdown per block. Recipe: show `AgentThinking` until the first *text* part exists, not while the request status is "submitted". | `text`, `streaming?`, `split?` ('paragraph'\|'line'), `as?`, `class?`, `renderBlock?: Snippet<[string, number]>` |
127
+ | `ChatTranscript` | Read-only, two-sided transcript of NAMED speakers — a live phone call (caller vs. the assistant's persona), a Scribe dictation, a meeting recording. Not a chat client (no composer/reactions/edit/delete, no HTML in the words; for a channel use `MessageThread`). Speakers sit on opposite sides (first listed `start`, second `end`, or `side`); consecutive turns group into a run (name + avatar once, tail on the last bubble; a grouped bubble carries the name sr-only). Label speakers by real names ("Dana Testwell", "Ruby"), never role words. `streaming` reveals the LAST turn per line through `StreamedText` with a caret. New turns slide in from their own side on motion tokens (history doesn't animate; calm/reduced motion honoured). Follows the live edge via `createStickToBottom`, `JumpToLatest` when scrolled away. Polite `role="log"`, keyboard-scrollable, `<time>` stamps (offset ms → "1:05", ISO → clock). Bubble tints are `color-mix` of a token family (`tone`: neutral/primary/secondary/accent/info). `meta` snippet (latency, flags — in words), `actions` snippet (hover/focus toolbar, none by default), `onTurnClick` (bubble becomes a button, e.g. seek) + `activeId`. Pure helpers: `buildTranscriptRows`, `resolveSpeakerSides`, `formatTurnTime`. | `turns: TranscriptTurn[]` (`{id, speaker, text \| null, at?, placeholder?}`), `speakers: TranscriptSpeaker[]` (`{id, name, side?, avatarUrl?, tone?}`), `streaming?`, `group?` (true), `avatars?` (true), `live?` (true), `follow?` (true), `ariaLabel?`, `formatTime?`, `emptyText?`, `meta?`/`actions?` (snippets), `onTurnClick?`, `activeId?`, `maxHeight?`, `class?` |
127
128
 
128
129
  **Upgrades in this wave (all additive):**
129
130
  - **`Steps`** (`progress/Steps.svelte`) — `collapsible?`, `collapsed?` (bindable), `summary?: string | Snippet` (default "3 steps left" / "All steps done", live while folded; the header is a real `aria-expanded` / `aria-controls` button), `orientation?` ('horizontal'\|'vertical'; vertical also shows descriptions), `activeProgress?` (0–1, a mini ProgressRing on the active node and in the folded header), `completionDelay?` + `onFinished?` (fires once after `current` passes the last step; re-arms if `current` drops back). Unset, the markup is unchanged. Helpers: `stepsSummary`, `stepsRemaining`, `isStepsFinished`, `clampStepProgress`.
@@ -25,6 +25,11 @@
25
25
  * relayed — we never proxy a third-party stream.
26
26
  * - A playing playlist shows its current track's attribution (title,
27
27
  * composer, license) under the playlist item and its section row.
28
+ * - With a menu or submenu open, the mouse wheel over that surface
29
+ * adjusts volume (wheel up = louder, ~5% per notch; the list still
30
+ * scrolls while it has room) — the wheel escapes to the page the moment
31
+ * the pointer leaves the surface. Each surface (panel and any open
32
+ * submenu) carries its own non-passive listener; never window.
28
33
  *
29
34
  * A11y: the trigger is `aria-pressed` while playing and carries the full
30
35
  * menu-button wiring (`aria-expanded` / `aria-haspopup` / `aria-controls`).
@@ -129,6 +134,65 @@
129
134
  const x = Math.max(margin, Math.min(panel.left + 16, viewport.width - margin - width));
130
135
  return { x, y: row.bottom + 2, side: 'over' };
131
136
  }
137
+
138
+ // --- wheel volume over the open surfaces (jatui-xomny) -------------------
139
+
140
+ /** One mouse-wheel notch in pixel mode — Chrome/WebKit ship ±100 per notch. */
141
+ export const WHEEL_NOTCH_PX = 100;
142
+ /** One mouse-wheel notch in line mode — Firefox ships ±3 lines per notch. */
143
+ export const WHEEL_NOTCH_LINES = 3;
144
+ /** Volume change per wheel notch, on the 0-100 slider scale. */
145
+ export const WHEEL_STEP_PCT = 5;
146
+
147
+ /**
148
+ * A wheel delta normalized into notch units (~1 = one physical notch):
149
+ * pixel deltas divide by ~100 px, Firefox's line mode by 3 lines, and a
150
+ * page-mode delta counts as one notch. Trackpads emit small pixel deltas
151
+ * continuously — those are meant to accumulate (see wheelVolumeSteps),
152
+ * not to step on every event.
153
+ */
154
+ export function wheelNotches(deltaY: number, deltaMode: number): number {
155
+ if (deltaY === 0) return 0;
156
+ if (deltaMode === 1) return deltaY / WHEEL_NOTCH_LINES; // lines
157
+ if (deltaMode === 2) return deltaY; // one page ≈ one notch
158
+ return deltaY / WHEEL_NOTCH_PX; // pixels (deltaMode 0)
159
+ }
160
+
161
+ /**
162
+ * Accumulate a wheel delta into `acc` and return the WHOLE volume steps it
163
+ * adds up to, in volume terms — positive = louder (wheel up), negative =
164
+ * quieter (wheel down) — 0 while a partial notch is still pending. The
165
+ * remainder stays in `acc.value` across events, so trackpad pixel deltas
166
+ * accumulate and one wheel notch lands as about one step.
167
+ */
168
+ export function wheelVolumeSteps(
169
+ acc: { value: number },
170
+ deltaY: number,
171
+ deltaMode: number
172
+ ): number {
173
+ acc.value += wheelNotches(deltaY, deltaMode);
174
+ // Wheel down (positive delta) quiets — flip the sign into volume steps.
175
+ // +0 normalizes Math.trunc's negative zero, which toBe/toEqual treat as
176
+ // a different value from 0.
177
+ const steps = Math.trunc(-acc.value) + 0;
178
+ if (steps !== 0) acc.value += steps; // consume the whole steps
179
+ return steps;
180
+ }
181
+
182
+ /**
183
+ * The volume (0..1) after `steps` wheel steps: ±`stepPct` per step on the
184
+ * 0-100 slider scale, clamped to 0-100. The math runs on the rounded
185
+ * slider percent, so the result never carries float dust past what the
186
+ * slider itself would produce for the same value.
187
+ */
188
+ export function volumeAfterSteps(
189
+ volume: number,
190
+ steps: number,
191
+ stepPct = WHEEL_STEP_PCT
192
+ ): number {
193
+ const next = Math.round(volume * 100) + steps * stepPct;
194
+ return Math.min(100, Math.max(0, next)) / 100;
195
+ }
132
196
  </script>
133
197
 
134
198
  <script lang="ts">
@@ -190,10 +254,18 @@
190
254
  let panelRef = $state<HTMLDivElement | null>(null);
191
255
  let subRef = $state<HTMLDivElement | null>(null);
192
256
  let glyphRef = $state<HTMLSpanElement | null>(null);
257
+ /** The volume section (header + slider) at the top of the panel. */
258
+ let volumeRef = $state<HTMLDivElement | null>(null);
193
259
 
194
260
  let playError = $state(false);
195
261
  let track = $state<PlaylistState['track']>(null);
196
262
 
263
+ // Wheel-volume accumulator over the open surfaces (jatui-xomny): carries
264
+ // the pending partial notch between wheel events so trackpad pixel deltas
265
+ // accumulate into whole steps. Reset every time the menu opens; shared by
266
+ // the panel and its submenus for continuity.
267
+ const wheelAcc = { value: 0 };
268
+
197
269
  const scapes = $derived(items.filter((i): i is Scape => !isAmbienceLink(i)));
198
270
  const currentItem = $derived(items.find((s) => s.id === currentId) ?? null);
199
271
  const currentLabel = $derived(currentItem?.label ?? currentId);
@@ -260,9 +332,10 @@
260
332
  });
261
333
 
262
334
  // While open: click-outside dismissal (capture phase, so a press that
263
- // begins outside never reaches the page first) and APG focus movement into
264
- // the menu — focus the first nav item once the panel exists. A resize
265
- // closes an open submenu (its position is stale).
335
+ // begins outside never reaches the page first), the non-passive wheel
336
+ // listener on the PANEL element (never window — see onSurfaceWheel), and
337
+ // APG focus movement into the menu — focus the first nav item once the
338
+ // panel exists. A resize closes an open submenu (its position is stale).
266
339
  $effect(() => {
267
340
  if (!open) return;
268
341
  const panel = panelRef;
@@ -276,12 +349,26 @@
276
349
  const onResize = () => closeSubmenu(false);
277
350
  document.addEventListener('pointerdown', onDocPointerDown, true);
278
351
  window.addEventListener('resize', onResize);
352
+ panel.addEventListener('wheel', onPanelWheel, { passive: false });
279
353
  return () => {
280
354
  document.removeEventListener('pointerdown', onDocPointerDown, true);
281
355
  window.removeEventListener('resize', onResize);
356
+ panel.removeEventListener('wheel', onPanelWheel);
282
357
  };
283
358
  });
284
359
 
360
+ // Non-passive wheel on any open submenu too (jatui-xomny): over the
361
+ // submenu the wheel is volume with the same rule — a list that can still
362
+ // scroll keeps scrolling. The submenu is a SIBLING of the panel (never
363
+ // clipped by its scroll box), so it carries its own listener.
364
+ $effect(() => {
365
+ const sub = subRef;
366
+ if (!open || !sub || subGroup === null) return;
367
+ const onSubWheel = (e: WheelEvent) => onSurfaceWheel(e, sub, false);
368
+ sub.addEventListener('wheel', onSubWheel, { passive: false });
369
+ return () => sub.removeEventListener('wheel', onSubWheel);
370
+ });
371
+
285
372
  // Keep an open submenu inside the viewport vertically once its real
286
373
  // height is known.
287
374
  $effect(() => {
@@ -328,6 +415,7 @@
328
415
  items = snapshot(); // freshest registry snapshot for the menu
329
416
  effectivePlacement = resolvePlacement();
330
417
  subGroup = null;
418
+ wheelAcc.value = 0; // a fresh menu starts from no pending wheel delta
331
419
  open = true;
332
420
  }
333
421
 
@@ -433,6 +521,74 @@
433
521
  engine.setVolume(v / 100);
434
522
  }
435
523
 
524
+ // --- wheel volume over the open surfaces (jatui-xomny) -------------------
525
+ // While the menu (or one of its submenus) is open the wheel adjusts volume
526
+ // instead of scrolling the page — EXCEPT over a scrollable list that can
527
+ // still move in the wheel's direction, where the wheel stays a scroll. The
528
+ // listeners are non-passive on the surface elements (the panel and any
529
+ // open submenu — the submenu is a SIBLING of the panel, so it needs its
530
+ // own) and are only attached while open, so a wheel anywhere else scrolls
531
+ // the page exactly as before.
532
+
533
+ /**
534
+ * The elements a native wheel scroll could move for a target inside
535
+ * `surface`: every HTMLElement from the target up to and including the
536
+ * surface itself (the panel and each submenu are their own
537
+ * `overflow-y-auto` scroll containers).
538
+ */
539
+ function scrollablesBetween(target: Node, surface: HTMLElement): HTMLElement[] {
540
+ const out: HTMLElement[] = [];
541
+ let el: Node | null = target;
542
+ while (el && el !== surface) {
543
+ if (el instanceof HTMLElement) out.push(el);
544
+ el = el.parentNode;
545
+ }
546
+ out.push(surface);
547
+ return out;
548
+ }
549
+
550
+ /** Can `el` still move in the wheel's direction? (1px epsilon: a list
551
+ * sitting at its edge with sub-pixel scrollTop is at the end.) */
552
+ function canScrollFurther(el: HTMLElement, down: boolean): boolean {
553
+ const style = getComputedStyle(el);
554
+ const scrolls =
555
+ (style.overflowY === 'auto' || style.overflowY === 'scroll') &&
556
+ el.scrollHeight > el.clientHeight;
557
+ if (!scrolls) return false;
558
+ return down ? el.scrollTop + el.clientHeight < el.scrollHeight - 1 : el.scrollTop > 1;
559
+ }
560
+
561
+ /**
562
+ * The shared wheel handler for one open surface. `hasVolume` marks the
563
+ * panel — the surface that owns the volume section, where the wheel is
564
+ * ALWAYS volume (it is a control, not a list). Over the list rows the
565
+ * wheel stays a scroll while some scrollable under the pointer can still
566
+ * move that way; past the end (or when the list is shorter than the
567
+ * surface) it steps the volume instead of letting the scroll escape to
568
+ * the page.
569
+ */
570
+ function onSurfaceWheel(e: WheelEvent, surface: HTMLElement, hasVolume: boolean) {
571
+ const target = e.target as Node | null;
572
+ if (!target || !surface.contains(target)) return;
573
+ // Vertical wheel only — shift+wheel is horizontal scrolling.
574
+ if (e.deltaY === 0) return;
575
+ const down = e.deltaY > 0;
576
+ const overVolume = hasVolume && (volumeRef?.contains(target) ?? false);
577
+ const hijack =
578
+ overVolume || !scrollablesBetween(target, surface).some((el) => canScrollFurther(el, down));
579
+ if (!hijack) return; // native scroll — never preventDefault, never step
580
+
581
+ e.preventDefault();
582
+ const steps = wheelVolumeSteps(wheelAcc, e.deltaY, e.deltaMode);
583
+ if (steps !== 0) engine.setVolume(volumeAfterSteps(volume, steps));
584
+ }
585
+
586
+ function onPanelWheel(e: WheelEvent) {
587
+ const panel = panelRef;
588
+ if (!panel) return;
589
+ onSurfaceWheel(e, panel, true);
590
+ }
591
+
436
592
  // --- pointer path to the menu: press-and-hold ----------------------------
437
593
  function onPointerDown(e: PointerEvent) {
438
594
  longPressed = false;
@@ -657,7 +813,7 @@
657
813
  onscroll={() => closeSubmenu(false)}
658
814
  >
659
815
  <!-- Volume: a real slider in the roving set; it owns its own arrows. -->
660
- <div role="none" class="mb-1 border-b border-base-content/10 px-2 pb-2.5 pt-1.5">
816
+ <div role="none" bind:this={volumeRef} class="mb-1 border-b border-base-content/10 px-2 pb-2.5 pt-1.5">
661
817
  <div class="mb-1.5 flex items-center justify-between">
662
818
  <span class="text-jatui-label text-base-content/60">Volume</span>
663
819
  <span class="text-2xs text-base-content/50 tabular-nums">{sliderValue}%</span>
@@ -24,6 +24,11 @@
24
24
  * relayed — we never proxy a third-party stream.
25
25
  * - A playing playlist shows its current track's attribution (title,
26
26
  * composer, license) under the playlist item and its section row.
27
+ * - With a menu or submenu open, the mouse wheel over that surface
28
+ * adjusts volume (wheel up = louder, ~5% per notch; the list still
29
+ * scrolls while it has room) — the wheel escapes to the page the moment
30
+ * the pointer leaves the surface. Each surface (panel and any open
31
+ * submenu) carries its own non-passive listener; never window.
27
32
  *
28
33
  * A11y: the trigger is `aria-pressed` while playing and carries the full
29
34
  * menu-button wiring (`aria-expanded` / `aria-haspopup` / `aria-controls`).
@@ -91,6 +96,37 @@ export declare function placeSubmenu(row: {
91
96
  y: number;
92
97
  side: 'left' | 'right' | 'over';
93
98
  };
99
+ /** One mouse-wheel notch in pixel mode — Chrome/WebKit ship ±100 per notch. */
100
+ export declare const WHEEL_NOTCH_PX = 100;
101
+ /** One mouse-wheel notch in line mode — Firefox ships ±3 lines per notch. */
102
+ export declare const WHEEL_NOTCH_LINES = 3;
103
+ /** Volume change per wheel notch, on the 0-100 slider scale. */
104
+ export declare const WHEEL_STEP_PCT = 5;
105
+ /**
106
+ * A wheel delta normalized into notch units (~1 = one physical notch):
107
+ * pixel deltas divide by ~100 px, Firefox's line mode by 3 lines, and a
108
+ * page-mode delta counts as one notch. Trackpads emit small pixel deltas
109
+ * continuously — those are meant to accumulate (see wheelVolumeSteps),
110
+ * not to step on every event.
111
+ */
112
+ export declare function wheelNotches(deltaY: number, deltaMode: number): number;
113
+ /**
114
+ * Accumulate a wheel delta into `acc` and return the WHOLE volume steps it
115
+ * adds up to, in volume terms — positive = louder (wheel up), negative =
116
+ * quieter (wheel down) — 0 while a partial notch is still pending. The
117
+ * remainder stays in `acc.value` across events, so trackpad pixel deltas
118
+ * accumulate and one wheel notch lands as about one step.
119
+ */
120
+ export declare function wheelVolumeSteps(acc: {
121
+ value: number;
122
+ }, deltaY: number, deltaMode: number): number;
123
+ /**
124
+ * The volume (0..1) after `steps` wheel steps: ±`stepPct` per step on the
125
+ * 0-100 slider scale, clamped to 0-100. The math runs on the rounded
126
+ * slider percent, so the result never carries float dust past what the
127
+ * slider itself would produce for the same value.
128
+ */
129
+ export declare function volumeAfterSteps(volume: number, steps: number, stepPct?: number): number;
94
130
  import type { Ambience } from '../ambience/types';
95
131
  type $$ComponentProps = {
96
132
  /** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. Section order/labels come from the engine's `groups` option. */
@@ -30,6 +30,7 @@
30
30
  import { scrollEdge as scrollEdgeAction } from '../actions/scrollEdge';
31
31
  import { onMount, untrack } from 'svelte';
32
32
  import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
33
+ import { focusDialogTarget, type InitialFocus } from '../utils/dialogFocus';
33
34
  import type { Snippet } from 'svelte';
34
35
  import { fly, fade } from 'svelte/transition';
35
36
  import { cubicOut } from 'svelte/easing';
@@ -52,6 +53,7 @@
52
53
  shortcutRegistry,
53
54
  shortcutContext = 'bottom-sheet',
54
55
  scrollEdge = true,
56
+ initialFocus = 'first-input',
55
57
  children
56
58
  }: {
57
59
  open?: boolean;
@@ -75,9 +77,27 @@
75
77
  shortcutContext?: string;
76
78
  /** Soft progressive-blur fade at the top of the content once it scrolls (`use:scrollEdge`). Ignored with `fullHeight` (the slot owns scrolling). Default true. */
77
79
  scrollEdge?: boolean;
80
+ /**
81
+ * What to focus when the sheet opens. Default `'first-input'`:
82
+ * [data-autofocus]/[autofocus] inside the panel, else the first enabled
83
+ * text-entry control, else the panel itself. `'dialog'` focuses the
84
+ * panel without searching; an HTMLElement or CSS selector targets
85
+ * exactly that; `null` disables auto-focus entirely.
86
+ */
87
+ initialFocus?: InitialFocus | null;
78
88
  children?: Snippet;
79
89
  } = $props();
80
90
 
91
+ // On open, focus the first fillable field — WAI-ARIA APG dialog pattern:
92
+ // focus the first field the user is expected to fill. See
93
+ // utils/dialogFocus.ts for the resolution order. `initialFocus = null`
94
+ // opts out entirely.
95
+ let panelElement: HTMLElement | undefined = $state();
96
+ $effect(() => {
97
+ if (!open || initialFocus === null) return;
98
+ focusDialogTarget(panelElement ?? null, { initialFocus, fallback: panelElement ?? null });
99
+ });
100
+
81
101
  // While open, a modal shortcut context keeps the host page's registry
82
102
  // shortcuts from firing behind it.
83
103
  $effect(() => {
@@ -234,6 +254,7 @@
234
254
  class="fixed bottom-0 inset-x-0 flex flex-col bg-base-100 rounded-t-2xl border-t border-base-300 shadow-2xl"
235
255
  style="z-index: {zIndex + 1}; {panelHeightStyle} transform: translateY({dragCurrentY}px); opacity: {dragOpacity}; transition: {isDragging ? 'none' : 'transform 0.15s ease, opacity 0.15s ease, height 0.2s ease'};"
236
256
  role="dialog"
257
+ bind:this={panelElement}
237
258
  tabindex="-1"
238
259
  aria-modal="true"
239
260
  aria-label={title || 'Bottom sheet'}
@@ -1,4 +1,5 @@
1
1
  import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
2
+ import { type InitialFocus } from '../utils/dialogFocus';
2
3
  import type { Snippet } from 'svelte';
3
4
  type $$ComponentProps = {
4
5
  open?: boolean;
@@ -22,6 +23,14 @@ type $$ComponentProps = {
22
23
  shortcutContext?: string;
23
24
  /** Soft progressive-blur fade at the top of the content once it scrolls (`use:scrollEdge`). Ignored with `fullHeight` (the slot owns scrolling). Default true. */
24
25
  scrollEdge?: boolean;
26
+ /**
27
+ * What to focus when the sheet opens. Default `'first-input'`:
28
+ * [data-autofocus]/[autofocus] inside the panel, else the first enabled
29
+ * text-entry control, else the panel itself. `'dialog'` focuses the
30
+ * panel without searching; an HTMLElement or CSS selector targets
31
+ * exactly that; `null` disables auto-focus entirely.
32
+ */
33
+ initialFocus?: InitialFocus | null;
25
34
  children?: Snippet;
26
35
  };
27
36
  declare const BottomSheet: import("svelte").Component<$$ComponentProps, {}, "open">;
@@ -3,6 +3,7 @@
3
3
  import { cubicOut } from 'svelte/easing';
4
4
  import { untrack } from 'svelte';
5
5
  import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
6
+ import { focusDialogTarget, type InitialFocus } from '../utils/dialogFocus';
6
7
 
7
8
  let {
8
9
  open = $bindable(true),
@@ -17,6 +18,7 @@
17
18
  oncancel,
18
19
  shortcutRegistry,
19
20
  shortcutContext = 'confirm-modal',
21
+ initialFocus = 'first-input',
20
22
  } = $props<{
21
23
  open?: boolean
22
24
  title?: string
@@ -32,6 +34,14 @@
32
34
  shortcutRegistry?: ShortcutRegistry
33
35
  /** Context name pushed while open. Default `'confirm-modal'`. */
34
36
  shortcutContext?: string
37
+ /**
38
+ * What to focus when the modal opens (see utils/dialogFocus.ts).
39
+ * Default `'first-input'` resolves [data-autofocus]/[autofocus], then a
40
+ * text-entry control, then falls back to the dialog box itself — a
41
+ * confirm dialog has no text field, so it lands on the safe dialog
42
+ * element, never the destructive button.
43
+ */
44
+ initialFocus?: InitialFocus
35
45
  }>()
36
46
 
37
47
  // While open, a modal shortcut context keeps the host page's registry
@@ -59,11 +69,7 @@
59
69
  if (typeof document === 'undefined') return
60
70
  if (open) {
61
71
  previousFocus = document.activeElement as HTMLElement
62
- requestAnimationFrame(() => {
63
- if (modalBox?.contains(document.activeElement)) return
64
- const first = modalBox?.querySelector<HTMLElement>(FOCUSABLE)
65
- ;(first ?? modalBox)?.focus()
66
- })
72
+ focusDialogTarget(modalBox, { initialFocus, fallback: modalBox })
67
73
  } else if (previousFocus) {
68
74
  previousFocus.focus?.()
69
75
  previousFocus = null
@@ -1,4 +1,5 @@
1
1
  import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
2
+ import { type InitialFocus } from '../utils/dialogFocus';
2
3
  type $$ComponentProps = {
3
4
  open?: boolean;
4
5
  title?: string;
@@ -14,6 +15,14 @@ type $$ComponentProps = {
14
15
  shortcutRegistry?: ShortcutRegistry;
15
16
  /** Context name pushed while open. Default `'confirm-modal'`. */
16
17
  shortcutContext?: string;
18
+ /**
19
+ * What to focus when the modal opens (see utils/dialogFocus.ts).
20
+ * Default `'first-input'` resolves [data-autofocus]/[autofocus], then a
21
+ * text-entry control, then falls back to the dialog box itself — a
22
+ * confirm dialog has no text field, so it lands on the safe dialog
23
+ * element, never the destructive button.
24
+ */
25
+ initialFocus?: InitialFocus;
17
26
  };
18
27
  declare const ConfirmModal: import("svelte").Component<$$ComponentProps, {}, "open">;
19
28
  type ConfirmModal = ReturnType<typeof ConfirmModal>;
@@ -12,6 +12,7 @@
12
12
  import { scrollEdge as scrollEdgeAction } from '../actions/scrollEdge';
13
13
  import { untrack, type Snippet } from 'svelte';
14
14
  import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
15
+ import { focusDialogTarget, type InitialFocus } from '../utils/dialogFocus';
15
16
 
16
17
  interface Props {
17
18
  open?: boolean;
@@ -45,6 +46,15 @@
45
46
  shortcutRegistry?: ShortcutRegistry;
46
47
  /** Context name pushed while open. Default `'drawer'`. */
47
48
  shortcutContext?: string;
49
+ /**
50
+ * What to focus when the drawer opens. Default `'first-input'`:
51
+ * [data-autofocus]/[autofocus] inside the panel, else the first enabled
52
+ * text-entry control (text-like input, textarea, contenteditable —
53
+ * never the header close button), else the panel itself. `'dialog'`
54
+ * focuses the panel without searching for a field. An HTMLElement or
55
+ * CSS selector targets exactly that; `null` disables auto-focus entirely.
56
+ */
57
+ initialFocus?: InitialFocus | null;
48
58
  onclose?: () => void;
49
59
  class?: string;
50
60
  headerActions?: Snippet;
@@ -67,6 +77,7 @@
67
77
  trapKeys = true,
68
78
  shortcutRegistry,
69
79
  shortcutContext = 'drawer',
80
+ initialFocus = 'first-input',
70
81
  onclose,
71
82
  class: className = '',
72
83
  headerActions,
@@ -96,6 +107,17 @@
96
107
  onclose?.();
97
108
  }
98
109
 
110
+ let panelElement: HTMLElement | undefined = $state();
111
+
112
+ // On open, focus the first fillable field — WAI-ARIA APG dialog pattern:
113
+ // focus the first field the user is expected to fill. See
114
+ // utils/dialogFocus.ts for the resolution order. `initialFocus = null`
115
+ // opts out entirely.
116
+ $effect(() => {
117
+ if (!open || initialFocus === null) return;
118
+ focusDialogTarget(panelElement ?? null, { initialFocus, fallback: panelElement ?? null });
119
+ });
120
+
99
121
  function handleKeydown(e: KeyboardEvent) {
100
122
  if (e.key === 'Escape' && open) {
101
123
  e.stopImmediatePropagation();
@@ -140,6 +162,7 @@
140
162
  ? 'z-[101] translate-x-0'
141
163
  : '-z-10 ' + (position === 'right' ? 'translate-x-full' : '-translate-x-full')} {className}"
142
164
  role="dialog"
165
+ bind:this={panelElement}
143
166
  aria-modal={open}
144
167
  aria-label={title}
145
168
  aria-hidden={!open}
@@ -1,5 +1,6 @@
1
1
  import { type Snippet } from 'svelte';
2
2
  import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
3
+ import { type InitialFocus } from '../utils/dialogFocus';
3
4
  interface Props {
4
5
  open?: boolean;
5
6
  title?: string;
@@ -32,6 +33,15 @@ interface Props {
32
33
  shortcutRegistry?: ShortcutRegistry;
33
34
  /** Context name pushed while open. Default `'drawer'`. */
34
35
  shortcutContext?: string;
36
+ /**
37
+ * What to focus when the drawer opens. Default `'first-input'`:
38
+ * [data-autofocus]/[autofocus] inside the panel, else the first enabled
39
+ * text-entry control (text-like input, textarea, contenteditable —
40
+ * never the header close button), else the panel itself. `'dialog'`
41
+ * focuses the panel without searching for a field. An HTMLElement or
42
+ * CSS selector targets exactly that; `null` disables auto-focus entirely.
43
+ */
44
+ initialFocus?: InitialFocus | null;
35
45
  onclose?: () => void;
36
46
  class?: string;
37
47
  headerActions?: Snippet;
@@ -30,7 +30,12 @@
30
30
  if (visible && opts) {
31
31
  value = '';
32
32
  error = null;
33
- tick().then(() => inputRef?.focus());
33
+ // Focus AND select the input (jatui-fo6o8) so a replacement value
34
+ // can be typed over immediately without a select-all gesture.
35
+ tick().then(() => {
36
+ inputRef?.focus();
37
+ inputRef?.select();
38
+ });
34
39
  }
35
40
  });
36
41
 
@@ -9,6 +9,7 @@
9
9
  import CloseButton from './CloseButton.svelte';
10
10
  import { onMount, untrack } from 'svelte';
11
11
  import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
12
+ import { focusDialogTarget, type InitialFocus } from '../utils/dialogFocus';
12
13
  import { fly, fade } from 'svelte/transition';
13
14
  import { cubicOut } from 'svelte/easing';
14
15
 
@@ -51,6 +52,15 @@
51
52
  shortcutRegistry?: ShortcutRegistry;
52
53
  /** Context name pushed while open. Default `'modal'`. */
53
54
  shortcutContext?: string;
55
+ /**
56
+ * What to focus when the modal opens. Default `'first-input'`:
57
+ * [data-autofocus]/[autofocus] inside the dialog, else the first enabled
58
+ * text-entry control (text-like input, textarea, contenteditable —
59
+ * never the header close button), else the dialog itself. `'dialog'`
60
+ * restores the old container-focus behaviour for read-only/confirm
61
+ * dialogs. An HTMLElement or CSS selector targets exactly that.
62
+ */
63
+ initialFocus?: InitialFocus;
54
64
  onclose?: () => void;
55
65
  onopen?: () => void;
56
66
  header?: import('svelte').Snippet;
@@ -75,6 +85,7 @@
75
85
  trapKeys = true,
76
86
  shortcutRegistry,
77
87
  shortcutContext = 'modal',
88
+ initialFocus = 'first-input',
78
89
  onclose,
79
90
  onopen,
80
91
  header,
@@ -112,7 +123,11 @@
112
123
  if (open) {
113
124
  previousFocus = document.activeElement as HTMLElement;
114
125
  document.body.style.overflow = 'hidden';
115
- requestAnimationFrame(() => modalElement?.focus());
126
+ // Focus the first fillable field, not the dialog box — see
127
+ // utils/dialogFocus.ts for the resolution order. Falls back to the
128
+ // dialog itself when there is no text field, so Escape handling
129
+ // (owned on the dialog element) keeps working as before.
130
+ focusDialogTarget(modalElement, { initialFocus, fallback: modalElement });
116
131
  onopen?.();
117
132
  } else {
118
133
  document.body.style.overflow = '';
@@ -143,13 +158,13 @@
143
158
 
144
159
  function handleKeyDown(event: KeyboardEvent) {
145
160
  // Escape is owned HERE on the dialog element, not by <svelte:window>.
146
- // Modal focuses the dialog (tabindex=-1) on open, so in the default state
147
- // keydown dispatches from the dialog itself — and the trapKeys
148
- // stopPropagation below (which under Svelte 5 delegated events also
149
- // blocks every window listener) would swallow Escape before the window
150
- // handler could act: Esc did nothing unless focus was in a text field.
151
- // Handling it at element level also guarantees a Modal opened inside a
152
- // host Drawer closes before the drawer does.
161
+ // When no text field matched, Modal focuses the dialog (tabindex=-1) on
162
+ // open, so in that state keydown dispatches from the dialog itself — and
163
+ // the trapKeys stopPropagation below (which under Svelte 5 delegated
164
+ // events also blocks every window listener) would swallow Escape before
165
+ // the window handler could act. Handling it at element level also
166
+ // guarantees a Modal opened inside a host Drawer closes before the
167
+ // drawer does.
153
168
  if (open && event.key === 'Escape' && closeOnEscape) {
154
169
  event.preventDefault();
155
170
  event.stopPropagation();
@@ -1,4 +1,5 @@
1
1
  import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
2
+ import { type InitialFocus } from '../utils/dialogFocus';
2
3
  interface Props {
3
4
  open?: boolean;
4
5
  title?: string;
@@ -38,6 +39,15 @@ interface Props {
38
39
  shortcutRegistry?: ShortcutRegistry;
39
40
  /** Context name pushed while open. Default `'modal'`. */
40
41
  shortcutContext?: string;
42
+ /**
43
+ * What to focus when the modal opens. Default `'first-input'`:
44
+ * [data-autofocus]/[autofocus] inside the dialog, else the first enabled
45
+ * text-entry control (text-like input, textarea, contenteditable —
46
+ * never the header close button), else the dialog itself. `'dialog'`
47
+ * restores the old container-focus behaviour for read-only/confirm
48
+ * dialogs. An HTMLElement or CSS selector targets exactly that.
49
+ */
50
+ initialFocus?: InitialFocus;
41
51
  onclose?: () => void;
42
52
  onopen?: () => void;
43
53
  header?: import('svelte').Snippet;