@runwisp/ui 1.3.0 → 1.5.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 (73) hide show
  1. package/package.json +5 -6
  2. package/src/lib/actions/floating.ts +73 -0
  3. package/src/lib/actions/portal.ts +5 -1
  4. package/src/lib/components/Accordion.svelte +6 -2
  5. package/src/lib/components/AccordionItem.svelte +80 -23
  6. package/src/lib/components/Avatar.svelte +0 -1
  7. package/src/lib/components/CodeBlock.svelte +84 -35
  8. package/src/lib/components/CronInput.svelte +5 -2
  9. package/src/lib/components/DataGrid.svelte +95 -133
  10. package/src/lib/components/Drawer.svelte +2 -6
  11. package/src/lib/components/Dropdown.svelte +19 -57
  12. package/src/lib/components/EmptyState.svelte +2 -1
  13. package/src/lib/components/ErrorState.svelte +2 -1
  14. package/src/lib/components/Heading.svelte +19 -12
  15. package/src/lib/components/Kbd.svelte +25 -10
  16. package/src/lib/components/Link.svelte +7 -1
  17. package/src/lib/components/LogConsole.svelte +37 -34
  18. package/src/lib/components/Modal.svelte +44 -33
  19. package/src/lib/components/NotificationBell.svelte +2 -1
  20. package/src/lib/components/PageContainer.svelte +1 -10
  21. package/src/lib/components/PageHeader.svelte +8 -4
  22. package/src/lib/components/Popover.svelte +9 -51
  23. package/src/lib/components/Progress.svelte +19 -6
  24. package/src/lib/components/Radio.svelte +16 -4
  25. package/src/lib/components/RadioGroup.svelte +15 -2
  26. package/src/lib/components/Select.svelte +13 -45
  27. package/src/lib/components/Skeleton.svelte +6 -6
  28. package/src/lib/components/Table.svelte +13 -4
  29. package/src/lib/components/TableBody.svelte +2 -1
  30. package/src/lib/components/TableCell.svelte +38 -8
  31. package/src/lib/components/TableHead.svelte +12 -4
  32. package/src/lib/components/TableRow.svelte +23 -3
  33. package/src/lib/components/TaskCard.svelte +1 -1
  34. package/src/lib/components/TimezoneSelect.svelte +2 -2
  35. package/src/lib/components/accordion.svelte.ts +19 -0
  36. package/src/lib/components/dashboard/RunDetailPanel.svelte +141 -67
  37. package/src/lib/components/dashboard/RunFilterPopover.svelte +7 -16
  38. package/src/lib/components/dashboard/RunsList.svelte +57 -37
  39. package/src/lib/components/dashboard/cron-format.ts +1 -1
  40. package/src/lib/components/dashboard/run-filters.test.ts +2 -58
  41. package/src/lib/components/dashboard/run-filters.ts +5 -53
  42. package/src/lib/components/dashboard/run-helpers.test.ts +17 -7
  43. package/src/lib/components/dashboard/run-helpers.ts +12 -1
  44. package/src/lib/components/dashboard/status-config.ts +38 -83
  45. package/src/lib/components/dashboard/types.ts +0 -26
  46. package/src/lib/components/radio-group.ts +11 -0
  47. package/src/lib/index.ts +8 -43
  48. package/src/lib/log-console/LogFetcher.svelte.ts +3 -14
  49. package/src/lib/log-console/highlight-scroll.test.ts +59 -0
  50. package/src/lib/log-console/highlight-scroll.ts +41 -0
  51. package/src/lib/theme.css +10 -4
  52. package/src/lib/utils/clipboard.svelte.test.ts +92 -0
  53. package/src/lib/utils/clipboard.svelte.ts +73 -0
  54. package/src/lib/utils/format.ts +65 -94
  55. package/src/lib/utils/id.test.ts +3 -7
  56. package/src/lib/utils/id.ts +3 -2
  57. package/src/lib/utils/theme.svelte.test.ts +0 -6
  58. package/src/lib/utils/theme.svelte.ts +3 -5
  59. package/src/lib/utils/toast.svelte.ts +3 -11
  60. package/src/stories/Accordion.stories.svelte +61 -0
  61. package/src/stories/Button.stories.svelte +2 -2
  62. package/src/stories/CodeBlock.stories.svelte +55 -0
  63. package/src/stories/DataGrid.stories.svelte +2 -7
  64. package/src/stories/Dropdown.stories.svelte +3 -3
  65. package/src/stories/Heading.stories.svelte +31 -0
  66. package/src/stories/Kbd.stories.svelte +36 -0
  67. package/src/stories/Link.stories.svelte +38 -0
  68. package/src/stories/PageHeader.stories.svelte +31 -0
  69. package/src/stories/Progress.stories.svelte +26 -0
  70. package/src/stories/RadioGroup.stories.svelte +43 -0
  71. package/src/stories/Table.stories.svelte +69 -0
  72. package/src/svelte-shims.d.ts +0 -5
  73. package/src/lib/components/dashboard/status-config.test.ts +0 -18
@@ -25,18 +25,13 @@
25
25
  /** Take over sorting (e.g. server-side). When set, the grid only reports
26
26
  * the clicked key and never reorders `data` itself. */
27
27
  onSort?: (key: string) => void;
28
- /** Built-in fuzzy search box above the table. For typed filters use
29
- * `FilterBar` + `applyFilters` outside the grid instead. */
30
- filterable?: boolean;
31
- filterPlaceholder?: string;
32
- filterKeys?: string[];
33
- filterQuery?: string;
34
28
  paginate?: boolean;
35
29
  page?: number;
36
30
  pageSize?: number;
37
31
  pageSizeOptions?: number[];
38
32
  /** Server mode: the full row count. `data` is then the current page as
39
- * delivered, so the grid skips its own filter, sort, and slicing. */
33
+ * delivered, so the grid skips its own sort and slicing. Filter with
34
+ * `FilterBar` + `applyFilters` before handing rows to the grid. */
40
35
  total?: number;
41
36
  onPageChange?: (page: number) => void;
42
37
  onPageSizeChange?: (pageSize: number) => void;
@@ -85,10 +80,13 @@
85
80
  <script lang="ts" generics="T extends object">
86
81
  import { ArrowUp, ArrowDown, ArrowUpDown } from "@lucide/svelte";
87
82
  import Checkbox from "./Checkbox.svelte";
88
- import Input from "./Input.svelte";
89
83
  import Pagination from "./Pagination.svelte";
90
84
  import Spinner from "./Spinner.svelte";
91
- import Fuse from "fuse.js";
85
+ import Table from "./Table.svelte";
86
+ import TableBody from "./TableBody.svelte";
87
+ import TableCell from "./TableCell.svelte";
88
+ import TableHead from "./TableHead.svelte";
89
+ import TableRow from "./TableRow.svelte";
92
90
  import { selectionState } from "./data-grid-selection.js";
93
91
 
94
92
  let {
@@ -99,10 +97,6 @@
99
97
  sortKey = $bindable(undefined),
100
98
  sortDirection = $bindable(undefined),
101
99
  onSort,
102
- filterable = false,
103
- filterPlaceholder = "Filter…",
104
- filterKeys,
105
- filterQuery = $bindable(""),
106
100
  paginate = false,
107
101
  page = $bindable(1),
108
102
  pageSize = $bindable(20),
@@ -166,36 +160,16 @@
166
160
  }
167
161
  }
168
162
 
169
- const searchableKeys = $derived(
170
- filterKeys?.length ? filterKeys : columns.map((c) => String(c.key)),
171
- );
172
-
173
- const fuse = $derived(
174
- new Fuse(data, {
175
- keys: searchableKeys,
176
- threshold: 0.3,
177
- ignoreLocation: true,
178
- }),
179
- );
180
-
181
- const filteredData = $derived.by(() => {
182
- if (typeof total === "number") return data;
183
-
184
- if (!filterable || !filterQuery.trim()) return data;
185
-
186
- return fuse.search(filterQuery).map((r) => r.item);
187
- });
188
-
189
163
  // Client-side sort. Off in server mode (`total` set) or when the parent
190
164
  // drives sorting via `onSort`; otherwise reorder by the active key so a
191
165
  // sortable header actually sorts. Stable + inert on display-only keys.
192
166
  const sortedData = $derived.by(() => {
193
167
  if (onSort || typeof total === "number" || !sortKey || !sortDirection) {
194
- return filteredData;
168
+ return data;
195
169
  }
196
170
  const dir = sortDirection === "desc" ? -1 : 1;
197
171
  const key = sortKey;
198
- return [...filteredData].sort(
172
+ return [...data].sort(
199
173
  (a, b) => dir * compareValues(resolvePath(a, key), resolvePath(b, key)),
200
174
  );
201
175
  });
@@ -220,7 +194,7 @@
220
194
  let someSelected = $derived(selection.someSelected);
221
195
 
222
196
  // Cell rhythm — airy by default, tight when compact. Header + body share it.
223
- const cellPad = $derived(compact ? "px-3 py-2" : "px-5 py-3.5");
197
+ const density = $derived(compact ? "compact" : "comfortable");
224
198
  // Hover reveals a teal accent rail on the leftmost cell (see .group on <tr>).
225
199
  const railHover = "group-hover:shadow-[inset_3px_0_0_var(--color-primary)]";
226
200
  </script>
@@ -230,22 +204,6 @@
230
204
  ? ''
231
205
  : 'rounded-[4px] border border-outline bg-surface-raised shadow-sm'} {className}"
232
206
  >
233
- {#if filterable}
234
- <div class="border-b border-outline p-3">
235
- <Input
236
- value={filterQuery}
237
- oninput={(e) => {
238
- filterQuery = e.currentTarget.value;
239
- if (paginate && !total) page = 1;
240
- }}
241
- type="search"
242
- placeholder={filterPlaceholder}
243
- size="sm"
244
- class="max-w-sm"
245
- />
246
- </div>
247
- {/if}
248
-
249
207
  <div
250
208
  class="relative min-h-0 flex-1 rounded-[4px] {wrapperClass ??
251
209
  (stickyHeader ? '' : 'overflow-x-auto')}"
@@ -263,91 +221,93 @@
263
221
  </div>
264
222
  {/if}
265
223
 
266
- <table class="w-full text-sm">
224
+ <Table bare>
267
225
  {#if showHeader}
268
- <thead
226
+ <TableHead
269
227
  class={stickyHeader
270
228
  ? "sticky top-0 z-10 bg-surface-raised/95 backdrop-blur-sm"
271
229
  : ""}
272
230
  >
273
- <tr class="border-b border-outline text-left">
274
- {#if selectable}
275
- <th class="w-12 {cellPad}">
276
- <Checkbox
277
- checked={allSelected}
278
- indeterminate={someSelected}
279
- onchange={toggleAll}
280
- size="sm"
281
- />
282
- </th>
283
- {/if}
284
- {#each columns as column (column.key)}
285
- <th
286
- class="
287
- group {cellPad}
288
- font-mono text-xs tracking-[0.08em] text-on-surface-faint uppercase
289
- {column.align === 'center' ? 'text-center' : ''}
290
- {column.align === 'right' ? 'text-right' : ''}
291
- {sortKey === column.key
292
- ? 'text-on-surface shadow-[inset_0_-2px_0_var(--color-primary)]'
293
- : ''}
294
- "
295
- style={column.width ? `width: ${column.width}` : ""}
296
- >
297
- {#if column.sortable}
298
- <button
299
- onclick={() => handleSort(String(column.key))}
300
- class="
301
- inline-flex items-center gap-1 group-hover:text-on-surface
302
- {sortKey === column.key ? 'text-on-surface' : ''}
303
- "
304
- >
305
- {column.label}
306
- {#if sortKey === column.key}
307
- {#if sortDirection === "asc"}
308
- <ArrowUp size={14} class="text-primary" />
309
- {:else}
310
- <ArrowDown size={14} class="text-primary" />
311
- {/if}
231
+ {#if selectable}
232
+ <TableCell header {density} class="w-12">
233
+ <Checkbox
234
+ checked={allSelected}
235
+ indeterminate={someSelected}
236
+ onchange={toggleAll}
237
+ size="sm"
238
+ />
239
+ </TableCell>
240
+ {/if}
241
+ {#each columns as column (column.key)}
242
+ {@const sorted = sortKey === column.key}
243
+ <TableCell
244
+ header
245
+ {density}
246
+ align={column.align}
247
+ aria-sort={sorted
248
+ ? sortDirection === "asc"
249
+ ? "ascending"
250
+ : "descending"
251
+ : undefined}
252
+ class="group"
253
+ style={column.width ? `width: ${column.width}` : undefined}
254
+ >
255
+ {#if column.sortable}
256
+ <button
257
+ onclick={() => handleSort(String(column.key))}
258
+ class="
259
+ inline-flex items-center gap-1 group-hover:text-on-surface
260
+ {sorted ? 'text-on-surface' : ''}
261
+ "
262
+ >
263
+ {column.label}
264
+ {#if sorted}
265
+ {#if sortDirection === "asc"}
266
+ <ArrowUp size={14} class="text-primary" />
312
267
  {:else}
313
- <ArrowUpDown
314
- size={14}
315
- class="text-on-surface-faint opacity-0 group-hover:opacity-100"
316
- />
268
+ <ArrowDown size={14} class="text-primary" />
317
269
  {/if}
318
- </button>
319
- {:else}
320
- {column.label}
321
- {/if}
322
- </th>
323
- {/each}
324
- {#if rowAction}
325
- <th class="w-16 {cellPad}"></th>
326
- {/if}
327
- </tr>
328
- </thead>
270
+ {:else}
271
+ <ArrowUpDown
272
+ size={14}
273
+ class="text-on-surface-faint opacity-0 group-hover:opacity-100"
274
+ />
275
+ {/if}
276
+ </button>
277
+ {:else}
278
+ {column.label}
279
+ {/if}
280
+ </TableCell>
281
+ {/each}
282
+ {#if rowAction}
283
+ <TableCell header {density} class="w-16" />
284
+ {/if}
285
+ </TableHead>
329
286
  {/if}
330
- <tbody>
287
+ <TableBody>
331
288
  {#if pagedData.length === 0 && !loading}
332
- <tr>
333
- <td
289
+ <TableRow border="none">
290
+ <TableCell
334
291
  colspan={columns.length + (selectable ? 1 : 0) + (rowAction ? 1 : 0)}
335
- class="px-4 py-12 text-center text-on-surface-muted"
292
+ density="none"
293
+ align="center"
294
+ class="px-4 py-12 text-on-surface-muted"
336
295
  >
337
296
  {#if emptyState}
338
297
  {@render emptyState()}
339
298
  {:else}
340
299
  {emptyMessage}
341
300
  {/if}
342
- </td>
343
- </tr>
301
+ </TableCell>
302
+ </TableRow>
344
303
  {:else}
345
304
  {#each pagedData as row, idx (row[rowKey])}
346
- <tr
305
+ <TableRow
306
+ border="faint"
307
+ {hoverable}
347
308
  class="
348
- group border-b border-outline-faint last:border-b-0
309
+ group
349
310
  {striped && idx % 2 === 1 ? 'bg-surface-sunken/30' : ''}
350
- {hoverable ? 'hover:bg-surface-sunken' : ''}
351
311
  {isSelected(row) ? 'bg-primary-soft/30' : ''}
352
312
  {onRowClick ? 'cursor-pointer' : ''}
353
313
  {rowClass ? rowClass(row) : ''}
@@ -357,8 +317,9 @@
357
317
  {#if selectable}
358
318
  <!-- stopPropagation: a selection click must never also
359
319
  fire onRowClick (which usually navigates away). -->
360
- <td
361
- class="{cellPad} cursor-pointer {hoverable ? railHover : ''}"
320
+ <TableCell
321
+ {density}
322
+ class="cursor-pointer {hoverable ? railHover : ''}"
362
323
  onclick={(e) => {
363
324
  e.stopPropagation();
364
325
  if (!(e.target instanceof HTMLInputElement)) toggleRow(row);
@@ -369,16 +330,17 @@
369
330
  size="sm"
370
331
  class="pointer-events-none"
371
332
  />
372
- </td>
333
+ </TableCell>
373
334
  {/if}
374
335
  {#each columns as column, ci (column.key)}
375
- <td
376
- class="
377
- {cellPad} text-on-surface-muted
378
- {column.align === 'center' ? 'text-center' : ''}
379
- {column.align === 'right' ? 'text-right' : ''}
380
- {ci === 0 && !selectable && hoverable ? railHover : ''}
381
- "
336
+ <TableCell
337
+ {density}
338
+ align={column.align}
339
+ class="text-on-surface-muted {ci === 0 &&
340
+ !selectable &&
341
+ hoverable
342
+ ? railHover
343
+ : ''}"
382
344
  >
383
345
  {#if column.render}
384
346
  {@render column.render(row)}
@@ -389,18 +351,18 @@
389
351
  >{resolvePath(row, String(column.key)) ?? "-"}</span
390
352
  >
391
353
  {/if}
392
- </td>
354
+ </TableCell>
393
355
  {/each}
394
356
  {#if rowAction}
395
- <td class="{cellPad} text-right">
357
+ <TableCell {density} align="right">
396
358
  {@render rowAction(row)}
397
- </td>
359
+ </TableCell>
398
360
  {/if}
399
- </tr>
361
+ </TableRow>
400
362
  {/each}
401
363
  {/if}
402
- </tbody>
403
- </table>
364
+ </TableBody>
365
+ </Table>
404
366
  </div>
405
367
 
406
368
  {#if footer}
@@ -4,6 +4,7 @@
4
4
  <script lang="ts">
5
5
  import type { Snippet } from "svelte";
6
6
  import { X } from "@lucide/svelte";
7
+ import Heading from "./Heading.svelte";
7
8
  import { portal } from "../actions/portal.js";
8
9
  import { trapFocus } from "../actions/focusTrap.js";
9
10
  import { fly } from "svelte/transition";
@@ -122,12 +123,7 @@
122
123
  >
123
124
  <div>
124
125
  {#if title}
125
- <h2
126
- id="drawer-title"
127
- class="font-mono text-lg font-semibold text-on-surface"
128
- >
129
- {title}
130
- </h2>
126
+ <Heading level={2} size="lg" id="drawer-title">{title}</Heading>
131
127
  {/if}
132
128
  </div>
133
129
  {#if closable}
@@ -4,9 +4,11 @@
4
4
  <script lang="ts">
5
5
  import type { Snippet } from "svelte";
6
6
  import type { Component } from "svelte";
7
- import { tick, onDestroy } from "svelte";
8
7
  import { EllipsisVertical, Check } from "@lucide/svelte";
9
- import { computePosition, autoUpdate, flip, shift, offset } from "@floating-ui/dom";
8
+ import { floating, isOutsideClick } from "../actions/floating.js";
9
+ import { portal } from "../actions/portal.js";
10
+ import { trapFocus } from "../actions/focusTrap.js";
11
+ import Separator from "./Separator.svelte";
10
12
 
11
13
  interface MenuItem {
12
14
  label?: string;
@@ -41,74 +43,27 @@
41
43
  let open = $state(false);
42
44
  let triggerEl = $state<HTMLElement | null>(null);
43
45
  let menuEl = $state<HTMLElement | null>(null);
44
- let cleanupFloating = $state<(() => void) | null>(null);
45
46
  let transformOrigin = $state("top right");
46
47
 
47
- import { portal } from "../actions/portal.js";
48
- import { trapFocus } from "../actions/focusTrap.js";
49
-
50
- async function setupFloating() {
51
- if (!triggerEl || !menuEl) return;
52
-
53
- cleanupFloating = autoUpdate(triggerEl, menuEl, () => {
54
- if (!triggerEl || !menuEl) return;
55
-
56
- const placement = align === "right" ? "bottom-end" : "bottom-start";
57
-
58
- void computePosition(triggerEl, menuEl, {
59
- placement,
60
- middleware: [offset(4), flip(), shift({ padding: 8 })],
61
- }).then(({ x, y, placement }) => {
62
- if (menuEl) {
63
- Object.assign(menuEl.style, {
64
- left: `${x}px`,
65
- top: `${y}px`,
66
- position: "absolute",
67
- });
68
-
69
- // Flip transform origin vertically when the menu flips above the trigger
70
- const isTop = placement.startsWith("top");
71
- transformOrigin = `${isTop ? "bottom" : "top"} ${align}`;
72
- }
73
- });
74
- });
48
+ // Flip the transform origin vertically when the menu flips above the trigger.
49
+ function placed(placement: string) {
50
+ transformOrigin = `${placement.startsWith("top") ? "bottom" : "top"} ${align}`;
75
51
  }
76
52
 
77
53
  function handleItemClick(item: MenuItem) {
78
54
  if (item.divider) return;
79
55
  if (item.disabled) return;
80
56
  item.onClick?.();
81
- toggle(false);
82
- }
83
-
84
- function toggle(value: boolean) {
85
- open = value;
86
- if (open) {
87
- void tick().then(() => {
88
- void setupFloating();
89
- });
90
- } else {
91
- cleanupFloating?.();
92
- cleanupFloating = null;
93
- }
57
+ open = false;
94
58
  }
95
59
 
96
60
  function handleOutsideClick(e: MouseEvent) {
97
- const path = e.composedPath();
98
- if (triggerEl && path.includes(triggerEl)) return;
99
- if (menuEl && path.includes(menuEl)) return;
100
- if (open) toggle(false);
61
+ if (open && isOutsideClick(e, triggerEl, menuEl)) open = false;
101
62
  }
102
63
 
103
64
  function handleKeyDown(e: KeyboardEvent) {
104
- if (e.key === "Escape" && open) {
105
- toggle(false);
106
- }
65
+ if (e.key === "Escape" && open) open = false;
107
66
  }
108
-
109
- onDestroy(() => {
110
- cleanupFloating?.();
111
- });
112
67
  </script>
113
68
 
114
69
  <svelte:window onclick={handleOutsideClick} onkeydown={handleKeyDown} />
@@ -117,7 +72,7 @@
117
72
  <button
118
73
  onclick={(e) => {
119
74
  e.stopPropagation();
120
- toggle(!open);
75
+ open = !open;
121
76
  }}
122
77
  bind:this={triggerEl}
123
78
  aria-label={triggerLabel}
@@ -135,6 +90,13 @@
135
90
  <div
136
91
  use:portal
137
92
  use:trapFocus
93
+ use:floating={{
94
+ reference: triggerEl,
95
+ placement: align === "right" ? "bottom-end" : "bottom-start",
96
+ offset: 4,
97
+ padding: 8,
98
+ onPlace: placed,
99
+ }}
138
100
  bind:this={menuEl}
139
101
  role="menu"
140
102
  class="
@@ -150,7 +112,7 @@
150
112
  {:else}
151
113
  {#each items as item, idx (idx)}
152
114
  {#if item.divider}
153
- <hr class="my-1 border-outline" />
115
+ <Separator class="my-1" />
154
116
  {:else if item.href}
155
117
  <a
156
118
  href={item.href}
@@ -5,6 +5,7 @@
5
5
  import type { Snippet } from "svelte";
6
6
  import type { Component } from "svelte";
7
7
  import { Inbox } from "@lucide/svelte";
8
+ import Heading from "./Heading.svelte";
8
9
 
9
10
  interface Props {
10
11
  title?: string;
@@ -32,7 +33,7 @@
32
33
  <Icon size={iconSize} class="text-on-surface-faint" />
33
34
  </div>
34
35
 
35
- <h3 class="mb-1 font-mono text-lg font-semibold text-on-surface">{title}</h3>
36
+ <Heading level={3} size="lg" class="mb-1">{title}</Heading>
36
37
 
37
38
  {#if description}
38
39
  <p class="mb-6 max-w-sm text-sm text-on-surface-muted">{description}</p>
@@ -5,6 +5,7 @@
5
5
  import type { Snippet, Component } from "svelte";
6
6
  import { TriangleAlert, RefreshCw, WifiOff } from "@lucide/svelte";
7
7
  import Button from "./Button.svelte";
8
+ import Heading from "./Heading.svelte";
8
9
 
9
10
  interface Props {
10
11
  /** Error message to display */
@@ -56,7 +57,7 @@
56
57
  </div>
57
58
 
58
59
  <div class="space-y-1">
59
- <h3 class="font-mono text-lg font-semibold text-on-surface">{defaultTitle}</h3>
60
+ <Heading level={3} size="lg">{defaultTitle}</Heading>
60
61
  <p class="max-w-sm text-sm text-on-surface-muted">{message}</p>
61
62
  </div>
62
63
 
@@ -3,18 +3,19 @@
3
3
 
4
4
  <script lang="ts">
5
5
  import type { Snippet } from "svelte";
6
+ import type { HTMLAttributes } from "svelte/elements";
6
7
 
7
8
  type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
8
9
  type HeadingSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
9
10
 
10
- interface Props {
11
+ interface Props extends Omit<HTMLAttributes<HTMLHeadingElement>, "children" | "class"> {
11
12
  level: HeadingLevel;
12
13
  size?: HeadingSize;
13
14
  children: Snippet;
14
15
  class?: string;
15
16
  }
16
17
 
17
- let { level, size, children, class: className = "" }: Props = $props();
18
+ let { level, size, children, class: className = "", ...restProps }: Props = $props();
18
19
 
19
20
  const defaultSizeMap: Record<HeadingLevel, HeadingSize> = {
20
21
  1: "4xl",
@@ -25,24 +26,30 @@
25
26
  6: "md",
26
27
  };
27
28
 
28
- const sizeClasses: Record<string, string> = {
29
- xs: "text-xs font-semibold",
30
- sm: "text-sm font-semibold",
31
- md: "text-base font-semibold",
32
- lg: "text-lg font-semibold",
33
- xl: "text-xl font-bold",
34
- "2xl": "text-2xl font-bold",
35
- "3xl": "text-3xl font-bold",
36
- "4xl": "text-4xl font-bold tracking-tight",
29
+ const sizeClasses: Record<HeadingSize, string> = {
30
+ xs: "text-xs",
31
+ sm: "text-sm",
32
+ md: "text-base",
33
+ lg: "text-lg",
34
+ xl: "text-xl",
35
+ "2xl": "text-2xl",
36
+ "3xl": "text-3xl",
37
+ "4xl": "text-4xl",
37
38
  };
38
39
 
39
40
  const resolvedSize = $derived(size ?? defaultSizeMap[level]);
40
41
  const tag = $derived(`h${level}` as const);
41
42
  </script>
42
43
 
44
+ <!-- theme.css styles raw h1-h3 globally; data-rw-heading opts out of that rule
45
+ so these classes (the same runbook look: mono, 800, tight) decide. -->
43
46
  <svelte:element
44
47
  this={tag}
45
- class="font-mono tracking-tight text-on-surface {sizeClasses[resolvedSize]} {className}"
48
+ data-rw-heading
49
+ class="font-mono leading-[1.1] font-extrabold tracking-[-0.02em] text-on-surface {sizeClasses[
50
+ resolvedSize
51
+ ]} {className}"
52
+ {...restProps}
46
53
  >
47
54
  {@render children()}
48
55
  </svelte:element>
@@ -2,23 +2,34 @@
2
2
  <!-- SPDX-License-Identifier: Apache-2.0 -->
3
3
 
4
4
  <script lang="ts">
5
+ type KbdSize = "xs" | "sm";
6
+ /** `faint` sits quietly inside an input; `console` rides dark terminal chrome and inherits its text color. */
7
+ type KbdTone = "default" | "faint" | "console";
8
+
5
9
  interface Props {
6
10
  keys: string | string[];
11
+ size?: KbdSize;
12
+ tone?: KbdTone;
7
13
  class?: string;
8
14
  }
9
15
 
10
- let { keys, class: className = "" }: Props = $props();
16
+ let { keys, size = "sm", tone = "default", class: className = "" }: Props = $props();
11
17
 
12
18
  const keyList = $derived(Array.isArray(keys) ? keys : [keys]);
13
19
 
14
- const kbdClasses = `
15
- inline-flex items-center justify-center
16
- min-w-[1.5em] px-1.5 py-0.5
17
- text-xs font-mono font-medium
18
- bg-surface-sunken text-on-surface-muted
19
- border border-outline rounded-[3px]
20
- shadow-sm
21
- `;
20
+ const sizeClasses: Record<KbdSize, string> = {
21
+ xs: "px-1.5 text-2xs",
22
+ sm: "min-w-[1.5em] px-1.5 text-xs",
23
+ };
24
+
25
+ // Vertical padding lives with the tone: console keys sit inline in toolbar
26
+ // text and must not grow the row.
27
+ const toneClasses: Record<KbdTone, string> = {
28
+ default:
29
+ "py-0.5 font-medium bg-surface-sunken text-on-surface-muted border-outline shadow-sm",
30
+ faint: "py-0.5 font-medium bg-surface-raised text-on-surface-faint border-outline-faint shadow-sm",
31
+ console: "tracking-wide border-[var(--rw-con-gutter)]",
32
+ };
22
33
  </script>
23
34
 
24
35
  <span class="inline-flex items-center gap-1 {className}">
@@ -26,6 +37,10 @@
26
37
  {#if i > 0}
27
38
  <span class="text-xs text-on-surface-faint select-none">+</span>
28
39
  {/if}
29
- <kbd class={kbdClasses}>{key}</kbd>
40
+ <kbd
41
+ class="inline-flex items-center justify-center rounded-[3px] border font-mono {sizeClasses[
42
+ size
43
+ ]} {toneClasses[tone]}">{key}</kbd
44
+ >
30
45
  {/each}
31
46
  </span>