@joewinke/jatui 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CLAUDE.md +72 -2
  2. package/dist/actions/reveal.d.ts +37 -0
  3. package/dist/actions/reveal.js +42 -0
  4. package/dist/components/AudioRecorder.svelte +529 -0
  5. package/dist/components/AudioRecorder.svelte.d.ts +30 -0
  6. package/dist/components/BulkActionBar.svelte +40 -0
  7. package/dist/components/BulkActionBar.svelte.d.ts +9 -0
  8. package/dist/components/ConfirmDialog.svelte +39 -35
  9. package/dist/components/CopyButton.svelte +5 -2
  10. package/dist/components/FloatingActionBar.svelte +55 -1
  11. package/dist/components/FloatingActionBar.svelte.d.ts +8 -1
  12. package/dist/components/IconSelector.svelte +417 -0
  13. package/dist/components/IconSelector.svelte.d.ts +55 -0
  14. package/dist/components/InfiniteGrid.svelte +305 -0
  15. package/dist/components/InfiniteGrid.svelte.d.ts +71 -0
  16. package/dist/components/InfiniteSearchGrid.svelte +131 -0
  17. package/dist/components/InfiniteSearchGrid.svelte.d.ts +67 -0
  18. package/dist/components/InputDialog.svelte +49 -38
  19. package/dist/components/KeyboardShortcutsOverlay.svelte +94 -3
  20. package/dist/components/KeyboardShortcutsOverlay.svelte.d.ts +61 -16
  21. package/dist/components/MediaCaptureIcon.svelte +23 -0
  22. package/dist/components/MediaCaptureIcon.svelte.d.ts +1 -1
  23. package/dist/components/ThumbnailGrid.svelte +137 -0
  24. package/dist/components/ThumbnailGrid.svelte.d.ts +42 -0
  25. package/dist/components/batch/BatchJobTable.svelte +549 -0
  26. package/dist/components/batch/BatchJobTable.svelte.d.ts +114 -0
  27. package/dist/components/batch/QuotaMeter.svelte +78 -0
  28. package/dist/components/batch/QuotaMeter.svelte.d.ts +16 -0
  29. package/dist/components/batch/batchQueue.d.ts +56 -0
  30. package/dist/components/batch/batchQueue.js +81 -0
  31. package/dist/components/batch/elapsedEstimate.d.ts +24 -0
  32. package/dist/components/batch/elapsedEstimate.js +25 -0
  33. package/dist/components/batch/pollOperation.d.ts +45 -0
  34. package/dist/components/batch/pollOperation.js +80 -0
  35. package/dist/components/batch/useElapsedEstimate.d.ts +10 -0
  36. package/dist/components/batch/useElapsedEstimate.js +38 -0
  37. package/dist/components/bulkActionBarKeys.d.ts +40 -0
  38. package/dist/components/bulkActionBarKeys.js +38 -0
  39. package/dist/components/infiniteGridLoader.d.ts +70 -0
  40. package/dist/components/infiniteGridLoader.js +141 -0
  41. package/dist/components/linked-columns/LinkedColumns.svelte +10 -22
  42. package/dist/components/linked-columns/LinkedColumns.svelte.d.ts +1 -9
  43. package/dist/components/linked-columns/types.d.ts +9 -0
  44. package/dist/components/linked-columns/types.js +1 -0
  45. package/dist/components/replay/ChapterTimeline.svelte +43 -25
  46. package/dist/components/replay/ChapterTimeline.svelte.d.ts +26 -1
  47. package/dist/components/table/columnResize.d.ts +38 -0
  48. package/dist/components/table/columnResize.js +117 -0
  49. package/dist/components/table/index.d.ts +2 -0
  50. package/dist/components/table/index.js +1 -0
  51. package/dist/components/toast/ToastItem.svelte +47 -2
  52. package/dist/components/variant/VariantMatrixPicker.svelte +181 -0
  53. package/dist/components/variant/VariantMatrixPicker.svelte.d.ts +37 -0
  54. package/dist/components/variant/variantCombinations.d.ts +75 -0
  55. package/dist/components/variant/variantCombinations.js +100 -0
  56. package/dist/index.d.ts +34 -3
  57. package/dist/index.js +30 -1
  58. package/dist/stores/toast.d.ts +8 -0
  59. package/dist/stores/toast.js +2 -0
  60. package/dist/styles/motion.css +100 -0
  61. package/dist/utils/audioRecorder.d.ts +28 -0
  62. package/dist/utils/audioRecorder.js +58 -0
  63. package/dist/utils/audioRecorderCore.d.ts +132 -0
  64. package/dist/utils/audioRecorderCore.js +447 -0
  65. package/dist/utils/clipboard.d.ts +25 -0
  66. package/dist/utils/clipboard.js +59 -0
  67. package/dist/utils/dateFormatters.d.ts +11 -2
  68. package/dist/utils/dateFormatters.js +22 -2
  69. package/dist/utils/keyboardShortcutRegistry.d.ts +152 -0
  70. package/dist/utils/keyboardShortcutRegistry.js +365 -0
  71. package/package.json +1 -1
@@ -0,0 +1,78 @@
1
+ <script lang="ts">
2
+ /**
3
+ * QuotaMeter (jatui-m4uz3.7) — "312 / 500 generations · resets in 4h 10m".
4
+ *
5
+ * Purely presentational: the host owns the ledger and passes `used`/`limit`.
6
+ * Bar tints warning at `warnAt` and error at 100%.
7
+ */
8
+ import { formatElapsed } from './batchQueue';
9
+
10
+ interface Props {
11
+ used: number;
12
+ limit: number;
13
+ /** When the quota window resets — Date, ISO string, or epoch ms. */
14
+ resetsAt?: Date | string | number | null;
15
+ label?: string;
16
+ /** Unit noun shown after the numbers ("credits", "videos"). */
17
+ unit?: string;
18
+ /** Fraction (0..1) at which the bar turns warning. Default 0.8. */
19
+ warnAt?: number;
20
+ size?: 'xs' | 'sm' | 'md';
21
+ class?: string;
22
+ }
23
+
24
+ let {
25
+ used,
26
+ limit,
27
+ resetsAt = null,
28
+ label = 'Quota',
29
+ unit = '',
30
+ warnAt = 0.8,
31
+ size = 'sm',
32
+ class: className = ''
33
+ }: Props = $props();
34
+
35
+ const ratio = $derived(limit > 0 ? used / limit : 1);
36
+ const remaining = $derived(Math.max(0, limit - used));
37
+ const tone = $derived(ratio >= 1 ? 'progress-error' : ratio >= warnAt ? 'progress-warning' : 'progress-primary');
38
+ const textTone = $derived(ratio >= 1 ? 'text-error' : ratio >= warnAt ? 'text-warning' : 'text-base-content/60');
39
+
40
+ // Re-render the countdown once a minute; no ticking when there's no reset.
41
+ let now = $state(Date.now());
42
+ $effect(() => {
43
+ if (resetsAt == null) return;
44
+ const t = setInterval(() => (now = Date.now()), 60_000);
45
+ return () => clearInterval(t);
46
+ });
47
+
48
+ const resetLabel = $derived.by(() => {
49
+ if (resetsAt == null) return '';
50
+ const at = new Date(resetsAt).getTime();
51
+ if (!Number.isFinite(at)) return '';
52
+ const ms = at - now;
53
+ return ms <= 0 ? 'resets now' : `resets in ${formatElapsed(ms)}`;
54
+ });
55
+
56
+ const fmt = (n: number) => n.toLocaleString();
57
+ const sizeClass = { xs: 'h-1', sm: 'h-1.5', md: 'h-2.5' };
58
+ </script>
59
+
60
+ <div class="flex min-w-40 flex-col gap-1 {className}">
61
+ <div class="flex items-baseline justify-between gap-3 text-xs">
62
+ <span class="font-medium">{label}</span>
63
+ <span class="tabular-nums {textTone}">
64
+ {fmt(used)} / {fmt(limit)}{unit ? ` ${unit}` : ''}
65
+ </span>
66
+ </div>
67
+ <progress
68
+ class="progress w-full {tone} {sizeClass[size]}"
69
+ value={Math.min(used, limit)}
70
+ max={limit > 0 ? limit : 1}
71
+ aria-label="{label}: {fmt(used)} of {fmt(limit)}{unit ? ` ${unit}` : ''} used, {fmt(remaining)} left{resetLabel
72
+ ? `, ${resetLabel}`
73
+ : ''}"
74
+ ></progress>
75
+ {#if resetLabel}
76
+ <span class="text-base-content/50 text-2xs">{resetLabel}</span>
77
+ {/if}
78
+ </div>
@@ -0,0 +1,16 @@
1
+ interface Props {
2
+ used: number;
3
+ limit: number;
4
+ /** When the quota window resets — Date, ISO string, or epoch ms. */
5
+ resetsAt?: Date | string | number | null;
6
+ label?: string;
7
+ /** Unit noun shown after the numbers ("credits", "videos"). */
8
+ unit?: string;
9
+ /** Fraction (0..1) at which the bar turns warning. Default 0.8. */
10
+ warnAt?: number;
11
+ size?: 'xs' | 'sm' | 'md';
12
+ class?: string;
13
+ }
14
+ declare const QuotaMeter: import("svelte").Component<Props, {}, "">;
15
+ type QuotaMeter = ReturnType<typeof QuotaMeter>;
16
+ export default QuotaMeter;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Pure queue logic behind `BatchJobTable` (jatui-m4uz3.7).
3
+ *
4
+ * No DOM, no runes — the component owns the reactive `jobs` array and calls
5
+ * these to decide what to dispatch, reorder, and summarize. Kept separate so
6
+ * the scheduling rules are unit-testable under plain node:test.
7
+ */
8
+ export type JobStatus = 'queued' | 'running' | 'done' | 'failed' | 'skipped' | 'cancelled';
9
+ export interface JobRow<T> {
10
+ id: string;
11
+ /** Host payload — whatever the runner needs (prompt, file, params…). */
12
+ data: T;
13
+ status: JobStatus;
14
+ /** 0..1 while running, when the runner reports it. */
15
+ progress?: number;
16
+ /** Epoch ms of the current/last attempt's start. */
17
+ startedAt?: number;
18
+ /** Epoch ms of the current/last attempt's end. */
19
+ finishedAt?: number;
20
+ /** Number of attempts started so far. */
21
+ attempts: number;
22
+ error?: string;
23
+ result?: unknown;
24
+ }
25
+ export interface DispatchLimits {
26
+ /** Max jobs running at once. */
27
+ concurrencyLimit: number;
28
+ /** Max jobs to START in this run (e.g. a quota budget). `null`/undefined = unlimited. */
29
+ processLimit?: number | null;
30
+ /** Jobs already started in this run. */
31
+ startedThisRun: number;
32
+ }
33
+ /** Wrap host payloads as fresh queued rows. `idOf` defaults to a sequential id. */
34
+ export declare function toJobRows<T>(items: T[], idOf?: (item: T, index: number) => string): JobRow<T>[];
35
+ /** Ids of the queued jobs to start now, in table order, honoring both limits. */
36
+ export declare function nextJobsToStart<T>(jobs: JobRow<T>[], limits: DispatchLimits): string[];
37
+ /** True when the process limit (not the queue) is what stops further dispatch. */
38
+ export declare function isProcessLimitReached<T>(jobs: JobRow<T>[], limits: DispatchLimits): boolean;
39
+ /** Move a job `delta` places (−1 up, +1 down). Returns a new array; out-of-range is a no-op. */
40
+ export declare function moveJob<T>(jobs: JobRow<T>[], id: string, delta: number): JobRow<T>[];
41
+ export interface BatchSummary {
42
+ total: number;
43
+ queued: number;
44
+ running: number;
45
+ done: number;
46
+ failed: number;
47
+ skipped: number;
48
+ cancelled: number;
49
+ /** done + failed + skipped + cancelled. */
50
+ settled: number;
51
+ /** 0..1 — settled share of the batch, counting running jobs' partial progress. */
52
+ fraction: number;
53
+ }
54
+ export declare function summarizeJobs<T>(jobs: JobRow<T>[]): BatchSummary;
55
+ /** "45s", "3m 07s", "1h 02m". Negative/NaN → "0s". */
56
+ export declare function formatElapsed(ms: number): string;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Pure queue logic behind `BatchJobTable` (jatui-m4uz3.7).
3
+ *
4
+ * No DOM, no runes — the component owns the reactive `jobs` array and calls
5
+ * these to decide what to dispatch, reorder, and summarize. Kept separate so
6
+ * the scheduling rules are unit-testable under plain node:test.
7
+ */
8
+ /** Wrap host payloads as fresh queued rows. `idOf` defaults to a sequential id. */
9
+ export function toJobRows(items, idOf) {
10
+ return items.map((data, i) => ({
11
+ id: idOf ? idOf(data, i) : `job-${i + 1}`,
12
+ data,
13
+ status: 'queued',
14
+ attempts: 0
15
+ }));
16
+ }
17
+ /** Ids of the queued jobs to start now, in table order, honoring both limits. */
18
+ export function nextJobsToStart(jobs, limits) {
19
+ const running = jobs.filter((j) => j.status === 'running').length;
20
+ let slots = Math.max(0, Math.floor(limits.concurrencyLimit) - running);
21
+ if (limits.processLimit != null) {
22
+ slots = Math.min(slots, Math.max(0, limits.processLimit - limits.startedThisRun));
23
+ }
24
+ if (slots === 0)
25
+ return [];
26
+ return jobs
27
+ .filter((j) => j.status === 'queued')
28
+ .slice(0, slots)
29
+ .map((j) => j.id);
30
+ }
31
+ /** True when the process limit (not the queue) is what stops further dispatch. */
32
+ export function isProcessLimitReached(jobs, limits) {
33
+ return (limits.processLimit != null &&
34
+ limits.startedThisRun >= limits.processLimit &&
35
+ jobs.some((j) => j.status === 'queued'));
36
+ }
37
+ /** Move a job `delta` places (−1 up, +1 down). Returns a new array; out-of-range is a no-op. */
38
+ export function moveJob(jobs, id, delta) {
39
+ const from = jobs.findIndex((j) => j.id === id);
40
+ const to = from + delta;
41
+ if (from < 0 || to < 0 || to >= jobs.length || delta === 0)
42
+ return jobs;
43
+ const next = jobs.slice();
44
+ const [row] = next.splice(from, 1);
45
+ next.splice(to, 0, row);
46
+ return next;
47
+ }
48
+ export function summarizeJobs(jobs) {
49
+ const s = {
50
+ total: jobs.length,
51
+ queued: 0,
52
+ running: 0,
53
+ done: 0,
54
+ failed: 0,
55
+ skipped: 0,
56
+ cancelled: 0,
57
+ settled: 0,
58
+ fraction: 0
59
+ };
60
+ let partial = 0;
61
+ for (const j of jobs) {
62
+ s[j.status]++;
63
+ if (j.status === 'running')
64
+ partial += Math.min(1, Math.max(0, j.progress ?? 0));
65
+ }
66
+ s.settled = s.done + s.failed + s.skipped + s.cancelled;
67
+ s.fraction = s.total === 0 ? 0 : (s.settled + partial) / s.total;
68
+ return s;
69
+ }
70
+ /** "45s", "3m 07s", "1h 02m". Negative/NaN → "0s". */
71
+ export function formatElapsed(ms) {
72
+ const total = Number.isFinite(ms) && ms > 0 ? Math.round(ms / 1000) : 0;
73
+ const h = Math.floor(total / 3600);
74
+ const m = Math.floor((total % 3600) / 60);
75
+ const s = total % 60;
76
+ if (h > 0)
77
+ return `${h}h ${String(m).padStart(2, '0')}m`;
78
+ if (m > 0)
79
+ return `${m}m ${String(s).padStart(2, '0')}s`;
80
+ return `${s}s`;
81
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Rolling-average ETA math (jatui-m4uz3.7). Pure — the `useElapsedEstimate`
3
+ * rune in `useElapsedEstimate.svelte.ts` wraps it with reactive state.
4
+ */
5
+ export interface EtaInput {
6
+ /** Durations (ms) of finished jobs, oldest first. */
7
+ durations: number[];
8
+ /** Jobs still waiting to start. */
9
+ queued: number;
10
+ /** Elapsed ms of each currently running job. */
11
+ runningElapsed: number[];
12
+ /** Parallel workers. */
13
+ concurrency: number;
14
+ /** How many recent durations to average. Default 10. */
15
+ window?: number;
16
+ }
17
+ /** Mean of the last `window` durations, or null with no samples. */
18
+ export declare function rollingAverage(durations: number[], window?: number): number | null;
19
+ /**
20
+ * Estimated ms until the batch drains, or null until one job has finished.
21
+ * Running jobs contribute what's left of an average job (never negative);
22
+ * queued jobs a full average; total work is spread over the workers.
23
+ */
24
+ export declare function estimateRemainingMs(input: EtaInput): number | null;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Rolling-average ETA math (jatui-m4uz3.7). Pure — the `useElapsedEstimate`
3
+ * rune in `useElapsedEstimate.svelte.ts` wraps it with reactive state.
4
+ */
5
+ /** Mean of the last `window` durations, or null with no samples. */
6
+ export function rollingAverage(durations, window = 10) {
7
+ const recent = durations.slice(-Math.max(1, window)).filter((d) => Number.isFinite(d) && d >= 0);
8
+ if (recent.length === 0)
9
+ return null;
10
+ return recent.reduce((a, b) => a + b, 0) / recent.length;
11
+ }
12
+ /**
13
+ * Estimated ms until the batch drains, or null until one job has finished.
14
+ * Running jobs contribute what's left of an average job (never negative);
15
+ * queued jobs a full average; total work is spread over the workers.
16
+ */
17
+ export function estimateRemainingMs(input) {
18
+ const avg = rollingAverage(input.durations, input.window);
19
+ if (avg == null)
20
+ return null;
21
+ const running = input.runningElapsed.reduce((sum, e) => sum + Math.max(0, avg - e), 0);
22
+ const work = running + Math.max(0, input.queued) * avg;
23
+ const lanes = Math.max(1, Math.min(Math.floor(input.concurrency) || 1, input.queued + input.runningElapsed.length || 1));
24
+ return work / lanes;
25
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Promise poller for long-running remote operations (jatui-m4uz3.7) — the
3
+ * "submit, then poll /status until done" loop every generation API needs.
4
+ *
5
+ * const video = await pollOperation(
6
+ * async ({ signal }) => {
7
+ * const op = await fetch(`/api/ops/${id}`, { signal }).then((r) => r.json());
8
+ * return op.done ? { done: true, value: op.result } : { done: false, progress: op.pct / 100 };
9
+ * },
10
+ * { intervalMs: 2000, timeoutMs: 600_000, signal, onProgress: (p) => reportProgress(p) }
11
+ * );
12
+ *
13
+ * Rejects with `PollTimeoutError` on timeout, the signal's reason on abort,
14
+ * and whatever `check` throws (a thrown check is a failed operation, not a
15
+ * retry — wrap it yourself if transient errors should be swallowed).
16
+ */
17
+ export type PollStep<T> = {
18
+ done: true;
19
+ value: T;
20
+ } | {
21
+ done: false;
22
+ progress?: number;
23
+ };
24
+ export interface PollContext {
25
+ /** 1-based attempt number. */
26
+ attempt: number;
27
+ signal: AbortSignal;
28
+ }
29
+ export interface PollOptions {
30
+ /** Delay between checks. Default 2000. */
31
+ intervalMs?: number;
32
+ /** Give up after this many ms in total. Default: never. */
33
+ timeoutMs?: number;
34
+ /** Multiply the interval by this after each check (capped by maxIntervalMs). Default 1. */
35
+ backoff?: number;
36
+ maxIntervalMs?: number;
37
+ signal?: AbortSignal;
38
+ /** Fires after every not-done check. `progress` is whatever the check reported. */
39
+ onProgress?: (progress: number | undefined, attempt: number) => void;
40
+ }
41
+ export declare class PollTimeoutError extends Error {
42
+ readonly timeoutMs: number;
43
+ constructor(timeoutMs: number);
44
+ }
45
+ export declare function pollOperation<T>(check: (ctx: PollContext) => Promise<PollStep<T>> | PollStep<T>, opts?: PollOptions): Promise<T>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Promise poller for long-running remote operations (jatui-m4uz3.7) — the
3
+ * "submit, then poll /status until done" loop every generation API needs.
4
+ *
5
+ * const video = await pollOperation(
6
+ * async ({ signal }) => {
7
+ * const op = await fetch(`/api/ops/${id}`, { signal }).then((r) => r.json());
8
+ * return op.done ? { done: true, value: op.result } : { done: false, progress: op.pct / 100 };
9
+ * },
10
+ * { intervalMs: 2000, timeoutMs: 600_000, signal, onProgress: (p) => reportProgress(p) }
11
+ * );
12
+ *
13
+ * Rejects with `PollTimeoutError` on timeout, the signal's reason on abort,
14
+ * and whatever `check` throws (a thrown check is a failed operation, not a
15
+ * retry — wrap it yourself if transient errors should be swallowed).
16
+ */
17
+ export class PollTimeoutError extends Error {
18
+ timeoutMs;
19
+ constructor(timeoutMs) {
20
+ super(`Operation did not finish within ${timeoutMs}ms`);
21
+ this.timeoutMs = timeoutMs;
22
+ this.name = 'PollTimeoutError';
23
+ }
24
+ }
25
+ function abortReason(signal) {
26
+ return signal.reason ?? new DOMException('Polling aborted', 'AbortError');
27
+ }
28
+ export function pollOperation(check, opts = {}) {
29
+ const { intervalMs = 2000, timeoutMs, backoff = 1, maxIntervalMs = Infinity, onProgress } = opts;
30
+ const outer = opts.signal;
31
+ // Internal controller: aborts in-flight checks on timeout or outer abort.
32
+ const ctrl = new AbortController();
33
+ return new Promise((resolve, reject) => {
34
+ let timer;
35
+ let deadline;
36
+ let settled = false;
37
+ const finish = (fn) => {
38
+ if (settled)
39
+ return;
40
+ settled = true;
41
+ clearTimeout(timer);
42
+ clearTimeout(deadline);
43
+ outer?.removeEventListener('abort', onAbort);
44
+ fn();
45
+ };
46
+ const onAbort = () => {
47
+ const reason = abortReason(outer);
48
+ ctrl.abort(reason);
49
+ finish(() => reject(reason));
50
+ };
51
+ if (outer?.aborted)
52
+ return onAbort();
53
+ outer?.addEventListener('abort', onAbort, { once: true });
54
+ if (timeoutMs != null) {
55
+ deadline = setTimeout(() => {
56
+ const err = new PollTimeoutError(timeoutMs);
57
+ ctrl.abort(err);
58
+ finish(() => reject(err));
59
+ }, timeoutMs);
60
+ }
61
+ let delay = intervalMs;
62
+ const tick = async (attempt) => {
63
+ let step;
64
+ try {
65
+ step = await check({ attempt, signal: ctrl.signal });
66
+ }
67
+ catch (err) {
68
+ return finish(() => reject(err));
69
+ }
70
+ if (settled)
71
+ return;
72
+ if (step.done)
73
+ return finish(() => resolve(step.value));
74
+ onProgress?.(step.progress, attempt);
75
+ timer = setTimeout(() => tick(attempt + 1), delay);
76
+ delay = Math.min(maxIntervalMs, delay * backoff);
77
+ };
78
+ tick(1);
79
+ });
80
+ }
@@ -0,0 +1,10 @@
1
+ export interface ElapsedEstimate {
2
+ readonly averageMs: number | null;
3
+ readonly samples: number;
4
+ record(durationMs: number): void;
5
+ reset(): void;
6
+ remainingMs(queued: number, runningElapsed: number[], concurrency: number): number | null;
7
+ }
8
+ export declare function useElapsedEstimate(opts?: {
9
+ window?: number;
10
+ }): ElapsedEstimate;
@@ -0,0 +1,38 @@
1
+ /* useElapsedEstimate.svelte.js generated by Svelte v5.53.11 */
2
+ import * as $ from 'svelte/internal/client';
3
+ import { estimateRemainingMs, rollingAverage } from './elapsedEstimate';
4
+
5
+ export function useElapsedEstimate(opts = {}) {
6
+ const window = opts.window ?? 10;
7
+ let durations = $.state($.proxy([]));
8
+ const averageMs = $.derived(() => rollingAverage($.get(durations), window));
9
+
10
+ return {
11
+ get averageMs() {
12
+ return $.get(averageMs);
13
+ },
14
+
15
+ get samples() {
16
+ return $.get(durations).length;
17
+ },
18
+
19
+ record(durationMs) {
20
+ // Only the window matters — keep the array bounded.
21
+ $.set(durations, [...$.get(durations), durationMs].slice(-window), true);
22
+ },
23
+
24
+ reset() {
25
+ $.set(durations, [], true);
26
+ },
27
+
28
+ remainingMs(queued, runningElapsed, concurrency) {
29
+ return estimateRemainingMs({
30
+ durations: $.get(durations),
31
+ queued,
32
+ runningElapsed,
33
+ concurrency,
34
+ window
35
+ });
36
+ }
37
+ };
38
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Pure hotkey resolution for `BulkActionBar` (jatui-is2ow.6) — ported from
3
+ * the IDE's fork (`ide/src/lib/components/BulkActionBar.svelte`, audit
4
+ * jat-1alfp row 15).
5
+ *
6
+ * The IDE fork keeps key handling in the host page; here it lives in the
7
+ * component, but the DECIDING logic is this pure function so it can be
8
+ * unit-tested without a DOM. The component's keydown handler calls
9
+ * `matchBulkHotkey(event, actions)` and invokes the returned action's
10
+ * `onSelect`.
11
+ *
12
+ * Rules (mirroring the shortcut registry's sharp edges):
13
+ * - single printable keys only (`event.key.length === 1`) — an action
14
+ * declaring a multi-char `key` is never matched;
15
+ * - matched case-insensitively (`'C'` on the action fires on `'c'`);
16
+ * - ignored while the focus is in a text entry target (`isTypingTarget`);
17
+ * - ignored for modifier combos (Ctrl/⌘/Alt) and already-handled events.
18
+ */
19
+ /** The subset of `BulkAction` the resolver needs. */
20
+ export interface BulkHotkeyAction {
21
+ /** Single-key binding (e.g. 'c'). Multi-char values never match. */
22
+ key?: string;
23
+ /** Click handler — fired by the component when the hotkey matches. */
24
+ onSelect: () => void;
25
+ }
26
+ /** Minimal structural subset of `KeyboardEvent` — real events satisfy it. */
27
+ export interface BulkHotkeyEvent {
28
+ key: string;
29
+ target: EventTarget | null;
30
+ defaultPrevented?: boolean;
31
+ ctrlKey?: boolean;
32
+ metaKey?: boolean;
33
+ altKey?: boolean;
34
+ }
35
+ /**
36
+ * Resolve a keydown to the bulk action whose single-key binding matches,
37
+ * or return null when the event must not fire a hotkey (typing target,
38
+ * modifier combo, defaultPrevented, non-printable key, no match).
39
+ */
40
+ export declare function matchBulkHotkey(event: BulkHotkeyEvent, actions: readonly BulkHotkeyAction[]): BulkHotkeyAction | null;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pure hotkey resolution for `BulkActionBar` (jatui-is2ow.6) — ported from
3
+ * the IDE's fork (`ide/src/lib/components/BulkActionBar.svelte`, audit
4
+ * jat-1alfp row 15).
5
+ *
6
+ * The IDE fork keeps key handling in the host page; here it lives in the
7
+ * component, but the DECIDING logic is this pure function so it can be
8
+ * unit-tested without a DOM. The component's keydown handler calls
9
+ * `matchBulkHotkey(event, actions)` and invokes the returned action's
10
+ * `onSelect`.
11
+ *
12
+ * Rules (mirroring the shortcut registry's sharp edges):
13
+ * - single printable keys only (`event.key.length === 1`) — an action
14
+ * declaring a multi-char `key` is never matched;
15
+ * - matched case-insensitively (`'C'` on the action fires on `'c'`);
16
+ * - ignored while the focus is in a text entry target (`isTypingTarget`);
17
+ * - ignored for modifier combos (Ctrl/⌘/Alt) and already-handled events.
18
+ */
19
+ import { isTypingTarget } from '../utils/keyboardShortcutRegistry';
20
+ /**
21
+ * Resolve a keydown to the bulk action whose single-key binding matches,
22
+ * or return null when the event must not fire a hotkey (typing target,
23
+ * modifier combo, defaultPrevented, non-printable key, no match).
24
+ */
25
+ export function matchBulkHotkey(event, actions) {
26
+ if (event.defaultPrevented)
27
+ return null;
28
+ if (event.ctrlKey || event.metaKey || event.altKey)
29
+ return null;
30
+ if (isTypingTarget(event.target))
31
+ return null;
32
+ // Single printable keys only — 'Escape', 'ArrowDown', 'Enter' etc. are
33
+ // navigation keys owned by listNav and must never trigger a bulk action.
34
+ if (!event.key || event.key.length !== 1)
35
+ return null;
36
+ const wanted = event.key.toLowerCase();
37
+ return (actions.find((a) => a.key && a.key.length === 1 && a.key.toLowerCase() === wanted) ?? null);
38
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Cursor-paged loader behind InfiniteGrid / InfiniteSearchGrid (jatui-m4uz3.4).
3
+ *
4
+ * Ported from chimaro's InfiniteMediaGrid/InfiniteSearchGrid, where the paging
5
+ * machinery was welded to Supabase + media types. This is the machinery alone,
6
+ * DOM-free and framework-free so it can be unit-tested under node:test:
7
+ *
8
+ * - ONE request in flight per generation (the fast-scroll double-fire guard —
9
+ * an IntersectionObserver can call back twice before `loading` flips).
10
+ * - `reset()` bumps a generation and aborts the in-flight request. A response
11
+ * that lands for an older generation is DROPPED, so a slow page for query
12
+ * "ca" can never be appended under the results for "cat" (the cursor race).
13
+ * - Items are de-duplicated by key across pages (offset paging over a list
14
+ * that grew between requests re-sends rows).
15
+ * - Empty and exhausted are distinct states: `empty` = done with zero items,
16
+ * `exhausted` = done with some. A page that returns no items AND repeats the
17
+ * cursor it was given is treated as the end, never as "try again".
18
+ * - A failed page parks in `error`; nothing auto-retries (the observer would
19
+ * hammer a dead endpoint). `retry()` re-requests the same cursor.
20
+ */
21
+ export interface InfinitePage<T, C> {
22
+ items: T[];
23
+ /** Cursor for the next page; `null` (or undefined) means there is no next page. */
24
+ nextCursor?: C | null;
25
+ /** Optional total row count, surfaced in the end-of-list footer. */
26
+ total?: number;
27
+ }
28
+ export interface InfiniteLoadContext {
29
+ /** Aborted when the grid resets (query/filter change) or unmounts. */
30
+ signal: AbortSignal;
31
+ }
32
+ export type InfiniteLoadFn<T, C> = (cursor: C | null, ctx: InfiniteLoadContext) => Promise<InfinitePage<T, C>>;
33
+ export type InfiniteStatus = 'idle' | 'loading' | 'error' | 'done';
34
+ export interface InfiniteLoaderState<T, C> {
35
+ items: T[];
36
+ status: InfiniteStatus;
37
+ /** Cursor the NEXT load will send (`null` before the first page). */
38
+ cursor: C | null;
39
+ /** True once at least one page has settled for the current generation. */
40
+ initialized: boolean;
41
+ /** status 'done' with zero items. */
42
+ empty: boolean;
43
+ /** status 'done' with at least one item. */
44
+ exhausted: boolean;
45
+ error: unknown;
46
+ total: number | undefined;
47
+ /** Bumps on every reset; stale responses carry an older value and are dropped. */
48
+ generation: number;
49
+ /** Items appended by the most recent page (for a live-region announcement). */
50
+ lastPageCount: number;
51
+ }
52
+ export interface InfiniteLoaderOptions<T, C> {
53
+ load: InfiniteLoadFn<T, C>;
54
+ getKey?: (item: T) => unknown;
55
+ onChange?: (state: InfiniteLoaderState<T, C>) => void;
56
+ }
57
+ export interface InfiniteLoader<T, C> {
58
+ readonly state: InfiniteLoaderState<T, C>;
59
+ /** Fetch the next page. No-op while loading, done, or errored. Resolves when settled. */
60
+ loadNext(): Promise<void>;
61
+ /** Re-request the page that failed. No-op unless status is 'error'. */
62
+ retry(): Promise<void>;
63
+ /** Drop everything, abort in flight, start a new generation (does not fetch). */
64
+ reset(): void;
65
+ /** Swap the fetcher (e.g. a new query closure) — call reset() after. */
66
+ setLoad(load: InfiniteLoadFn<T, C>): void;
67
+ /** Abort in flight and stop emitting. */
68
+ destroy(): void;
69
+ }
70
+ export declare function createInfiniteLoader<T, C = unknown>(opts: InfiniteLoaderOptions<T, C>): InfiniteLoader<T, C>;