@joewinke/jatui 0.23.0 → 0.25.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 (36) hide show
  1. package/CLAUDE.md +32 -2
  2. package/dist/components/AudioRecorder.svelte +84 -304
  3. package/dist/components/AudioRecorder.svelte.d.ts +1 -7
  4. package/dist/components/BulkActionBar.svelte +40 -0
  5. package/dist/components/BulkActionBar.svelte.d.ts +9 -0
  6. package/dist/components/ConfirmDialog.svelte +39 -35
  7. package/dist/components/CopyButton.svelte +5 -2
  8. package/dist/components/FloatingActionBar.svelte +55 -1
  9. package/dist/components/FloatingActionBar.svelte.d.ts +8 -1
  10. package/dist/components/InputDialog.svelte +49 -38
  11. package/dist/components/KeyboardShortcutsOverlay.svelte +54 -0
  12. package/dist/components/KeyboardShortcutsOverlay.svelte.d.ts +30 -0
  13. package/dist/components/bulkActionBarKeys.d.ts +40 -0
  14. package/dist/components/bulkActionBarKeys.js +38 -0
  15. package/dist/components/linked-columns/LinkedColumns.svelte +10 -22
  16. package/dist/components/linked-columns/LinkedColumns.svelte.d.ts +1 -9
  17. package/dist/components/linked-columns/types.d.ts +9 -0
  18. package/dist/components/linked-columns/types.js +1 -0
  19. package/dist/components/replay/ChapterTimeline.svelte +43 -25
  20. package/dist/components/replay/ChapterTimeline.svelte.d.ts +26 -1
  21. package/dist/components/session-nav/transcriptModel.d.ts +46 -0
  22. package/dist/components/session-nav/transcriptModel.js +143 -19
  23. package/dist/components/toast/ToastItem.svelte +47 -2
  24. package/dist/index.d.ts +8 -5
  25. package/dist/index.js +7 -2
  26. package/dist/stores/toast.d.ts +8 -0
  27. package/dist/stores/toast.js +2 -0
  28. package/dist/utils/audioRecorder.d.ts +28 -0
  29. package/dist/utils/audioRecorder.js +58 -0
  30. package/dist/utils/audioRecorderCore.d.ts +132 -0
  31. package/dist/utils/audioRecorderCore.js +447 -0
  32. package/dist/utils/clipboard.d.ts +25 -0
  33. package/dist/utils/clipboard.js +59 -0
  34. package/dist/utils/dateFormatters.d.ts +11 -2
  35. package/dist/utils/dateFormatters.js +22 -2
  36. package/package.json +1 -1
@@ -3,6 +3,11 @@ export interface BulkAction {
3
3
  label: string;
4
4
  /** Optional SVG path string for a 24×24 icon. */
5
5
  icon?: string;
6
+ /** Optional single-key hotkey (e.g. `'c'`) — fires `onSelect` while the
7
+ * bar is visible and not loading. Ignored while typing (isTypingTarget),
8
+ * for modifier combos and for non-printable keys. Rendered as a <kbd>
9
+ * hint on the button. */
10
+ key?: string;
6
11
  /** Called when the action is clicked. */
7
12
  onSelect: () => void;
8
13
  /** Renders in the danger variant. Default false. */
@@ -37,6 +42,10 @@ interface Props {
37
42
  onClear?: () => void;
38
43
  /** Loading state — disables all actions and shows a spinner. */
39
44
  loading?: boolean;
45
+ /** When set, replaces the action buttons with a spinner + this message
46
+ * (role="status", aria-live="polite") — the "bulk action in flight"
47
+ * presentation from the IDE's fork. */
48
+ loadingMessage?: string | null;
40
49
  /** Label for the count. Default "selected". */
41
50
  countLabel?: string;
42
51
  }
@@ -151,10 +151,15 @@
151
151
  {/if}
152
152
 
153
153
  <style>
154
+ /* Theme tokens (jatui-is2ow.3): every colour derives from var(--color-*) —
155
+ relative-color `oklch(from var(--x) l c h / a)` keeps the token's hue/chroma
156
+ and only swaps alpha, so light and dark themes both render correctly.
157
+ --n-00 (pure black) is a jat-IDE neutral; the inline fallback keeps
158
+ standalone consumers (no app.css) rendering the same value. */
154
159
  .cd-scrim {
155
160
  position: fixed;
156
161
  inset: 0;
157
- background: oklch(0 0 0 / 0.45);
162
+ background: oklch(from var(--n-00, oklch(0 0 0)) l c h / 0.45);
158
163
  z-index: 9990;
159
164
  backdrop-filter: blur(2px);
160
165
  }
@@ -175,21 +180,21 @@
175
180
  flex-direction: column;
176
181
  gap: 0.625rem;
177
182
  padding: 1rem 1.125rem 0.625rem;
178
- background: color-mix(in oklch, var(--color-base-100) 98%, transparent);
179
- border: 1px solid var(--color-base-300);
183
+ background: oklch(from var(--color-base-200) l c h / 98%);
184
+ border: 1px solid var(--color-base-100);
180
185
  border-radius: 0.875rem;
181
186
  box-shadow:
182
- 0 20px 60px oklch(0 0 0 / 0.55),
183
- 0 0 0 1px color-mix(in oklch, var(--color-base-300) 50%, transparent);
187
+ 0 20px 60px oklch(from var(--n-00, oklch(0 0 0)) l c h / 0.55),
188
+ 0 0 0 1px oklch(from var(--color-base-100) l c h / 50%);
184
189
  backdrop-filter: blur(16px);
185
190
  }
186
191
 
187
192
  .cd-box.cd-danger {
188
- border-color: color-mix(in oklch, var(--color-error) 55%, transparent);
193
+ border-color: oklch(from var(--color-error) l c h / 55%);
189
194
  box-shadow:
190
- 0 20px 60px oklch(0 0 0 / 0.6),
191
- 0 0 0 1px color-mix(in oklch, var(--color-error) 25%, transparent),
192
- 0 0 30px color-mix(in oklch, var(--color-error) 12%, transparent);
195
+ 0 20px 60px oklch(from var(--n-00, oklch(0 0 0)) l c h / 0.6),
196
+ 0 0 0 1px oklch(from var(--color-error) l c h / 25%),
197
+ 0 0 30px oklch(from var(--color-error) l c h / 12%);
193
198
  }
194
199
 
195
200
  .cd-header {
@@ -206,7 +211,7 @@
206
211
  display: flex;
207
212
  align-items: center;
208
213
  justify-content: center;
209
- background: color-mix(in oklch, var(--color-info) 35%, transparent);
214
+ background: color-mix(in oklch, var(--color-info) 20%, transparent);
210
215
  border: 1px solid color-mix(in oklch, var(--color-info) 40%, transparent);
211
216
  color: var(--color-info);
212
217
  }
@@ -217,9 +222,9 @@
217
222
  }
218
223
 
219
224
  .cd-icon.cd-icon-danger {
220
- background: color-mix(in oklch, var(--color-error) 35%, transparent);
221
- border-color: color-mix(in oklch, var(--color-error) 40%, transparent);
222
- color: var(--color-error-content);
225
+ background: color-mix(in oklch, var(--color-error) 20%, transparent);
226
+ border-color: oklch(from var(--color-error) l c h / 40%);
227
+ color: var(--color-error);
223
228
  }
224
229
 
225
230
  .cd-title-group {
@@ -241,13 +246,12 @@
241
246
  .cd-body {
242
247
  margin: 0;
243
248
  font-size: 0.8rem;
244
- color: color-mix(in oklch, var(--color-base-content) 65%, transparent);
249
+ color: oklch(from var(--color-base-content) l c h / 70%);
245
250
  line-height: 1.5;
246
251
  }
247
252
 
248
253
  .cd-actions {
249
254
  display: flex;
250
- justify-content: flex-end;
251
255
  align-items: center;
252
256
  gap: 0.375rem;
253
257
  padding-top: 0.125rem;
@@ -263,15 +267,15 @@
263
267
  border-radius: 0.5rem;
264
268
  border: 1px solid transparent;
265
269
  background: transparent;
266
- color: color-mix(in oklch, var(--color-base-content) 85%, transparent);
270
+ color: oklch(from var(--color-base-content) l c h / 95%);
267
271
  cursor: pointer;
268
272
  transition: background 0.1s ease, border-color 0.1s ease, color 0.1s ease;
269
273
  white-space: nowrap;
270
274
  }
271
275
 
272
276
  .cd-btn:hover {
273
- background: var(--color-base-200);
274
- border-color: var(--color-base-300);
277
+ background: var(--color-base-100);
278
+ border-color: var(--color-base-100);
275
279
  }
276
280
 
277
281
  .cd-btn-cancel:focus-visible {
@@ -280,25 +284,25 @@
280
284
  }
281
285
 
282
286
  .cd-btn-confirm {
283
- background: color-mix(in oklch, var(--color-base-300) 60%, transparent);
284
- border-color: var(--color-base-300);
287
+ background: oklch(from var(--color-base-100) l c h / 60%);
288
+ border-color: color-mix(in oklch, var(--color-primary) 40%, transparent);
285
289
  }
286
290
 
287
291
  .cd-btn-confirm:hover {
288
- background: var(--color-base-200);
289
- border-color: color-mix(in oklch, var(--color-base-content) 30%, transparent);
292
+ background: color-mix(in oklch, var(--color-primary) 20%, var(--color-base-200));
293
+ border-color: color-mix(in oklch, var(--color-primary) 50%, transparent);
290
294
  }
291
295
 
292
296
  .cd-btn-danger {
293
- background: color-mix(in oklch, var(--color-error) 45%, transparent);
294
- border-color: color-mix(in oklch, var(--color-error) 55%, transparent);
295
- color: var(--color-error-content);
297
+ background: color-mix(in oklch, var(--color-error) 30%, transparent);
298
+ border-color: oklch(from var(--color-error) l c h / 55%);
299
+ color: oklch(from var(--color-error) calc(l + 0.2) c h);
296
300
  }
297
301
 
298
302
  .cd-btn-danger:hover {
299
- background: color-mix(in oklch, var(--color-error) 65%, transparent);
300
- border-color: color-mix(in oklch, var(--color-error) 70%, transparent);
301
- color: var(--color-error-content);
303
+ background: color-mix(in oklch, var(--color-error) 45%, transparent);
304
+ border-color: oklch(from var(--color-error) l c h / 70%);
305
+ color: oklch(from var(--color-error) calc(l + 0.25) c h);
302
306
  }
303
307
 
304
308
  .cd-btn-confirm:focus-visible,
@@ -311,11 +315,11 @@
311
315
  font-family: ui-monospace, monospace;
312
316
  font-size: 0.65rem;
313
317
  padding: 0.0625rem 0.3125rem;
314
- background: var(--color-base-200);
315
- border: 1px solid var(--color-base-300);
318
+ background: var(--color-base-100);
319
+ border: 1px solid var(--color-base-100);
316
320
  border-bottom-width: 2px;
317
321
  border-radius: 0.25rem;
318
- color: color-mix(in oklch, var(--color-base-content) 70%, transparent);
322
+ color: oklch(from var(--color-base-content) l c h / 85%);
319
323
  line-height: 1.6;
320
324
  }
321
325
 
@@ -323,7 +327,7 @@
323
327
  margin-left: auto;
324
328
  font-family: ui-monospace, monospace;
325
329
  font-size: 0.7rem;
326
- color: color-mix(in oklch, var(--color-base-content) 45%, transparent);
330
+ color: oklch(from var(--color-base-content) l c h / 55%);
327
331
  font-variant-numeric: tabular-nums;
328
332
  min-width: 2ch;
329
333
  text-align: right;
@@ -331,20 +335,20 @@
331
335
 
332
336
  .cd-progress {
333
337
  height: 2px;
334
- background: var(--color-base-200);
338
+ background: var(--color-base-100);
335
339
  border-radius: 999px;
336
340
  overflow: hidden;
337
341
  }
338
342
 
339
343
  .cd-progress-fill {
340
344
  height: 100%;
341
- background: linear-gradient(90deg, var(--color-info), var(--color-info));
345
+ background: linear-gradient(90deg, var(--color-primary), var(--color-info));
342
346
  border-radius: 999px;
343
347
  transition: width 60ms linear;
344
348
  }
345
349
 
346
350
  .cd-progress-fill.cd-progress-danger {
347
- background: linear-gradient(90deg, var(--color-error), var(--color-error));
351
+ background: linear-gradient(90deg, var(--color-error), var(--color-warning));
348
352
  }
349
353
 
350
354
  @media (prefers-reduced-motion: reduce) {
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import { copyText } from '../utils/clipboard';
3
+
2
4
  interface Props {
3
5
  text?: string;
4
6
  getText?: () => string | Promise<string>;
@@ -42,14 +44,15 @@
42
44
  if (copied) return;
43
45
  try {
44
46
  const value = getText ? await getText() : (text ?? '');
45
- await navigator.clipboard.writeText(value);
47
+ const ok = await copyText(value);
48
+ if (!ok) return;
46
49
  copied = true;
47
50
  clearTimeout(timer);
48
51
  timer = setTimeout(() => {
49
52
  copied = false;
50
53
  }, 1400);
51
54
  } catch {
52
- // clipboard not available — no-op
55
+ // clipboard unavailable — no-op
53
56
  }
54
57
  }
55
58
  </script>
@@ -11,6 +11,11 @@
11
11
  * loading={bulkActionLoading}
12
12
  * onClear={clearSelection}
13
13
  * />
14
+ *
15
+ * Per-action `key` renders a <kbd> hotkey hint on the button (advisory —
16
+ * the matching keydown handling lives in the host/BulkActionBar). While
17
+ * `loading` with a `loadingMessage` set, the buttons are replaced by a
18
+ * spinner + message row (matching the IDE's fork, audit jat-1alfp #15).
14
19
  */
15
20
 
16
21
  import { fade } from 'svelte/transition';
@@ -24,6 +29,8 @@
24
29
  icon?: string;
25
30
  /** Color variant */
26
31
  variant?: 'default' | 'primary' | 'danger' | 'warning' | 'success';
32
+ /** Single-key hotkey hint rendered as a <kbd> chip (advisory only). */
33
+ key?: string;
27
34
  /** Called when clicked */
28
35
  onclick: () => void;
29
36
  }
@@ -59,8 +66,13 @@
59
66
  actions?: ActionButton[];
60
67
  /** Dropdown menus (open upward from the bar) */
61
68
  dropdowns?: ActionDropdown[];
62
- /** Disables all buttons while true and shows a spinner */
69
+ /** While loading, disables all buttons. With `loadingMessage` also set,
70
+ * the buttons are replaced by a spinner + message row. */
63
71
  loading?: boolean;
72
+ /** When set, replaces the action buttons with a spinner + this message
73
+ * (role="status", aria-live="polite") — matching the IDE's fork, where
74
+ * this alone signals the in-flight state. */
75
+ loadingMessage?: string | null;
64
76
  /** Called when the "Clear" button is clicked */
65
77
  onClear?: () => void;
66
78
  /** Label for the selection count (default: "selected") */
@@ -72,6 +84,7 @@
72
84
  actions = [],
73
85
  dropdowns = [],
74
86
  loading = false,
87
+ loadingMessage = null,
75
88
  onClear,
76
89
  countLabel = 'selected',
77
90
  }: Props = $props();
@@ -110,6 +123,12 @@
110
123
  <div class="fab" transition:fade={{ duration: 150 }}>
111
124
  <span class="fab-count">{count} {countLabel}</span>
112
125
 
126
+ {#if loadingMessage}
127
+ <div class="fab-loading" role="status" aria-live="polite">
128
+ <span class="fab-spinner"></span>
129
+ <span class="fab-loading-msg">{loadingMessage}</span>
130
+ </div>
131
+ {:else}
113
132
  {#if actions.length > 0}
114
133
  <div class="fab-divider"></div>
115
134
  {#each actions as action}
@@ -118,6 +137,7 @@
118
137
  class="fab-btn {variantClass(action.variant)}"
119
138
  onclick={action.onclick}
120
139
  disabled={loading}
140
+ title={action.key ? `${action.label} (${action.key})` : undefined}
121
141
  >
122
142
  {#if action.icon}
123
143
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
@@ -126,6 +146,9 @@
126
146
  </svg>
127
147
  {/if}
128
148
  {action.label}
149
+ {#if action.key}
150
+ <kbd class="fab-key">{action.key}</kbd>
151
+ {/if}
129
152
  </button>
130
153
  {/each}
131
154
  {/if}
@@ -175,6 +198,7 @@
175
198
  </div>
176
199
  {/each}
177
200
  {/if}
201
+ {/if}
178
202
 
179
203
  {#if onClear}
180
204
  <div class="fab-divider"></div>
@@ -310,6 +334,36 @@
310
334
  to { transform: rotate(360deg); }
311
335
  }
312
336
 
337
+ .fab-key {
338
+ display: inline-flex;
339
+ align-items: center;
340
+ justify-content: center;
341
+ min-width: 1.125rem;
342
+ height: 1.125rem;
343
+ padding: 0 0.25rem;
344
+ background: var(--color-base-100);
345
+ border: 1px solid oklch(from var(--color-base-content) l c h / 35%);
346
+ border-radius: 0.25rem;
347
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
348
+ font-size: 0.6875rem;
349
+ font-weight: 600;
350
+ line-height: 1;
351
+ color: oklch(from var(--color-base-content) l c h / 80%);
352
+ }
353
+
354
+ .fab-loading {
355
+ display: flex;
356
+ align-items: center;
357
+ gap: 0.5rem;
358
+ padding: 0.125rem 0.375rem;
359
+ }
360
+
361
+ .fab-loading-msg {
362
+ font-size: 0.8125rem;
363
+ color: var(--color-primary);
364
+ white-space: nowrap;
365
+ }
366
+
313
367
  /* Dropdown */
314
368
  .fab-dropdown-wrapper {
315
369
  position: relative;
@@ -7,6 +7,8 @@ export interface ActionButton {
7
7
  icon?: string;
8
8
  /** Color variant */
9
9
  variant?: 'default' | 'primary' | 'danger' | 'warning' | 'success';
10
+ /** Single-key hotkey hint rendered as a <kbd> chip (advisory only). */
11
+ key?: string;
10
12
  /** Called when clicked */
11
13
  onclick: () => void;
12
14
  }
@@ -39,8 +41,13 @@ interface Props {
39
41
  actions?: ActionButton[];
40
42
  /** Dropdown menus (open upward from the bar) */
41
43
  dropdowns?: ActionDropdown[];
42
- /** Disables all buttons while true and shows a spinner */
44
+ /** While loading, disables all buttons. With `loadingMessage` also set,
45
+ * the buttons are replaced by a spinner + message row. */
43
46
  loading?: boolean;
47
+ /** When set, replaces the action buttons with a spinner + this message
48
+ * (role="status", aria-live="polite") — matching the IDE's fork, where
49
+ * this alone signals the in-flight state. */
50
+ loadingMessage?: string | null;
44
51
  /** Called when the "Clear" button is clicked */
45
52
  onClear?: () => void;
46
53
  /** Label for the selection count (default: "selected") */
@@ -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
 
@@ -24,7 +24,23 @@
24
24
  *
25
25
  * Precedence: `sections` > `registry` > `shortcuts`. The overlay owns `?` —
26
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>
27
42
  */
43
+ import type { Snippet } from 'svelte';
28
44
  import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
29
45
 
30
46
  export interface KeyboardShortcut {
@@ -48,6 +64,8 @@
48
64
  shortcuts = [],
49
65
  sections,
50
66
  registry,
67
+ extraSections,
68
+ extra,
51
69
  title = 'Keyboard Shortcuts',
52
70
  open = $bindable(false)
53
71
  }: {
@@ -63,6 +81,20 @@
63
81
  * the stack first). Ignored when `sections` is given.
64
82
  */
65
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;
66
98
  title?: string;
67
99
  open?: boolean;
68
100
  } = $props();
@@ -209,6 +241,28 @@
209
241
  </div>
210
242
  {/each}
211
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}
212
266
  <div class="kso-row kso-row-meta">
213
267
  <kbd class="kso-key" class:kso-key-pressed={keyMatchesPressed('?', depressedKey)}>?</kbd>
214
268
  <span class="kso-description">Toggle this overlay</span>