@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
@@ -23,7 +23,10 @@
23
23
  function handleSubmit() {
24
24
  if (opts?.validate) {
25
25
  const msg = opts.validate(value);
26
- if (msg !== null) { error = msg; return; }
26
+ if (msg !== null) {
27
+ error = msg;
28
+ return;
29
+ }
27
30
  }
28
31
  error = null;
29
32
  submitInput(value);
@@ -80,7 +83,6 @@
80
83
  <input
81
84
  bind:this={inputRef}
82
85
  bind:value
83
- oninput={() => { if (error) error = null; }}
84
86
  type="text"
85
87
  class="id-input"
86
88
  class:id-input-error={error !== null}
@@ -88,7 +90,10 @@
88
90
  maxlength={opts.maxLength}
89
91
  autocomplete="off"
90
92
  spellcheck="false"
93
+ oninput={() => { if (error) error = null; }}
91
94
  />
95
+
96
+ <!-- Error / char count -->
92
97
  {#if error !== null || opts.maxLength}
93
98
  <div class="id-field-footer">
94
99
  {#if error !== null}
@@ -127,10 +132,15 @@
127
132
  {/if}
128
133
 
129
134
  <style>
135
+ /* Theme tokens (jatui-is2ow.3): every colour derives from var(--color-*) —
136
+ relative-color `oklch(from var(--x) l c h / a)` keeps the token's hue/chroma
137
+ and only swaps alpha, so light and dark themes both render correctly.
138
+ --n-00 (pure black) is a jat-IDE neutral; the inline fallback keeps
139
+ standalone consumers (no app.css) rendering the same value. */
130
140
  .id-scrim {
131
141
  position: fixed;
132
142
  inset: 0;
133
- background: oklch(0 0 0 / 0.45);
143
+ background: oklch(from var(--n-00, oklch(0 0 0)) l c h / 0.45);
134
144
  z-index: 9991;
135
145
  backdrop-filter: blur(2px);
136
146
  }
@@ -151,12 +161,12 @@
151
161
  flex-direction: column;
152
162
  gap: 0.625rem;
153
163
  padding: 1rem 1.125rem 0.625rem;
154
- background: oklch(0.17 0.02 250 / 0.98);
155
- border: 1px solid oklch(0.32 0.03 250);
164
+ background: oklch(from var(--color-base-200) l c h / 98%);
165
+ border: 1px solid var(--color-base-100);
156
166
  border-radius: 0.875rem;
157
167
  box-shadow:
158
- 0 20px 60px oklch(0 0 0 / 0.55),
159
- 0 0 0 1px oklch(0.28 0.03 250 / 0.5);
168
+ 0 20px 60px oklch(from var(--n-00, oklch(0 0 0)) l c h / 0.55),
169
+ 0 0 0 1px oklch(from var(--color-base-100) l c h / 50%);
160
170
  backdrop-filter: blur(16px);
161
171
  }
162
172
 
@@ -174,9 +184,9 @@
174
184
  display: flex;
175
185
  align-items: center;
176
186
  justify-content: center;
177
- background: oklch(0.30 0.06 200 / 0.35);
178
- border: 1px solid oklch(0.45 0.10 200 / 0.4);
179
- color: oklch(0.75 0.12 200);
187
+ background: color-mix(in oklch, var(--color-info) 20%, transparent);
188
+ border: 1px solid color-mix(in oklch, var(--color-info) 30%, transparent);
189
+ color: var(--color-info);
180
190
  }
181
191
 
182
192
  .id-icon svg {
@@ -196,14 +206,14 @@
196
206
  margin: 0;
197
207
  font-size: 0.9375rem;
198
208
  font-weight: 600;
199
- color: oklch(0.92 0.04 250);
209
+ color: var(--color-base-content);
200
210
  line-height: 1.3;
201
211
  }
202
212
 
203
213
  .id-body {
204
214
  margin: 0;
205
215
  font-size: 0.8rem;
206
- color: oklch(0.60 0.03 250);
216
+ color: oklch(from var(--color-base-content) l c h / 70%);
207
217
  line-height: 1.5;
208
218
  }
209
219
 
@@ -211,51 +221,52 @@
211
221
  width: 100%;
212
222
  padding: 0.4375rem 0.75rem;
213
223
  font-size: 0.875rem;
214
- background: oklch(0.13 0.02 250);
215
- border: 1px solid oklch(0.32 0.03 250);
224
+ background: var(--color-base-300);
225
+ border: 1px solid var(--color-base-100);
216
226
  border-radius: 0.5rem;
217
- color: oklch(0.92 0.04 250);
227
+ color: var(--color-base-content);
218
228
  outline: none;
219
229
  transition: border-color 0.1s ease;
220
230
  box-sizing: border-box;
221
231
  }
222
232
 
223
233
  .id-input::placeholder {
224
- color: oklch(0.45 0.03 250);
234
+ color: oklch(from var(--color-base-content) l c h / 50%);
225
235
  }
226
236
 
227
237
  .id-input:focus {
228
- border-color: oklch(0.55 0.12 250);
229
- box-shadow: 0 0 0 2px oklch(0.55 0.12 250 / 0.2);
238
+ border-color: var(--color-primary);
239
+ box-shadow: 0 0 0 2px oklch(from var(--color-primary) l c h / 20%);
240
+ }
241
+
242
+ .id-input-error {
243
+ border-color: var(--color-error);
230
244
  }
231
245
 
232
- .id-input-error,
233
246
  .id-input-error:focus {
234
- border-color: oklch(0.55 0.20 25);
235
- box-shadow: 0 0 0 2px oklch(0.55 0.20 25 / 0.2);
247
+ border-color: var(--color-error);
248
+ box-shadow: 0 0 0 2px oklch(from var(--color-error) l c h / 20%);
236
249
  }
237
250
 
238
251
  .id-field-footer {
239
252
  display: flex;
240
- align-items: center;
241
253
  justify-content: space-between;
254
+ align-items: flex-start;
242
255
  gap: 0.5rem;
243
256
  margin-top: -0.125rem;
244
- min-height: 1rem;
245
257
  }
246
258
 
247
259
  .id-error {
248
260
  margin: 0;
249
261
  font-size: 0.75rem;
250
- color: oklch(0.65 0.18 25);
262
+ color: var(--color-error);
251
263
  line-height: 1.4;
252
264
  }
253
265
 
254
266
  .id-char-count {
255
267
  margin-left: auto;
256
268
  font-size: 0.75rem;
257
- color: oklch(0.50 0.03 250);
258
- font-variant-numeric: tabular-nums;
269
+ color: oklch(from var(--color-base-content) l c h / 55%);
259
270
  white-space: nowrap;
260
271
  flex-shrink: 0;
261
272
  }
@@ -277,34 +288,34 @@
277
288
  border-radius: 0.5rem;
278
289
  border: 1px solid transparent;
279
290
  background: transparent;
280
- color: oklch(0.80 0.04 250);
291
+ color: oklch(from var(--color-base-content) l c h / 95%);
281
292
  cursor: pointer;
282
293
  transition: background 0.1s ease, border-color 0.1s ease, color 0.1s ease;
283
294
  white-space: nowrap;
284
295
  }
285
296
 
286
297
  .id-btn:hover {
287
- background: oklch(0.24 0.03 250);
288
- border-color: oklch(0.32 0.04 250);
298
+ background: var(--color-base-100);
299
+ border-color: var(--color-base-100);
289
300
  }
290
301
 
291
302
  .id-btn-cancel:focus-visible {
292
- outline: 2px solid oklch(0.60 0.10 250);
303
+ outline: 2px solid var(--color-primary);
293
304
  outline-offset: 2px;
294
305
  }
295
306
 
296
307
  .id-btn-confirm {
297
- background: oklch(0.28 0.04 250 / 0.6);
298
- border-color: oklch(0.40 0.05 250);
308
+ background: oklch(from var(--color-base-100) l c h / 60%);
309
+ border-color: oklch(from var(--color-base-content) l c h / 25%);
299
310
  }
300
311
 
301
312
  .id-btn-confirm:hover {
302
- background: oklch(0.34 0.06 250);
303
- border-color: oklch(0.50 0.08 250);
313
+ background: color-mix(in oklch, var(--color-primary) 20%, var(--color-base-300));
314
+ border-color: color-mix(in oklch, var(--color-primary) 40%, transparent);
304
315
  }
305
316
 
306
317
  .id-btn-confirm:focus-visible {
307
- outline: 2px solid oklch(0.60 0.10 250);
318
+ outline: 2px solid var(--color-primary);
308
319
  outline-offset: 2px;
309
320
  }
310
321
 
@@ -312,11 +323,11 @@
312
323
  font-family: ui-monospace, monospace;
313
324
  font-size: 0.65rem;
314
325
  padding: 0.0625rem 0.3125rem;
315
- background: oklch(0.22 0.02 250);
316
- border: 1px solid oklch(0.30 0.03 250);
326
+ background: var(--color-base-100);
327
+ border: 1px solid var(--color-base-100);
317
328
  border-bottom-width: 2px;
318
329
  border-radius: 0.25rem;
319
- color: oklch(0.75 0.04 250);
330
+ color: oklch(from var(--color-base-content) l c h / 85%);
320
331
  line-height: 1.6;
321
332
  }
322
333
 
@@ -15,7 +15,33 @@
15
15
  * { key: 'k / ↑', description: 'Focus previous item' },
16
16
  * { key: 'Enter', description: 'Open detail' },
17
17
  * ]} />
18
+ *
19
+ * Or feed it from a shortcut registry (createShortcutRegistry) — the
20
+ * overlay lists whatever can fire right now, grouped by context, and
21
+ * pushes a modal context while open so page shortcuts don't fire behind it:
22
+ *
23
+ * <KeyboardShortcutsOverlay {registry} />
24
+ *
25
+ * Precedence: `sections` > `registry` > `shortcuts`. The overlay owns `?` —
26
+ * don't register it in the registry too.
27
+ *
28
+ * App-specific sections (marks, list motions, cockpit panels…) that the
29
+ * overlay itself doesn't know about come in via `extraSections` — they
30
+ * render after the primary content, before the meta rows, in EVERY mode:
31
+ *
32
+ * <KeyboardShortcutsOverlay {registry} extraSections={[
33
+ * { title: 'Marks & jumps (Vim)', shortcuts: [{ key: "m{a-z}", description: 'Drop a mark' }] },
34
+ * ]} />
35
+ *
36
+ * For content the section renderer can't express, an `extra` snippet is
37
+ * rendered last (just above the meta rows):
38
+ *
39
+ * <KeyboardShortcutsOverlay {registry}>
40
+ * {#snippet extra()}<div>…custom footer…</div>{/snippet}
41
+ * </KeyboardShortcutsOverlay>
18
42
  */
43
+ import type { Snippet } from 'svelte';
44
+ import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
19
45
 
20
46
  export interface KeyboardShortcut {
21
47
  key: string;
@@ -37,6 +63,9 @@
37
63
  let {
38
64
  shortcuts = [],
39
65
  sections,
66
+ registry,
67
+ extraSections,
68
+ extra,
40
69
  title = 'Keyboard Shortcuts',
41
70
  open = $bindable(false)
42
71
  }: {
@@ -47,6 +76,25 @@
47
76
  * precedence over the flat `shortcuts` prop.
48
77
  */
49
78
  sections?: ShortcutSection[];
79
+ /**
80
+ * Shortcut registry to read sections from (active contexts, top of
81
+ * the stack first). Ignored when `sections` is given.
82
+ */
83
+ registry?: ShortcutRegistry;
84
+ /**
85
+ * Extra app-provided sections (e.g. marks, list motions, cockpit
86
+ * panels) appended after the primary content and before the meta
87
+ * rows, regardless of whether the primary content came from
88
+ * `sections`, `registry` or the flat `shortcuts` list. Same shape
89
+ * as `sections`, with per-key `color` tinting supported.
90
+ */
91
+ extraSections?: ShortcutSection[];
92
+ /**
93
+ * Fully custom footer content, rendered after `extraSections` and
94
+ * just above the built-in meta rows. Use when the section renderer
95
+ * (title + key/description rows) can't express the markup.
96
+ */
97
+ extra?: Snippet;
50
98
  title?: string;
51
99
  open?: boolean;
52
100
  } = $props();
@@ -60,6 +108,19 @@
60
108
  open = isOpen;
61
109
  });
62
110
 
111
+ // Registry sections are snapshotted on open — BEFORE pushing the overlay's
112
+ // own modal context, which would otherwise hide every page context.
113
+ let registrySections = $state<ShortcutSection[] | null>(null);
114
+ $effect(() => {
115
+ if (!isOpen || !registry) return;
116
+ registrySections = registry.toSections();
117
+ return registry.pushContext('keyboard-shortcuts-overlay', { modal: true });
118
+ });
119
+
120
+ const shownSections = $derived(
121
+ sections && sections.length > 0 ? sections : registry ? registrySections : null
122
+ );
123
+
63
124
  // Track which key label is currently depressed
64
125
  let depressedKey = $state<string | null>(null);
65
126
  let depressTimer: ReturnType<typeof setTimeout> | null = null;
@@ -104,7 +165,15 @@
104
165
  if (!pressed) return false;
105
166
  // Match single-char labels directly
106
167
  const parts = keyLabel.split(/\s*\/\s*/);
107
- return parts.some(p => p.trim() === pressed || p.trim().toLowerCase() === pressed.toLowerCase());
168
+ const p = pressed.toLowerCase();
169
+ return parts.some((part) => {
170
+ const t = part.trim();
171
+ if (t === pressed || t.toLowerCase() === p) return true;
172
+ // Combo labels ("Ctrl+K", "⇧⌘K") — match on the key itself.
173
+ const base = (t.length > 1 && t.includes('+') ? t.slice(t.lastIndexOf('+', t.length - 2) + 1) : t)
174
+ .replace(/^[⌃⌥⇧⌘]+/, '');
175
+ return base !== t && base.toLowerCase() === p;
176
+ });
108
177
  }
109
178
 
110
179
  $effect(() => {
@@ -143,8 +212,8 @@
143
212
  </button>
144
213
  </div>
145
214
  <div class="kso-list">
146
- {#if sections && sections.length > 0}
147
- {#each sections as section, sIdx (section.title + '::' + sIdx)}
215
+ {#if shownSections && shownSections.length > 0}
216
+ {#each shownSections as section, sIdx (section.title + '::' + sIdx)}
148
217
  <div class="kso-section-title">{section.title}</div>
149
218
  {#each section.shortcuts as { key, description, color }, i (section.title + '::' + key + '::' + i)}
150
219
  <div class="kso-row">
@@ -172,6 +241,28 @@
172
241
  </div>
173
242
  {/each}
174
243
  {/if}
244
+ {#if extraSections}
245
+ {#each extraSections as section, sIdx (section.title + '::extra::' + sIdx)}
246
+ <div class="kso-section-title">{section.title}</div>
247
+ {#each section.shortcuts as { key, description, color }, i (section.title + '::extra::' + key + '::' + i)}
248
+ <div class="kso-row">
249
+ <kbd
250
+ class="kso-key"
251
+ class:kso-key-pressed={keyMatchesPressed(key, depressedKey)}
252
+ style={color
253
+ ? `color:${color};border-color:color-mix(in oklch,${color} 50%,oklch(0.35 0.02 250));background:color-mix(in oklch,${color} 12%,oklch(0.25 0.02 250))`
254
+ : ''}
255
+ >
256
+ {key}
257
+ </kbd>
258
+ <span class="kso-description">{description}</span>
259
+ </div>
260
+ {/each}
261
+ {/each}
262
+ {/if}
263
+ {#if extra}
264
+ {@render extra()}
265
+ {/if}
175
266
  <div class="kso-row kso-row-meta">
176
267
  <kbd class="kso-key" class:kso-key-pressed={keyMatchesPressed('?', depressedKey)}>?</kbd>
177
268
  <span class="kso-description">Toggle this overlay</span>
@@ -1,20 +1,46 @@
1
1
  /**
2
- * KeyboardShortcutsOverlay — press `?` to toggle a modal listing the
3
- * keyboard shortcuts available on the current page. Press `?` again or
4
- * `Escape` to dismiss. Input/contenteditable fields and Ctrl/Meta/Alt
5
- * combos are ignored so the shortcut never interferes with typing.
6
- *
7
- * While open, physical keydown events depress the matching on-screen keycap
8
- * with a brief translateY(1px) + tint (var(--motion-instant)).
9
- *
10
- * Usage:
11
- *
12
- * <KeyboardShortcutsOverlay shortcuts={[
13
- * { key: 'j / ↓', description: 'Focus next item' },
14
- * { key: 'k / ↑', description: 'Focus previous item' },
15
- * { key: 'Enter', description: 'Open detail' },
16
- * ]} />
17
- */
2
+ * KeyboardShortcutsOverlay — press `?` to toggle a modal listing the
3
+ * keyboard shortcuts available on the current page. Press `?` again or
4
+ * `Escape` to dismiss. Input/contenteditable fields and Ctrl/Meta/Alt
5
+ * combos are ignored so the shortcut never interferes with typing.
6
+ *
7
+ * While open, physical keydown events depress the matching on-screen keycap
8
+ * with a brief translateY(1px) + tint (var(--motion-instant)).
9
+ *
10
+ * Usage:
11
+ *
12
+ * <KeyboardShortcutsOverlay shortcuts={[
13
+ * { key: 'j / ↓', description: 'Focus next item' },
14
+ * { key: 'k / ↑', description: 'Focus previous item' },
15
+ * { key: 'Enter', description: 'Open detail' },
16
+ * ]} />
17
+ *
18
+ * Or feed it from a shortcut registry (createShortcutRegistry) — the
19
+ * overlay lists whatever can fire right now, grouped by context, and
20
+ * pushes a modal context while open so page shortcuts don't fire behind it:
21
+ *
22
+ * <KeyboardShortcutsOverlay {registry} />
23
+ *
24
+ * Precedence: `sections` > `registry` > `shortcuts`. The overlay owns `?` —
25
+ * don't register it in the registry too.
26
+ *
27
+ * App-specific sections (marks, list motions, cockpit panels…) that the
28
+ * overlay itself doesn't know about come in via `extraSections` — they
29
+ * render after the primary content, before the meta rows, in EVERY mode:
30
+ *
31
+ * <KeyboardShortcutsOverlay {registry} extraSections={[
32
+ * { title: 'Marks & jumps (Vim)', shortcuts: [{ key: "m{a-z}", description: 'Drop a mark' }] },
33
+ * ]} />
34
+ *
35
+ * For content the section renderer can't express, an `extra` snippet is
36
+ * rendered last (just above the meta rows):
37
+ *
38
+ * <KeyboardShortcutsOverlay {registry}>
39
+ * {#snippet extra()}<div>…custom footer…</div>{/snippet}
40
+ * </KeyboardShortcutsOverlay>
41
+ */
42
+ import type { Snippet } from 'svelte';
43
+ import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
18
44
  export interface KeyboardShortcut {
19
45
  key: string;
20
46
  description: string;
@@ -37,6 +63,25 @@ type $$ComponentProps = {
37
63
  * precedence over the flat `shortcuts` prop.
38
64
  */
39
65
  sections?: ShortcutSection[];
66
+ /**
67
+ * Shortcut registry to read sections from (active contexts, top of
68
+ * the stack first). Ignored when `sections` is given.
69
+ */
70
+ registry?: ShortcutRegistry;
71
+ /**
72
+ * Extra app-provided sections (e.g. marks, list motions, cockpit
73
+ * panels) appended after the primary content and before the meta
74
+ * rows, regardless of whether the primary content came from
75
+ * `sections`, `registry` or the flat `shortcuts` list. Same shape
76
+ * as `sections`, with per-key `color` tinting supported.
77
+ */
78
+ extraSections?: ShortcutSection[];
79
+ /**
80
+ * Fully custom footer content, rendered after `extraSections` and
81
+ * just above the built-in meta rows. Use when the section renderer
82
+ * (title + key/description rows) can't express the markup.
83
+ */
84
+ extra?: Snippet;
40
85
  title?: string;
41
86
  open?: boolean;
42
87
  };
@@ -21,6 +21,8 @@
21
21
  | 'trash'
22
22
  | 'plus'
23
23
  | 'type'
24
+ | 'pause'
25
+ | 'play'
24
26
  size?: number
25
27
  class?: string
26
28
  } = $props()
@@ -83,6 +85,27 @@
83
85
  >
84
86
  <rect width="18" height="18" x="3" y="3" rx="2" />
85
87
  </svg>
88
+ {:else if name === 'pause'}
89
+ <svg
90
+ width={size}
91
+ height={size}
92
+ viewBox="0 0 24 24"
93
+ fill="currentColor"
94
+ class={className}
95
+ >
96
+ <rect x="6" y="4" width="4" height="16" rx="1" />
97
+ <rect x="14" y="4" width="4" height="16" rx="1" />
98
+ </svg>
99
+ {:else if name === 'play'}
100
+ <svg
101
+ width={size}
102
+ height={size}
103
+ viewBox="0 0 24 24"
104
+ fill="currentColor"
105
+ class={className}
106
+ >
107
+ <path d="M8 5.14v13.72a1 1 0 0 0 1.5.87l11-6.86a1 1 0 0 0 0-1.74l-11-6.86a1 1 0 0 0-1.5.87Z" />
108
+ </svg>
86
109
  {:else if name === 'loader'}
87
110
  <svg
88
111
  width={size}
@@ -1,5 +1,5 @@
1
1
  type $$ComponentProps = {
2
- name: 'mic' | 'video' | 'monitor' | 'square' | 'loader' | 'x' | 'file' | 'trash' | 'plus' | 'type';
2
+ name: 'mic' | 'video' | 'monitor' | 'square' | 'loader' | 'x' | 'file' | 'trash' | 'plus' | 'type' | 'pause' | 'play';
3
3
  size?: number;
4
4
  class?: string;
5
5
  };
@@ -0,0 +1,137 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ /**
5
+ * Generic thumbnail item — the decoupled chimaro shape. Unlike chimaro's
6
+ * UnifiedMediaItem this carries only what a thumbnail needs; callers with
7
+ * richer records pass a projection ({ id, url, alt }) or render a `cell`
8
+ * snippet for full control.
9
+ */
10
+ export interface ThumbnailItem {
11
+ id: string;
12
+ /** Image URL; when absent the alt text renders as a text tile. */
13
+ url?: string | null;
14
+ /** Alt text / display label. */
15
+ alt: string;
16
+ }
17
+
18
+ interface Props {
19
+ items: ThumbnailItem[];
20
+ /** How many thumbnails to show before collapsing the rest into a +N counter. Default 8. */
21
+ max?: number;
22
+ /** Tile size. Default 'md'. */
23
+ size?: 'xs' | 'sm' | 'md' | 'lg';
24
+ /** Show remove (×) buttons on hover; requires `onRemove`. */
25
+ removable?: boolean;
26
+ /** Show the dashed add tile at the end; requires `onAdd`. */
27
+ addable?: boolean;
28
+ /** Tooltip / aria-label for the add tile. Default 'Add items'. */
29
+ addLabel?: string;
30
+ /** Fired when a thumbnail is clicked. */
31
+ onSelect?: (item: ThumbnailItem) => void;
32
+ /** Fired when a remove (×) button is clicked. */
33
+ onRemove?: (item: ThumbnailItem) => void;
34
+ /** Fired when the add tile is clicked. */
35
+ onAdd?: () => void;
36
+ /**
37
+ * Replace the default image/text tile rendering for each item — e.g. a
38
+ * video poster, an initials avatar, a FileThumbnail. The snippet renders
39
+ * inside the clickable tile, so it should fill it (`h-full w-full`).
40
+ */
41
+ cell?: Snippet<[ThumbnailItem]>;
42
+ }
43
+
44
+ let {
45
+ items,
46
+ max = 8,
47
+ size = 'md',
48
+ removable = false,
49
+ addable = false,
50
+ addLabel = 'Add items',
51
+ onSelect,
52
+ onRemove,
53
+ onAdd,
54
+ cell
55
+ }: Props = $props();
56
+
57
+ const sizeClasses: Record<NonNullable<Props['size']>, string> = {
58
+ xs: 'h-8 w-8',
59
+ sm: 'h-12 w-12',
60
+ md: 'h-16 w-16',
61
+ lg: 'h-20 w-20'
62
+ };
63
+
64
+ const visible = $derived(items.slice(0, max));
65
+ const overflow = $derived(Math.max(0, items.length - max));
66
+
67
+ function shortLabel(alt: string): string {
68
+ return alt.length > 14 ? `${alt.slice(0, 14)}…` : alt;
69
+ }
70
+ </script>
71
+
72
+ <div class="flex flex-wrap items-center gap-2">
73
+ {#each visible as item (item.id)}
74
+ <div class="group relative">
75
+ <button
76
+ type="button"
77
+ class="{sizeClasses[size]} cursor-pointer overflow-hidden rounded border transition-all duration-200
78
+ {removable ? 'border-2 border-accent' : 'border-base-300 hover:border-primary'}"
79
+ onclick={() => onSelect?.(item)}
80
+ title={item.alt}
81
+ aria-label={item.alt}
82
+ >
83
+ {#if cell}
84
+ {@render cell(item)}
85
+ {:else if item.url}
86
+ <img src={item.url} alt={item.alt} class="h-full w-full object-cover" loading="lazy" />
87
+ {:else}
88
+ <div class="flex h-full w-full items-center justify-center bg-base-200 p-1">
89
+ <span class="text-2xs text-center font-semibold leading-tight break-all text-base-content/80">
90
+ {shortLabel(item.alt)}
91
+ </span>
92
+ </div>
93
+ {/if}
94
+ </button>
95
+
96
+ {#if removable && onRemove}
97
+ <button
98
+ type="button"
99
+ class="absolute -top-1 -right-1 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-error text-error-content opacity-0 transition-opacity group-hover:opacity-100"
100
+ onclick={(e) => {
101
+ e.stopPropagation();
102
+ onRemove(item);
103
+ }}
104
+ title="Remove"
105
+ aria-label="Remove {item.alt}"
106
+ >
107
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" class="h-2.5 w-2.5">
108
+ <path d="M18 6 6 18M6 6l12 12" />
109
+ </svg>
110
+ </button>
111
+ {/if}
112
+ </div>
113
+ {/each}
114
+
115
+ {#if overflow > 0}
116
+ <div
117
+ class="{sizeClasses[size]} flex items-center justify-center rounded border border-base-300 bg-base-200"
118
+ title="{overflow} more item{overflow === 1 ? '' : 's'}"
119
+ >
120
+ <span class="text-xs opacity-70">+{overflow}</span>
121
+ </div>
122
+ {/if}
123
+
124
+ {#if addable && onAdd}
125
+ <button
126
+ type="button"
127
+ class="{sizeClasses[size]} flex scale-90 cursor-pointer items-center justify-center rounded border-2 border-dashed border-primary/30 bg-primary/5 transition-all duration-200 hover:border-primary/50 hover:bg-primary/10"
128
+ onclick={onAdd}
129
+ title={addLabel}
130
+ aria-label={addLabel}
131
+ >
132
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="h-4 w-4 text-primary/70">
133
+ <path d="M12 5v14M5 12h14" />
134
+ </svg>
135
+ </button>
136
+ {/if}
137
+ </div>
@@ -0,0 +1,42 @@
1
+ import type { Snippet } from 'svelte';
2
+ /**
3
+ * Generic thumbnail item — the decoupled chimaro shape. Unlike chimaro's
4
+ * UnifiedMediaItem this carries only what a thumbnail needs; callers with
5
+ * richer records pass a projection ({ id, url, alt }) or render a `cell`
6
+ * snippet for full control.
7
+ */
8
+ export interface ThumbnailItem {
9
+ id: string;
10
+ /** Image URL; when absent the alt text renders as a text tile. */
11
+ url?: string | null;
12
+ /** Alt text / display label. */
13
+ alt: string;
14
+ }
15
+ interface Props {
16
+ items: ThumbnailItem[];
17
+ /** How many thumbnails to show before collapsing the rest into a +N counter. Default 8. */
18
+ max?: number;
19
+ /** Tile size. Default 'md'. */
20
+ size?: 'xs' | 'sm' | 'md' | 'lg';
21
+ /** Show remove (×) buttons on hover; requires `onRemove`. */
22
+ removable?: boolean;
23
+ /** Show the dashed add tile at the end; requires `onAdd`. */
24
+ addable?: boolean;
25
+ /** Tooltip / aria-label for the add tile. Default 'Add items'. */
26
+ addLabel?: string;
27
+ /** Fired when a thumbnail is clicked. */
28
+ onSelect?: (item: ThumbnailItem) => void;
29
+ /** Fired when a remove (×) button is clicked. */
30
+ onRemove?: (item: ThumbnailItem) => void;
31
+ /** Fired when the add tile is clicked. */
32
+ onAdd?: () => void;
33
+ /**
34
+ * Replace the default image/text tile rendering for each item — e.g. a
35
+ * video poster, an initials avatar, a FileThumbnail. The snippet renders
36
+ * inside the clickable tile, so it should fill it (`h-full w-full`).
37
+ */
38
+ cell?: Snippet<[ThumbnailItem]>;
39
+ }
40
+ declare const ThumbnailGrid: import("svelte").Component<Props, {}, "">;
41
+ type ThumbnailGrid = ReturnType<typeof ThumbnailGrid>;
42
+ export default ThumbnailGrid;