@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
@@ -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>
@@ -3,11 +3,15 @@
3
3
 
4
4
  <script lang="ts">
5
5
  import type { Snippet } from "svelte";
6
+ import type { HTMLAnchorAttributes } from "svelte/elements";
6
7
 
7
8
  type LinkVariant = "default" | "muted" | "primary" | "danger";
8
9
  type LinkUnderline = "always" | "hover" | "none";
9
10
 
10
- interface Props {
11
+ // Use Link for off-site URLs (`external` opens a new tab with rel=noopener
12
+ // noreferrer). An in-app href must still come from SvelteKit's resolve():
13
+ // being a component, Link sits outside svelte/no-navigation-without-resolve.
14
+ interface Props extends Omit<HTMLAnchorAttributes, "href" | "children" | "class"> {
11
15
  href: string;
12
16
  variant?: LinkVariant;
13
17
  external?: boolean;
@@ -23,6 +27,7 @@
23
27
  underline = "hover",
24
28
  children,
25
29
  class: className = "",
30
+ ...restProps
26
31
  }: Props = $props();
27
32
 
28
33
  const baseClasses = `
@@ -50,6 +55,7 @@
50
55
  class="{baseClasses} {variantClasses[variant]} {underlineClasses[underline]} {className}"
51
56
  target={external ? "_blank" : undefined}
52
57
  rel={external ? "noopener noreferrer" : undefined}
58
+ {...restProps}
53
59
  >
54
60
  {@render children()}
55
61
  </a>
@@ -6,13 +6,12 @@
6
6
  import { ansiLineToHtml, visibleColumns } from "../log-console/ansi.js";
7
7
  import { LogCache } from "../log-console/LogCache.svelte.js";
8
8
  import { LogFetcher } from "../log-console/LogFetcher.svelte.js";
9
+ import { createHighlightScroll } from "../log-console/highlight-scroll.js";
9
10
  import type { FetchLogsFn, LogEvent } from "../log-console/types.js";
10
11
  import { formatBytes } from "../utils/format.js";
11
12
 
12
13
  interface Props {
13
14
  fetchLogs?: FetchLogsFn;
14
- chunkSize?: number;
15
- lineHeight?: number;
16
15
  class?: string;
17
16
  // highlightLine, when set, scrolls the console so the given absolute
18
17
  // line number is visible and pulses a short-lived flash on that row.
@@ -51,8 +50,6 @@
51
50
 
52
51
  let {
53
52
  fetchLogs,
54
- chunkSize = 4096,
55
- lineHeight = 20,
56
53
  class: className = "",
57
54
  highlightLine = null,
58
55
  fetchLineHistory,
@@ -63,6 +60,8 @@
63
60
  wrap = $bindable(false),
64
61
  }: Props = $props();
65
62
 
63
+ const lineHeight = 20; // px per rendered row
64
+
66
65
  // --- Frame-history inline expansion (single expansion at a time) ---
67
66
  // expandedLine is the absolute line number whose history block is open;
68
67
  // expandedFrames holds the fetched whole-region frames (null while loading).
@@ -149,7 +148,7 @@
149
148
  // lineTop / totalHeight / the scroll-position→line lookup stay O(1) or
150
149
  // O(log n) even though rows are no longer uniform. A plain Map is
151
150
  // deliberate: reactivity is driven by the `rowCountVersion` counter (bumped
152
- // on any change), not by per-key subscriptions — iterating a SvelteMap of
151
+ // on any change), not by per-key subscriptions, iterating a SvelteMap of
153
152
  // tens of thousands of lines on every streamed line would be wasteful.
154
153
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
155
154
  const rowCounts = new Map<number, number>();
@@ -176,7 +175,7 @@
176
175
  // Measures wrapped row counts for exactly the lines in [min, max] and bumps
177
176
  // rowCountVersion if anything changed. Called directly wherever new line
178
177
  // text lands in the cache (streamed append, on-demand backfill), each of
179
- // which already knows the touched range from LogCache's merge result — so
178
+ // which already knows the touched range from LogCache's merge result, so
180
179
  // a single appended line costs O(1) here instead of re-walking the whole
181
180
  // (up to 50k-line) cache. Plain function, not an $effect: reads/writes
182
181
  // rowCounts and cache.lines by key, never by iterating the map.
@@ -202,7 +201,7 @@
202
201
  // or the available column width changes (wrap width affects every line's
203
202
  // row count, so there's no way to scope that to a range). Line
204
203
  // appends/backfills are measured incrementally at their call sites via
205
- // measureRange instead — reading `cache.lines` in this effect's tracked
204
+ // measureRange instead, reading `cache.lines` in this effect's tracked
206
205
  // scope would resubscribe it to the SvelteMap's shared version signal and
207
206
  // re-run this full O(n) walk on every single appended line. `untrack`
208
207
  // (same pattern as the fetch/prune effects below) keeps that read out of
@@ -271,7 +270,7 @@
271
270
  const i = lineNum - cache.firstAvailableLine;
272
271
  if (i <= 0) return base;
273
272
  if (i >= prefixSums.length) {
274
- const last = prefixSums.length > 0 ? (prefixSums[prefixSums.length - 1] ?? 0) : 0;
273
+ const last = prefixSums.at(-1) ?? 0;
275
274
  return last * lineHeight + base;
276
275
  }
277
276
  return (prefixSums[i] ?? 0) * lineHeight + base;
@@ -345,11 +344,8 @@
345
344
  let overlayRows = $derived(cache.overlayRows);
346
345
 
347
346
  let totalHeight = $derived.by(() => {
348
- const truncationBannerHeight = cache.firstAvailableLine > 0 ? lineHeight : 0;
349
347
  const linesHeight = wrap
350
- ? (prefixSums.length > 0
351
- ? (prefixSums[prefixSums.length - 1] ?? 0)
352
- : Math.max(0, cache.totalLines - cache.firstAvailableLine)) *
348
+ ? (prefixSums.at(-1) ?? Math.max(0, cache.totalLines - cache.firstAvailableLine)) *
353
349
  lineHeight +
354
350
  truncationBannerHeight
355
351
  : (cache.totalLines - cache.firstAvailableLine) * lineHeight + truncationBannerHeight;
@@ -376,7 +372,7 @@
376
372
 
377
373
  // Width of the virtual scroll surface: wide enough for the longest line
378
374
  // seen so far, never narrower than the viewport (so short logs show no
379
- // horizontal scrollbar). Wrapping pins the surface to the viewport — there
375
+ // horizontal scrollbar). Wrapping pins the surface to the viewport, there
380
376
  // is no horizontal axis to scroll.
381
377
  let surfaceWidth = $derived(
382
378
  wrap
@@ -410,8 +406,7 @@
410
406
  // viewport lands at the end of the log without racing the auto-scroll.
411
407
  $effect(() => {
412
408
  const fn = fetchLogs;
413
- const cs = chunkSize;
414
- fetcher = new LogFetcher(cache, fn, cs, (min, max) => {
409
+ fetcher = new LogFetcher(cache, fn, (min, max) => {
415
410
  measureRange(min, max);
416
411
  if (isAutoScroll && !userScrolledUp) {
417
412
  requestAnimationFrame(() => scrollToBottom());
@@ -434,14 +429,14 @@
434
429
  }
435
430
  });
436
431
 
437
- // Prune stale cache entries — separate from reads to avoid a read-write cycle on SvelteMap.
432
+ // Prune stale cache entries, separate from reads to avoid a read-write cycle on SvelteMap.
438
433
  $effect(() => {
439
434
  const vs = visibleStart;
440
435
  const ve = visibleEnd;
441
436
  untrack(() => cache.prune(vs, ve));
442
437
  });
443
438
 
444
- let prevTailRows = 0; // plain variable — intentionally non-reactive
439
+ let prevTailRows = 0; // plain variable, intentionally non-reactive
445
440
 
446
441
  $effect(() => {
447
442
  // Track committed lines AND live overlay rows so an animating region at
@@ -498,7 +493,7 @@
498
493
  containerWidth = containerEl.clientWidth;
499
494
  // Resync the reactive scroll position with the DOM. The browser
500
495
  // clamps scrollTop when the viewport grows (e.g. maximizing the
501
- // console), but no scroll event fires for that clamp — so without
496
+ // console), but no scroll event fires for that clamp, so without
502
497
  // this the virtualizer keeps a stale, too-large scrollTop and
503
498
  // renders an empty window until the first manual scroll.
504
499
  scrollTop = containerEl.scrollTop;
@@ -538,29 +533,32 @@
538
533
  scrollTop = 0;
539
534
  }
540
535
 
541
- // Search-hit deep-link: when highlightLine flips to a non-null number,
542
- // scroll the viewport so that line is centred, and pulse a one-shot
543
- // flash class for ~1.5s. Cleaning up the timer on prop change keeps a
544
- // rapid sequence of jumps from leaking timers.
545
- $effect(() => {
546
- const target = highlightLine;
547
- if (target === null || target === undefined) {
548
- flashLine = null;
549
- return;
550
- }
551
- if (!containerEl) return;
552
- const targetY = lineTop(target) - containerHeight / 2 + lineHeight / 2;
553
- const clamped = Math.max(0, Math.min(targetY, totalHeight - containerHeight));
554
- containerEl.scrollTo({ top: clamped, behavior: "smooth" });
555
- isAutoScroll = false;
556
- userScrolledUp = true;
557
- flashLine = target;
558
- if (flashTimer !== null) clearTimeout(flashTimer);
559
- flashTimer = setTimeout(() => {
536
+ // Search-hit deep-link: scroll the viewport so the highlighted line is
537
+ // centred, once per highlightLine, and pulse a one-shot flash class for
538
+ // ~1.5s. createHighlightScroll keeps later layout changes (new lines on a
539
+ // live run, resizes) from pulling the view back to the hit.
540
+ const syncHighlightScroll = createHighlightScroll({
541
+ line: () => highlightLine,
542
+ ready: (line) => containerEl !== null && containerHeight > 0 && line < cache.totalLines,
543
+ reveal: (line) => {
544
+ if (!containerEl) return;
545
+ const targetY = lineTop(line) - containerHeight / 2 + lineHeight / 2;
546
+ const clamped = Math.max(0, Math.min(targetY, totalHeight - containerHeight));
547
+ containerEl.scrollTo({ top: clamped, behavior: "smooth" });
548
+ isAutoScroll = false;
549
+ userScrolledUp = true;
550
+ flashLine = line;
551
+ if (flashTimer !== null) clearTimeout(flashTimer);
552
+ flashTimer = setTimeout(() => {
553
+ flashLine = null;
554
+ flashTimer = null;
555
+ }, 1500);
556
+ },
557
+ clear: () => {
560
558
  flashLine = null;
561
- flashTimer = null;
562
- }, 1500);
559
+ },
563
560
  });
561
+ $effect(syncHighlightScroll);
564
562
 
565
563
  $effect(() => {
566
564
  if (!containerEl) return;
@@ -651,7 +649,7 @@
651
649
  : 'text-[var(--rw-con-dim)] hover:text-aurora-400'}"
652
650
  title="{line.frameCount} earlier frame{line.frameCount === 1
653
651
  ? ''
654
- : 's'} — click to {expandedLine === line.num
652
+ : 's'}, click to {expandedLine === line.num
655
653
  ? 'hide'
656
654
  : 'rewind'}"
657
655
  aria-label="Toggle frame history for line {line.num + 1}"
@@ -911,7 +909,7 @@
911
909
  border-top-color: color-mix(in srgb, var(--rw-term-warn) 30%, transparent);
912
910
  }
913
911
 
914
- /* Subtle row hover on the fixed-dark console surface — a faint lift of the
912
+ /* Subtle row hover on the fixed-dark console surface, a faint lift of the
915
913
  console text colour, never a theme-flipping fill. */
916
914
  .log-line:hover {
917
915
  background-color: color-mix(in srgb, var(--rw-con-text) 4%, transparent);
@@ -941,7 +939,7 @@
941
939
 
942
940
  /* Blinking teal block caret shown while a run is actively streaming. One
943
941
  monospace cell wide so it reads as a terminal cursor parked at the write
944
- position — inline after a still-appending line, or alone on the next. */
942
+ position, inline after a still-appending line, or alone on the next. */
945
943
  .stream-cursor {
946
944
  display: inline-block;
947
945
  width: 1ch;
@@ -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
 
8
9
  interface Props {
9
10
  open?: boolean;
@@ -20,7 +21,6 @@
20
21
  class?: string;
21
22
  }
22
23
 
23
- import { portal } from "../actions/portal.js";
24
24
  import { trapFocus } from "../actions/focusTrap.js";
25
25
 
26
26
  let {
@@ -36,6 +36,8 @@
36
36
  class: className = "",
37
37
  }: Props = $props();
38
38
 
39
+ const titleId = $props.id();
40
+
39
41
  const sizeClasses: Record<string, string> = {
40
42
  sm: "max-w-sm",
41
43
  md: "max-w-md",
@@ -49,35 +51,52 @@
49
51
  onClose?.();
50
52
  }
51
53
 
52
- function handleKeydown(e: KeyboardEvent) {
53
- if (e.key === "Escape" && closable) {
54
- handleClose();
55
- }
54
+ // showModal() puts the dialog in the top layer and makes the page behind it
55
+ // inert. The dialog stays open while mounted; `open` drives (un)mounting.
56
+ function showModal(node: HTMLDialogElement) {
57
+ node.showModal();
58
+ }
59
+
60
+ function handleCancel(e: Event) {
61
+ e.preventDefault();
62
+ if (closable) handleClose();
63
+ }
64
+
65
+ // Chrome ignores preventDefault() on a repeated Escape and closes anyway;
66
+ // reopen so a non-closable modal stays up while mounted.
67
+ function handleNativeClose(e: Event & { currentTarget: HTMLDialogElement }) {
68
+ if (open) e.currentTarget.showModal();
56
69
  }
57
- </script>
58
70
 
59
- <svelte:window onkeydown={handleKeydown} />
71
+ // Only a press that starts and ends on the backdrop closes the modal, so a
72
+ // text selection dragged out of the panel doesn't.
73
+ let pressedBackdrop = false;
74
+ function handlePointerDown(e: PointerEvent) {
75
+ pressedBackdrop = e.target === e.currentTarget;
76
+ }
77
+ function handleBackdropClick(e: MouseEvent) {
78
+ if (closable && pressedBackdrop && e.target === e.currentTarget) handleClose();
79
+ }
80
+ </script>
60
81
 
61
82
  {#if open}
62
- <div
63
- use:portal
83
+ <dialog
84
+ use:showModal
85
+ oncancel={handleCancel}
86
+ onclose={handleNativeClose}
87
+ onpointerdown={handlePointerDown}
88
+ onclick={handleBackdropClick}
89
+ aria-labelledby={title ? titleId : undefined}
64
90
  class="
65
- fixed inset-0
66
- z-50 flex items-center justify-center
67
- p-4
91
+ fixed inset-0 m-0 flex h-full max-h-none w-full max-w-none
92
+ items-center justify-center border-0 bg-transparent p-4
93
+ text-inherit backdrop:bg-transparent
68
94
  "
69
95
  >
70
- {#if closable}
71
- <button
72
- type="button"
73
- class="absolute inset-0 z-0 bg-backdrop backdrop-blur-sm"
74
- aria-label="Close modal"
75
- tabindex="-1"
76
- onclick={handleClose}
77
- ></button>
78
- {:else}
79
- <div class="absolute inset-0 z-0 bg-backdrop backdrop-blur-sm"></div>
80
- {/if}
96
+ <div
97
+ class="pointer-events-none absolute inset-0 z-0 bg-backdrop backdrop-blur-sm"
98
+ aria-hidden="true"
99
+ ></div>
81
100
 
82
101
  <div
83
102
  use:trapFocus
@@ -87,9 +106,6 @@
87
106
  rounded-[4px] border border-outline bg-surface-overlay shadow-lg
88
107
  {className}
89
108
  "
90
- role="dialog"
91
- aria-modal="true"
92
- aria-labelledby={title ? "modal-title" : undefined}
93
109
  >
94
110
  {#if header}
95
111
  <div class="border-b border-outline px-6 py-4">
@@ -101,12 +117,7 @@
101
117
  >
102
118
  <div>
103
119
  {#if title}
104
- <h2
105
- id="modal-title"
106
- class="font-mono text-lg font-semibold text-on-surface"
107
- >
108
- {title}
109
- </h2>
120
+ <Heading level={2} size="lg" id={titleId}>{title}</Heading>
110
121
  {/if}
111
122
  {#if description}
112
123
  <p class="mt-1 text-sm text-on-surface-muted">{description}</p>
@@ -136,5 +147,5 @@
136
147
  </div>
137
148
  {/if}
138
149
  </div>
139
- </div>
150
+ </dialog>
140
151
  {/if}
@@ -9,6 +9,7 @@
9
9
  <script lang="ts">
10
10
  import type { Snippet } from "svelte";
11
11
  import { Bell } from "@lucide/svelte";
12
+ import Heading from "./Heading.svelte";
12
13
  import Popover from "./Popover.svelte";
13
14
 
14
15
  interface Props {
@@ -60,7 +61,7 @@
60
61
 
61
62
  <div role="dialog" aria-label={title} class="w-96 max-w-[90vw]">
62
63
  <div class="mb-2 flex items-center justify-between gap-2">
63
- <h2 class="font-mono text-sm font-semibold text-on-surface">{title}</h2>
64
+ <Heading level={2} size="sm">{title}</Heading>
64
65
  {#if actions}
65
66
  <div class="flex items-center gap-3 text-xs">{@render actions()}</div>
66
67
  {/if}
@@ -4,16 +4,7 @@
4
4
  <script lang="ts">
5
5
  import type { Snippet } from "svelte";
6
6
 
7
- /**
8
- * PageContainer — the single, standardized page-level wrapper.
9
- *
10
- * Variants:
11
- * "standard" – Max-width + centering + padding + flex-col + min-h-full.
12
- * "full" – Same as standard but h-full (fills viewport).
13
- * "wide" – Max-width + centering only (consumer provides padding).
14
- * "narrow" – Narrower max-width + centering.
15
- * "flush" – Fills container height, no max-width.
16
- */
7
+ /** The standard page-level wrapper. See variantClasses for each variant's layout. */
17
8
  let {
18
9
  children,
19
10
  variant = "standard",
@@ -3,11 +3,13 @@
3
3
 
4
4
  <script lang="ts">
5
5
  import type { Snippet } from "svelte";
6
+ import Heading from "./Heading.svelte";
6
7
 
7
8
  interface Props {
8
9
  title: string;
9
- subtitle?: string;
10
- actions?: Snippet;
10
+ /** Plain text, or a snippet when the line carries tokens (counts, names). */
11
+ subtitle?: string | Snippet;
12
+ actions?: Snippet | undefined;
11
13
  class?: string;
12
14
  }
13
15
 
@@ -16,8 +18,10 @@
16
18
 
17
19
  <div class="flex shrink-0 flex-wrap items-start justify-between gap-3 px-1 {className}">
18
20
  <div class="min-w-0">
19
- <h1 class="font-mono text-2xl font-bold tracking-tight text-on-surface">{title}</h1>
20
- {#if subtitle}
21
+ <Heading level={1} size="2xl">{title}</Heading>
22
+ {#if typeof subtitle === "function"}
23
+ <p class="mt-0.5 text-sm text-on-surface-muted">{@render subtitle()}</p>
24
+ {:else if subtitle}
21
25
  <p class="mt-0.5 text-sm text-on-surface-muted">{subtitle}</p>
22
26
  {/if}
23
27
  </div>
@@ -4,9 +4,8 @@
4
4
  <script lang="ts">
5
5
  import type { Snippet } from "svelte";
6
6
  import type { Placement } from "@floating-ui/dom";
7
- import { onDestroy } from "svelte";
8
- import { computePosition, autoUpdate, flip, shift, offset } from "@floating-ui/dom";
9
7
  import { fade } from "svelte/transition";
8
+ import { floating, isOutsideClick } from "../actions/floating.js";
10
9
  import { portal } from "../actions/portal.js";
11
10
  import { trapFocus } from "../actions/focusTrap.js";
12
11
 
@@ -33,7 +32,6 @@
33
32
 
34
33
  let triggerEl = $state<HTMLElement | null>(null);
35
34
  let contentEl = $state<HTMLElement | null>(null);
36
- let cleanupFloating = $state<(() => void) | null>(null);
37
35
 
38
36
  // Track the `md` breakpoint only when sheet mode is requested, so the
39
37
  // content can switch between floating (desktop) and bottom sheet (phone)
@@ -50,64 +48,17 @@
50
48
 
51
49
  const asSheet = $derived(mobileSheet && !isDesktop);
52
50
 
53
- function teardown() {
54
- cleanupFloating?.();
55
- cleanupFloating = null;
56
- }
57
-
58
- function setupFloating() {
59
- if (!triggerEl || !contentEl) return;
60
- cleanupFloating = autoUpdate(triggerEl, contentEl, () => {
61
- if (!triggerEl || !contentEl) return;
62
- void computePosition(triggerEl, contentEl, {
63
- placement,
64
- middleware: [offset(8), flip(), shift({ padding: 8 })],
65
- }).then(({ x, y }) => {
66
- if (contentEl) {
67
- Object.assign(contentEl.style, {
68
- left: `${String(x)}px`,
69
- top: `${String(y)}px`,
70
- position: "absolute",
71
- });
72
- }
73
- });
74
- });
75
- }
76
-
77
- // Position the content while open: float it on desktop, or pin it as a
78
- // sheet on phone (clearing any inline coords floating-ui left behind).
79
- $effect(() => {
80
- if (!open || !contentEl) {
81
- teardown();
82
- return;
83
- }
84
- if (asSheet) {
85
- teardown();
86
- contentEl.style.removeProperty("left");
87
- contentEl.style.removeProperty("top");
88
- contentEl.style.removeProperty("position");
89
- return;
90
- }
91
- setupFloating();
92
- return teardown;
93
- });
94
-
95
51
  function toggle() {
96
52
  open = !open;
97
53
  }
98
54
 
99
55
  function handleOutsideClick(e: MouseEvent) {
100
- const path = e.composedPath();
101
- if (triggerEl && path.includes(triggerEl)) return;
102
- if (contentEl && path.includes(contentEl)) return;
103
- if (open) open = false;
56
+ if (open && isOutsideClick(e, triggerEl, contentEl)) open = false;
104
57
  }
105
58
 
106
59
  function handleKeyDown(e: KeyboardEvent) {
107
60
  if (e.key === "Escape" && open) open = false;
108
61
  }
109
-
110
- onDestroy(teardown);
111
62
  </script>
112
63
 
113
64
  <svelte:window onclick={handleOutsideClick} onkeydown={handleKeyDown} />
@@ -146,6 +97,13 @@
146
97
  <div
147
98
  use:portal
148
99
  use:trapFocus
100
+ use:floating={{
101
+ reference: triggerEl,
102
+ placement,
103
+ offset: 8,
104
+ padding: 8,
105
+ disabled: asSheet,
106
+ }}
149
107
  bind:this={contentEl}
150
108
  class="
151
109
  z-[9999] max-h-[80vh] overflow-y-auto
@@ -12,6 +12,9 @@
12
12
  size?: ProgressSize;
13
13
  showLabel?: boolean;
14
14
  label?: string;
15
+ /** Percent at which the bar turns warning / danger, overriding `variant`
16
+ * (e.g. `{ warning: 70, danger: 90 }` for CPU or memory). */
17
+ thresholds?: { warning?: number; danger?: number };
15
18
  class?: string;
16
19
  }
17
20
 
@@ -22,11 +25,18 @@
22
25
  size = "md",
23
26
  showLabel = false,
24
27
  label,
28
+ thresholds,
25
29
  class: className = "",
26
30
  }: Props = $props();
27
31
 
28
32
  const percentage = $derived(Math.min(Math.max((value / max) * 100, 0), 100));
29
33
 
34
+ const tone = $derived.by((): ProgressVariant => {
35
+ if (thresholds?.danger !== undefined && percentage >= thresholds.danger) return "danger";
36
+ if (thresholds?.warning !== undefined && percentage >= thresholds.warning) return "warning";
37
+ return variant;
38
+ });
39
+
30
40
  const variantClasses: Record<ProgressVariant, string> = {
31
41
  default: "bg-primary",
32
42
  success: "bg-success-surface",
@@ -55,16 +65,19 @@
55
65
  </div>
56
66
  {/if}
57
67
 
58
- <div class="w-full {sizeClasses[size]} overflow-hidden rounded-full bg-surface-sunken">
68
+ <div
69
+ class="w-full {sizeClasses[size]} overflow-hidden rounded-full bg-surface-sunken"
70
+ role="progressbar"
71
+ aria-label={label}
72
+ aria-valuenow={value}
73
+ aria-valuemin={0}
74
+ aria-valuemax={max}
75
+ >
59
76
  <div
60
77
  class="{sizeClasses[size]} {variantClasses[
61
- variant
78
+ tone
62
79
  ]} rounded-full transition-[width] duration-200"
63
80
  style="width: {percentage}%"
64
- role="progressbar"
65
- aria-valuenow={value}
66
- aria-valuemin={0}
67
- aria-valuemax={max}
68
81
  ></div>
69
82
  </div>
70
83
  </div>