@runwisp/ui 1.4.0 → 1.5.1

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 (87) hide show
  1. package/package.json +4 -5
  2. package/src/lib/actions/floating.ts +73 -0
  3. package/src/lib/actions/focusTrap.ts +3 -3
  4. package/src/lib/actions/portal.ts +5 -1
  5. package/src/lib/components/Accordion.svelte +6 -2
  6. package/src/lib/components/AccordionItem.svelte +80 -23
  7. package/src/lib/components/Avatar.svelte +0 -1
  8. package/src/lib/components/Badge.svelte +1 -1
  9. package/src/lib/components/CodeBlock.svelte +84 -35
  10. package/src/lib/components/CronInput.svelte +5 -2
  11. package/src/lib/components/DataGrid.svelte +100 -138
  12. package/src/lib/components/Drawer.svelte +2 -6
  13. package/src/lib/components/Dropdown.svelte +19 -57
  14. package/src/lib/components/EmptyState.svelte +2 -1
  15. package/src/lib/components/ErrorState.svelte +2 -1
  16. package/src/lib/components/FilterBar.svelte +1 -1
  17. package/src/lib/components/FilterPill.svelte +2 -2
  18. package/src/lib/components/FormField.svelte +1 -1
  19. package/src/lib/components/Heading.svelte +19 -12
  20. package/src/lib/components/Kbd.svelte +25 -10
  21. package/src/lib/components/Link.svelte +7 -1
  22. package/src/lib/components/LogConsole.svelte +40 -42
  23. package/src/lib/components/Modal.svelte +44 -33
  24. package/src/lib/components/NotificationBell.svelte +2 -1
  25. package/src/lib/components/PageContainer.svelte +1 -10
  26. package/src/lib/components/PageHeader.svelte +8 -4
  27. package/src/lib/components/Popover.svelte +9 -51
  28. package/src/lib/components/Progress.svelte +19 -6
  29. package/src/lib/components/Radio.svelte +16 -4
  30. package/src/lib/components/RadioGroup.svelte +15 -2
  31. package/src/lib/components/SearchInput.svelte +1 -1
  32. package/src/lib/components/Select.svelte +16 -47
  33. package/src/lib/components/Sparkline.svelte +2 -2
  34. package/src/lib/components/StatusDot.svelte +1 -1
  35. package/src/lib/components/Table.svelte +13 -4
  36. package/src/lib/components/TableBody.svelte +2 -1
  37. package/src/lib/components/TableCell.svelte +38 -8
  38. package/src/lib/components/TableHead.svelte +12 -4
  39. package/src/lib/components/TableRow.svelte +23 -3
  40. package/src/lib/components/TaskCard.svelte +1 -1
  41. package/src/lib/components/TimezoneSelect.svelte +2 -2
  42. package/src/lib/components/Tooltip.svelte +1 -1
  43. package/src/lib/components/accordion.svelte.ts +19 -0
  44. package/src/lib/components/dashboard/RunDetailPanel.svelte +106 -79
  45. package/src/lib/components/dashboard/RunFilterPopover.svelte +12 -21
  46. package/src/lib/components/dashboard/RunsList.svelte +17 -26
  47. package/src/lib/components/dashboard/cron-format.test.ts +1 -1
  48. package/src/lib/components/dashboard/cron-format.ts +3 -3
  49. package/src/lib/components/dashboard/run-filters.test.ts +4 -60
  50. package/src/lib/components/dashboard/run-filters.ts +29 -144
  51. package/src/lib/components/dashboard/run-helpers.test.ts +25 -6
  52. package/src/lib/components/dashboard/run-helpers.ts +40 -39
  53. package/src/lib/components/dashboard/status-config.ts +38 -83
  54. package/src/lib/components/dashboard/types.ts +0 -26
  55. package/src/lib/components/data-grid-selection.ts +2 -9
  56. package/src/lib/components/filter-spec.ts +3 -3
  57. package/src/lib/components/radio-group.ts +11 -0
  58. package/src/lib/index.ts +8 -43
  59. package/src/lib/log-console/LogCache.svelte.ts +1 -1
  60. package/src/lib/log-console/LogFetcher.svelte.ts +3 -14
  61. package/src/lib/log-console/ansi.ts +1 -1
  62. package/src/lib/log-console/highlight-scroll.test.ts +59 -0
  63. package/src/lib/log-console/highlight-scroll.ts +41 -0
  64. package/src/lib/theme-tokens.css +21 -21
  65. package/src/lib/theme.css +22 -16
  66. package/src/lib/utils/clipboard.svelte.test.ts +92 -0
  67. package/src/lib/utils/clipboard.svelte.ts +73 -0
  68. package/src/lib/utils/format.test.ts +3 -3
  69. package/src/lib/utils/format.ts +65 -94
  70. package/src/lib/utils/id.test.ts +3 -7
  71. package/src/lib/utils/id.ts +3 -2
  72. package/src/lib/utils/theme.svelte.test.ts +0 -6
  73. package/src/lib/utils/theme.svelte.ts +3 -5
  74. package/src/lib/utils/toast.svelte.ts +5 -14
  75. package/src/stories/Accordion.stories.svelte +61 -0
  76. package/src/stories/CodeBlock.stories.svelte +55 -0
  77. package/src/stories/DataGrid.stories.svelte +0 -5
  78. package/src/stories/Heading.stories.svelte +31 -0
  79. package/src/stories/Kbd.stories.svelte +36 -0
  80. package/src/stories/Link.stories.svelte +38 -0
  81. package/src/stories/LogConsole.stories.svelte +2 -2
  82. package/src/stories/PageHeader.stories.svelte +31 -0
  83. package/src/stories/Progress.stories.svelte +26 -0
  84. package/src/stories/RadioGroup.stories.svelte +43 -0
  85. package/src/stories/Table.stories.svelte +69 -0
  86. package/src/svelte-shims.d.ts +0 -5
  87. 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;
@@ -51,16 +46,16 @@
51
46
  onRowClick?: (row: T) => void;
52
47
  loading?: boolean;
53
48
  stickyHeader?: boolean;
54
- /** Hide the header row entirely — for single-column feeds (e.g. an
49
+ /** Hide the header row entirely, for single-column feeds (e.g. an
55
50
  * inbox) where a column label would be noise. */
56
51
  showHeader?: boolean;
57
- /** Rendered below the table body, inside the frame — e.g. a cursor
52
+ /** Rendered below the table body, inside the frame, e.g. a cursor
58
53
  * "Load more" button for feeds that don't use offset pagination. */
59
54
  footer?: Snippet;
60
55
  /** Drop the outer border/shadow/bg so the grid sits flush inside a Card
61
56
  * or panel that already supplies its own frame. */
62
57
  bare?: boolean;
63
- /** Extra classes per row — e.g. a left accent bar for a failing row.
58
+ /** Extra classes per row, e.g. a left accent bar for a failing row.
64
59
  * Rows always carry `group`, so a `render`/`rowAction` snippet can use
65
60
  * `group-hover:` to reveal on-hover controls. */
66
61
  rowClass?: (row: T) => string;
@@ -71,7 +66,7 @@
71
66
  // Type-aware comparator. Nullish sorts last; numbers numerically; dates by
72
67
  // epoch; everything else by locale with numeric-aware string compare.
73
68
  // Returns 0 for non-comparable/equal, so a column whose key maps to no real
74
- // field (a display-only key) leaves order untouched — sort stays inert there.
69
+ // field (a display-only key) leaves order untouched, sort stays inert there.
75
70
  function compareValues(a: unknown, b: unknown): number {
76
71
  if (a == null && b == null) return 0;
77
72
  if (a == null) return 1;
@@ -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
  });
@@ -219,8 +193,8 @@
219
193
  let allSelected = $derived(selection.allSelected);
220
194
  let someSelected = $derived(selection.someSelected);
221
195
 
222
- // 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");
196
+ // Cell rhythm, airy by default, tight when compact. Header + body share it.
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
 
@@ -11,7 +11,7 @@
11
11
  shown as pills; every other field stays behind "+ Add filter" until the user
12
12
  adds it, then lives as a pill. Each pill is self-contained (label + value +
13
13
  clear in one element, see FilterPill) so activating a filter never adds a
14
- second row — the table below never jumps. Data-mode agnostic: the parent feeds
14
+ second row, the table below never jumps. Data-mode agnostic: the parent feeds
15
15
  `values` to applyFilters (client) or maps it to query params (server).
16
16
  -->
17
17
 
@@ -4,7 +4,7 @@
4
4
  <!--
5
5
  One filter, one element. The pill is both the control and its own "chip":
6
6
  inactive it reads `Label ▾` (muted); active it reads `Label: value ✕` (accent)
7
- with an inline clear. Active vs inactive is a restyle of the SAME element — it
7
+ with an inline clear. Active vs inactive is a restyle of the SAME element, it
8
8
  never spawns a second row, so the table below never jumps. Clicking the body
9
9
  opens a type-specific editor popover (select menu / number / date range).
10
10
  -->
@@ -26,7 +26,7 @@
26
26
  let {
27
27
  field,
28
28
  values = $bindable({}),
29
- // Open the editor on mount — set when the pill was just added from the
29
+ // Open the editor on mount, set when the pill was just added from the
30
30
  // "+ Add filter" menu, so the user lands straight in the value picker.
31
31
  autoOpen = false,
32
32
  onClose,
@@ -13,7 +13,7 @@
13
13
  children: Snippet;
14
14
  class?: string;
15
15
  // The id of the control this field labels. Pass the SAME id to the wrapped
16
- // input/select so the <label for> actually points at it — otherwise the
16
+ // input/select so the <label for> actually points at it, otherwise the
17
17
  // label is dead (clicking it does nothing; screen readers can't associate
18
18
  // it). Falls back to a generated id when the field has no focusable target.
19
19
  id?: string;
@@ -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>