@runwisp/ui 1.5.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 (32) hide show
  1. package/package.json +4 -4
  2. package/src/lib/actions/focusTrap.ts +3 -3
  3. package/src/lib/components/Badge.svelte +1 -1
  4. package/src/lib/components/DataGrid.svelte +5 -5
  5. package/src/lib/components/FilterBar.svelte +1 -1
  6. package/src/lib/components/FilterPill.svelte +2 -2
  7. package/src/lib/components/FormField.svelte +1 -1
  8. package/src/lib/components/LogConsole.svelte +10 -10
  9. package/src/lib/components/SearchInput.svelte +1 -1
  10. package/src/lib/components/Select.svelte +3 -2
  11. package/src/lib/components/Sparkline.svelte +2 -2
  12. package/src/lib/components/StatusDot.svelte +1 -1
  13. package/src/lib/components/Tooltip.svelte +1 -1
  14. package/src/lib/components/dashboard/RunDetailPanel.svelte +15 -16
  15. package/src/lib/components/dashboard/RunFilterPopover.svelte +5 -5
  16. package/src/lib/components/dashboard/RunsList.svelte +8 -8
  17. package/src/lib/components/dashboard/cron-format.test.ts +1 -1
  18. package/src/lib/components/dashboard/cron-format.ts +2 -2
  19. package/src/lib/components/dashboard/run-filters.test.ts +2 -2
  20. package/src/lib/components/dashboard/run-filters.ts +27 -94
  21. package/src/lib/components/dashboard/run-helpers.test.ts +9 -0
  22. package/src/lib/components/dashboard/run-helpers.ts +28 -38
  23. package/src/lib/components/data-grid-selection.ts +2 -9
  24. package/src/lib/components/filter-spec.ts +3 -3
  25. package/src/lib/log-console/LogCache.svelte.ts +1 -1
  26. package/src/lib/log-console/ansi.ts +1 -1
  27. package/src/lib/theme-tokens.css +21 -21
  28. package/src/lib/theme.css +13 -13
  29. package/src/lib/utils/format.test.ts +3 -3
  30. package/src/lib/utils/format.ts +4 -4
  31. package/src/lib/utils/toast.svelte.ts +2 -3
  32. package/src/stories/LogConsole.stories.svelte +2 -2
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@runwisp/ui",
3
3
  "description": "Svelte 5 UI component library for RunWisp dashboards and admin interfaces.",
4
- "version": "1.5.0",
4
+ "version": "1.5.1",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -49,8 +49,8 @@
49
49
  "devDependencies": {
50
50
  "@chromatic-com/storybook": "^5.2.1",
51
51
  "@eslint/js": "^10.0.1",
52
- "@runwisp/eslint-config": "1.5.0",
53
- "@runwisp/typescript-config": "1.5.0",
52
+ "@runwisp/eslint-config": "1.5.1",
53
+ "@runwisp/typescript-config": "1.5.1",
54
54
  "@storybook/addon-a11y": "^10.5.5",
55
55
  "@storybook/addon-docs": "^10.5.5",
56
56
  "@storybook/addon-svelte-csf": "^5.1.2",
@@ -93,7 +93,7 @@
93
93
  "@fontsource-variable/geist-mono": "^5.3.0",
94
94
  "@fontsource/tasa-orbiter": "^5.3.0",
95
95
  "@lucide/svelte": "^1.52.0",
96
- "@runwisp/common": "1.5.0",
96
+ "@runwisp/common": "1.5.1",
97
97
  "@tanstack/svelte-virtual": "^3.13.35",
98
98
  "ansi-to-html": "^0.7.2",
99
99
  "cron-parser": "^5.7.0",
@@ -25,7 +25,7 @@ export function getFocusableElements(container: HTMLElement): HTMLElement[] {
25
25
  * Pure decision function: given a keydown event, the currently focusable
26
26
  * elements (in order), and whatever currently has focus, returns the element
27
27
  * that Tab/Shift+Tab should wrap focus to in order to keep it trapped inside
28
- * the set — or `null` if the key doesn't need handling.
28
+ * the set, or `null` if the key doesn't need handling.
29
29
  *
30
30
  * Framework- and DOM-free on purpose (elements are compared by identity only)
31
31
  * so this can be unit tested without a browser or jsdom.
@@ -59,7 +59,7 @@ export interface TrapFocusOptions {
59
59
  * Svelte action that keeps keyboard focus inside `node` while it's mounted:
60
60
  * Tab from the last focusable descendant wraps to the first (and Shift+Tab
61
61
  * from the first wraps to the last), and focus is restored to whatever was
62
- * focused beforehand once the trap is torn down — unless a click on some
62
+ * focused beforehand once the trap is torn down, unless a click on some
63
63
  * other real element already moved focus there, in which case that's left
64
64
  * alone.
65
65
  */
@@ -97,7 +97,7 @@ export function trapFocus(node: HTMLElement, options: TrapFocusOptions = {}) {
97
97
  destroy() {
98
98
  node.removeEventListener("keydown", handleKeydown);
99
99
  // By the time an action's destroy() runs, `node` (and whatever
100
- // inside it held focus) has already been detached — removing a
100
+ // inside it held focus) has already been detached, removing a
101
101
  // focused element resets focus to <body> as a browser default, so
102
102
  // that can't be told apart from an explicit close (Escape, item
103
103
  // activation) by checking `node.contains(activeElement)` here.
@@ -17,7 +17,7 @@
17
17
  outline?: boolean;
18
18
  /** Shown in the styled hover tooltip (see {@link Tooltip}). */
19
19
  tooltip?: string;
20
- /** Side the tooltip opens on — pick one that has room, e.g. "bottom" near the top of the viewport. */
20
+ /** Side the tooltip opens on, pick one that has room, e.g. "bottom" near the top of the viewport. */
21
21
  tooltipPosition?: TooltipPosition;
22
22
  children?: Snippet;
23
23
  class?: string;
@@ -46,16 +46,16 @@
46
46
  onRowClick?: (row: T) => void;
47
47
  loading?: boolean;
48
48
  stickyHeader?: boolean;
49
- /** 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
50
50
  * inbox) where a column label would be noise. */
51
51
  showHeader?: boolean;
52
- /** 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
53
53
  * "Load more" button for feeds that don't use offset pagination. */
54
54
  footer?: Snippet;
55
55
  /** Drop the outer border/shadow/bg so the grid sits flush inside a Card
56
56
  * or panel that already supplies its own frame. */
57
57
  bare?: boolean;
58
- /** 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.
59
59
  * Rows always carry `group`, so a `render`/`rowAction` snippet can use
60
60
  * `group-hover:` to reveal on-hover controls. */
61
61
  rowClass?: (row: T) => string;
@@ -66,7 +66,7 @@
66
66
  // Type-aware comparator. Nullish sorts last; numbers numerically; dates by
67
67
  // epoch; everything else by locale with numeric-aware string compare.
68
68
  // Returns 0 for non-comparable/equal, so a column whose key maps to no real
69
- // 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.
70
70
  function compareValues(a: unknown, b: unknown): number {
71
71
  if (a == null && b == null) return 0;
72
72
  if (a == null) return 1;
@@ -193,7 +193,7 @@
193
193
  let allSelected = $derived(selection.allSelected);
194
194
  let someSelected = $derived(selection.someSelected);
195
195
 
196
- // Cell rhythm — airy by default, tight when compact. Header + body share it.
196
+ // Cell rhythm, airy by default, tight when compact. Header + body share it.
197
197
  const density = $derived(compact ? "compact" : "comfortable");
198
198
  // Hover reveals a teal accent rail on the leftmost cell (see .group on <tr>).
199
199
  const railHover = "group-hover:shadow-[inset_3px_0_0_var(--color-primary)]";
@@ -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;
@@ -148,7 +148,7 @@
148
148
  // lineTop / totalHeight / the scroll-position→line lookup stay O(1) or
149
149
  // O(log n) even though rows are no longer uniform. A plain Map is
150
150
  // deliberate: reactivity is driven by the `rowCountVersion` counter (bumped
151
- // 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
152
152
  // tens of thousands of lines on every streamed line would be wasteful.
153
153
  // eslint-disable-next-line svelte/prefer-svelte-reactivity
154
154
  const rowCounts = new Map<number, number>();
@@ -175,7 +175,7 @@
175
175
  // Measures wrapped row counts for exactly the lines in [min, max] and bumps
176
176
  // rowCountVersion if anything changed. Called directly wherever new line
177
177
  // text lands in the cache (streamed append, on-demand backfill), each of
178
- // 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
179
179
  // a single appended line costs O(1) here instead of re-walking the whole
180
180
  // (up to 50k-line) cache. Plain function, not an $effect: reads/writes
181
181
  // rowCounts and cache.lines by key, never by iterating the map.
@@ -201,7 +201,7 @@
201
201
  // or the available column width changes (wrap width affects every line's
202
202
  // row count, so there's no way to scope that to a range). Line
203
203
  // appends/backfills are measured incrementally at their call sites via
204
- // measureRange instead — reading `cache.lines` in this effect's tracked
204
+ // measureRange instead, reading `cache.lines` in this effect's tracked
205
205
  // scope would resubscribe it to the SvelteMap's shared version signal and
206
206
  // re-run this full O(n) walk on every single appended line. `untrack`
207
207
  // (same pattern as the fetch/prune effects below) keeps that read out of
@@ -372,7 +372,7 @@
372
372
 
373
373
  // Width of the virtual scroll surface: wide enough for the longest line
374
374
  // seen so far, never narrower than the viewport (so short logs show no
375
- // horizontal scrollbar). Wrapping pins the surface to the viewport — there
375
+ // horizontal scrollbar). Wrapping pins the surface to the viewport, there
376
376
  // is no horizontal axis to scroll.
377
377
  let surfaceWidth = $derived(
378
378
  wrap
@@ -429,14 +429,14 @@
429
429
  }
430
430
  });
431
431
 
432
- // 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.
433
433
  $effect(() => {
434
434
  const vs = visibleStart;
435
435
  const ve = visibleEnd;
436
436
  untrack(() => cache.prune(vs, ve));
437
437
  });
438
438
 
439
- let prevTailRows = 0; // plain variable — intentionally non-reactive
439
+ let prevTailRows = 0; // plain variable, intentionally non-reactive
440
440
 
441
441
  $effect(() => {
442
442
  // Track committed lines AND live overlay rows so an animating region at
@@ -493,7 +493,7 @@
493
493
  containerWidth = containerEl.clientWidth;
494
494
  // Resync the reactive scroll position with the DOM. The browser
495
495
  // clamps scrollTop when the viewport grows (e.g. maximizing the
496
- // console), but no scroll event fires for that clamp — so without
496
+ // console), but no scroll event fires for that clamp, so without
497
497
  // this the virtualizer keeps a stale, too-large scrollTop and
498
498
  // renders an empty window until the first manual scroll.
499
499
  scrollTop = containerEl.scrollTop;
@@ -649,7 +649,7 @@
649
649
  : 'text-[var(--rw-con-dim)] hover:text-aurora-400'}"
650
650
  title="{line.frameCount} earlier frame{line.frameCount === 1
651
651
  ? ''
652
- : 's'} — click to {expandedLine === line.num
652
+ : 's'}, click to {expandedLine === line.num
653
653
  ? 'hide'
654
654
  : 'rewind'}"
655
655
  aria-label="Toggle frame history for line {line.num + 1}"
@@ -909,7 +909,7 @@
909
909
  border-top-color: color-mix(in srgb, var(--rw-term-warn) 30%, transparent);
910
910
  }
911
911
 
912
- /* 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
913
913
  console text colour, never a theme-flipping fill. */
914
914
  .log-line:hover {
915
915
  background-color: color-mix(in srgb, var(--rw-con-text) 4%, transparent);
@@ -939,7 +939,7 @@
939
939
 
940
940
  /* Blinking teal block caret shown while a run is actively streaming. One
941
941
  monospace cell wide so it reads as a terminal cursor parked at the write
942
- 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. */
943
943
  .stream-cursor {
944
944
  display: inline-block;
945
945
  width: 1ch;
@@ -41,7 +41,7 @@
41
41
  // External writes to `value` (Reset / clear-all) win immediately: adopt them
42
42
  // and drop any pending debounce. Depends only on `value` (raw is read
43
43
  // untracked), so typing never re-enters here. Tiny race: a reset that leaves
44
- // `value` unchanged ("" -> "") can't cancel a first-keystroke debounce —
44
+ // `value` unchanged ("" -> "") can't cancel a first-keystroke debounce,
45
45
  // negligible at 250ms. ponytail: guarded two-way sync, fine for one field.
46
46
  $effect(() => {
47
47
  const v = value;
@@ -21,7 +21,6 @@
21
21
  import { floating, isOutsideClick } from "../actions/floating.js";
22
22
  import { portal } from "../actions/portal.js";
23
23
  import { trapFocus } from "../actions/focusTrap.js";
24
- import { generateUlid } from "@runwisp/common";
25
24
 
26
25
  type SelectSize = "sm" | "md" | "lg";
27
26
 
@@ -41,6 +40,8 @@
41
40
  onchange?: (value: unknown) => void;
42
41
  }
43
42
 
43
+ const uid = $props.id();
44
+
44
45
  let {
45
46
  value = $bindable(),
46
47
  options = [],
@@ -52,7 +53,7 @@
52
53
  error,
53
54
  hint,
54
55
  class: className = "",
55
- id = `select-${generateUlid()}`,
56
+ id = `select-${uid}`,
56
57
  name,
57
58
  onchange,
58
59
  onsearch,
@@ -12,7 +12,7 @@
12
12
  }
13
13
 
14
14
  // `currentColor` by default so the caller can theme us with a Tailwind
15
- // text-* class — that's the only way to flip on .dark without burning a
15
+ // text-* class, that's the only way to flip on .dark without burning a
16
16
  // hardcoded hex in here.
17
17
  let {
18
18
  data,
@@ -47,7 +47,7 @@
47
47
  return `${pathD} L${lastX.toFixed(1)},${height} L0,${height} Z`;
48
48
  });
49
49
 
50
- // Use `fill-opacity` rather than appending "18" to a hex — that trick
50
+ // Use `fill-opacity` rather than appending "18" to a hex, that trick
51
51
  // breaks for `currentColor` / `oklch()` / `var(...)` values.
52
52
  let resolvedFill = $derived(fillColor ?? color);
53
53
  let resolvedFillOpacity = $derived(fillColor === undefined ? fillOpacity : 1);
@@ -8,7 +8,7 @@
8
8
  <script lang="ts">
9
9
  interface Props {
10
10
  tone?: StatusDotTone;
11
- /** Radar ping around the dot — use for "live" states only. */
11
+ /** Radar ping around the dot, use for "live" states only. */
12
12
  pulse?: boolean;
13
13
  size?: "sm" | "md" | "lg";
14
14
  class?: string;
@@ -45,7 +45,7 @@
45
45
  {@render children()}
46
46
 
47
47
  <!-- Hover intent: 100ms before it appears, so sweeping the pointer across a
48
- dense row doesn't flash a trail of bubbles. Still no fade — `duration-0`
48
+ dense row doesn't flash a trail of bubbles. Still no fade, `duration-0`
49
49
  keeps the appearance instant once the delay is served, per DESIGN.md. -->
50
50
  <div
51
51
  class="
@@ -47,6 +47,7 @@
47
47
  formatTriggeredByLabel,
48
48
  runRetryLabel,
49
49
  runUsageLabel,
50
+ runLogDownloadUrl,
50
51
  instanceSuffix,
51
52
  } from "./run-helpers.js";
52
53
 
@@ -102,7 +103,7 @@
102
103
  // Stop the selected run while it is live. When provided and the run is
103
104
  // running, the action cluster shows Stop alongside Run.
104
105
  onStop?: ((runId: string) => void) | undefined;
105
- // Trigger the task from the empty state (no run selected yet) — the
106
+ // Trigger the task from the empty state (no run selected yet), the
106
107
  // cold-start path so a never-run task is still launchable from here.
107
108
  onRunTask?: (() => void) | undefined;
108
109
  // Service lifecycle controls. When either is set the cluster swaps the
@@ -132,7 +133,7 @@
132
133
  motion?: RunMotion;
133
134
  // True when a deep-linked run id resolved to no run (deleted by retention,
134
135
  // or never existed). The empty state then says so plainly instead of the
135
- // generic "Select a run" — the caller must not silently substitute another.
136
+ // generic "Select a run", the caller must not silently substitute another.
136
137
  notFound?: boolean;
137
138
  // True while there is no run to show *yet* (the run list or a deep-linked
138
139
  // run is still loading), so the empty state doesn't claim "No runs yet".
@@ -194,8 +195,8 @@
194
195
  // below) and paints it into the console. Resolves the line the live
195
196
  // stream should resume from, or null when there's nothing left to stream
196
197
  // (the run already ended, or the caller cancelled while we were waiting).
197
- // A failed fetch still falls back to an SSE tail backfill from scratch —
198
- // it does not stop the console from live-tailing — but records the
198
+ // A failed fetch still falls back to an SSE tail backfill from scratch,
199
+ // it does not stop the console from live-tailing, but records the
199
200
  // failure so LogConsole can show it instead of looking like a quiet run.
200
201
  async function seedConsoleTail(
201
202
  id: string,
@@ -260,7 +261,7 @@
260
261
  });
261
262
 
262
263
  // Maximize the console to a full-bleed overlay. The console wrapper is
263
- // never remounted — `maximizePortal` relocates the *live* node to <body>
264
+ // never remounted, `maximizePortal` relocates the *live* node to <body>
264
265
  // (escaping any transformed/blurred ancestor that would otherwise trap a
265
266
  // position:fixed child) and moves it back on restore, so the SSE stream,
266
267
  // scroll position, and the {#key run.id} mount all survive the toggle.
@@ -282,7 +283,7 @@
282
283
 
283
284
  // The accent for a status that means "something to triage", or undefined
284
285
  // when there is nothing wrong. Tint is reserved for alarms (DESIGN.md) so a
285
- // failure is the one lit thing on the page — a deliberate stop or a skip is
286
+ // failure is the one lit thing on the page, a deliberate stop or a skip is
286
287
  // not a failure, and success least of all. Doubles as the switch for the
287
288
  // verdict phrase's colour, so wash and wording light up together.
288
289
  function alarmAccent(displayed: string): string | undefined {
@@ -306,7 +307,7 @@
306
307
  consoleMaximized = false;
307
308
  return;
308
309
  }
309
- // `F` toggles focus mode, the way a media player does — but never while
310
+ // `F` toggles focus mode, the way a media player does, but never while
310
311
  // the operator is typing into a field (search box, etc.).
311
312
  if (
312
313
  (event.key === "f" || event.key === "F") &&
@@ -411,7 +412,7 @@
411
412
  <!-- Detailed header: a readout whose ink scales with how much went wrong.
412
413
  Every run states its outcome in one phrase over one quiet fact line;
413
414
  only a run worth triaging colours the phrase and tints the surface.
414
- Nothing here is a fixed slot — every fact renders only when it is
415
+ Nothing here is a fixed slot, every fact renders only when it is
415
416
  true, so the header's height is itself a signal. -->
416
417
  <div
417
418
  class="head-region @container relative shrink-0 border-b border-outline-faint"
@@ -461,9 +462,9 @@
461
462
 
462
463
  <!-- Verdict: the outcome as one sentence, and the only large
463
464
  type in the panel. It is prose, so the phrase is sans and
464
- only the tokens inside it — the duration, a failing exit —
465
+ only the tokens inside it, the duration, a failing exit,
465
466
  are mono (DESIGN.md's mono-vs-sans rule). The glyph is bare
466
- — no plate, no ring — so it reads as part of the sentence,
467
+ (no plate, no ring) so it reads as part of the sentence,
467
468
  and it alone carries the outcome colour on a healthy run:
468
469
  green words are ink spent on nothing being wrong. -->
469
470
  <Tooltip content={config.description} position="right" wide>
@@ -717,9 +718,7 @@
717
718
  {/if}
718
719
  {/if}
719
720
  <a
720
- href="/api/tasks/{encodeURIComponent(
721
- run.taskName,
722
- )}/runs/{encodeURIComponent(run.id)}/log/raw"
721
+ href={runLogDownloadUrl(run.id)}
723
722
  download="{run.taskName}-{run.id}.log"
724
723
  class="inline-flex items-center justify-center rounded-[3px] border border-outline-faint bg-surface-raised p-2 text-on-surface-muted hover:border-outline-hover hover:bg-surface-sunken hover:text-primary"
725
724
  title="Download the full log (rotated and current parts as one file)"
@@ -773,7 +772,7 @@
773
772
  </div>
774
773
  </div>
775
774
 
776
- <!-- Console hero — maximizes to a full-bleed overlay via portal -->
775
+ <!-- Console hero, maximizes to a full-bleed overlay via portal -->
777
776
  {#if consoleMaximized}
778
777
  <div
779
778
  use:portal
@@ -902,7 +901,7 @@
902
901
  <EmptyState
903
902
  title={notFound ? "Run not found" : onRunTask ? "No runs yet" : "Select a run"}
904
903
  description={notFound
905
- ? "This run doesn't exist — it may have been deleted by retention, or the link is wrong. Pick a run from the list to continue."
904
+ ? "This run doesn't exist. It may have been deleted by retention, or the link is wrong. Pick a run from the list to continue."
906
905
  : onRunTask
907
906
  ? "This task hasn't run yet. Trigger it to see its output here."
908
907
  : "Pick a run from the list to view details and logs."}
@@ -923,7 +922,7 @@
923
922
 
924
923
  <style>
925
924
  /* Only a run worth triaging is washed in its outcome colour (--rw-oc, set
926
- inline). Everything else — success included — passes the surface itself so
925
+ inline). Everything else, success included, passes the surface itself so
927
926
  the mix collapses to a plain surface, which is what lets a genuine failure
928
927
  be the one lit thing on the page. */
929
928
  .head-region {
@@ -9,12 +9,12 @@
9
9
  renders removable chips for each.
10
10
 
11
11
  Status is a five-bucket pick (Running / Succeeded / Failed / Skipped /
12
- Stopped) with an "Advanced" expander for the individual statuses — buckets are
12
+ Stopped) with an "Advanced" expander for the individual statuses, buckets are
13
13
  pure UI groupings over the same `statuses` array. Time is an independent
14
14
  From/To date pair; exit code is a free-form expression (137, >100, >100 <150)
15
15
  normalized to an inclusive range at the wire.
16
16
 
17
- All controls are NATIVE (<select>, <input>, checkbox, button) — a portalled
17
+ All controls are NATIVE (<select>, <input>, checkbox, button), a portalled
18
18
  Select component must not nest inside the portalled Popover, since clicking an
19
19
  option lands outside the popover's DOM subtree and triggers its outside-click
20
20
  close. Every mutation reassigns the whole `filters` object (never an in-place
@@ -75,7 +75,7 @@
75
75
  }
76
76
 
77
77
  // Reflect a tri-state bucket onto the native checkbox (no `indeterminate`
78
- // HTML attribute exists — it must be set on the DOM node).
78
+ // HTML attribute exists, it must be set on the DOM node).
79
79
  function indeterminate(node: HTMLInputElement, value: boolean) {
80
80
  node.indeterminate = value;
81
81
  return {
@@ -95,7 +95,7 @@
95
95
  // --- Time -------------------------------------------------------------
96
96
  //
97
97
  // Independent From/To date bounds. A bare date is that day's 00:00 (From) or
98
- // its end (To), so either edge applies on its own — From alone means
98
+ // its end (To), so either edge applies on its own, From alone means
99
99
  // "everything since", To alone "everything before", and the same date in
100
100
  // both captures the whole day.
101
101
 
@@ -186,7 +186,7 @@
186
186
  class="flex cursor-pointer items-center gap-1 font-mono text-2xs font-medium text-on-surface-muted select-none marker:content-none hover:text-on-surface [&::-webkit-details-marker]:hidden"
187
187
  >
188
188
  <ChevronRight size={12} class={advancedOpen ? "rotate-90" : ""} />
189
- Advanced — pick exact statuses
189
+ Advanced: pick exact statuses
190
190
  </summary>
191
191
  <div class="mt-2 flex flex-col gap-1.5">
192
192
  {#each STATUS_GROUPS as group (group.label)}
@@ -114,7 +114,7 @@
114
114
  // null = no active search; the full list shows.
115
115
  outputMatches?: Map<string, RunOutputMatch> | null;
116
116
  // True while a query is typed but its results aren't in yet (debounce
117
- // window or request in flight) — the rail shows its searching shimmer.
117
+ // window or request in flight), the rail shows its searching shimmer.
118
118
  outputSearchPending?: boolean;
119
119
  } = $props();
120
120
 
@@ -124,7 +124,7 @@
124
124
  const OVERSCAN = 8;
125
125
  const LOAD_AHEAD = 10;
126
126
 
127
- // Selection model: two modes —
127
+ // Selection model: two modes,
128
128
  // 1. explicit: user picked specific rows. explicitIds holds them.
129
129
  // 2. selectAll: "all matching the current filter". exceptIds holds opt-outs.
130
130
  let selectAllMode = $state(false);
@@ -139,13 +139,13 @@
139
139
 
140
140
  // Loaded runs that matched the output search, in list order. A match in a
141
141
  // not-yet-loaded run can't be shown until the rail scrolls far enough to
142
- // load it — the count reflects what's loaded, not the whole history.
142
+ // load it, the count reflects what's loaded, not the whole history.
143
143
  const matchedRuns = $derived(
144
144
  outputMatches ? items.filter((r: Run) => outputMatches.has(r.id)) : [],
145
145
  );
146
146
 
147
147
  // Split the matching line into [before, match, after] around the first
148
- // occurrence of the query, windowed to keep the match in view — mirrors the
148
+ // occurrence of the query, windowed to keep the match in view, mirrors the
149
149
  // artifact's snippet. Rendered as plain text spans (Svelte auto-escapes), so
150
150
  // no untrusted HTML ever reaches the DOM.
151
151
  interface HighlightParts {
@@ -600,8 +600,8 @@
600
600
  </div>
601
601
  </div>
602
602
 
603
- <!-- Status dot. With bulk actions on it fades out — on row hover, or whenever a
604
- selection exists — so the row checkbox can take its place over it. -->
603
+ <!-- Status dot. With bulk actions on it fades out, on row hover, or whenever a
604
+ selection exists, so the row checkbox can take its place over it. -->
605
605
  <!-- Placeholders shaped like this list's run rows (same padding, dot, text
606
606
  and right readout), while a search or the first page is in flight. -->
607
607
  {#snippet skeletonRows(label: string)}
@@ -697,7 +697,7 @@
697
697
  >
698
698
  {#if showTaskName}
699
699
  <!-- Cross-task /runs variant: the same readout language as the task
700
- rail — status dot, status-colored outcome, mono right readout —
700
+ rail, status dot, status-colored outcome, mono right readout,
701
701
  with the task name carried as the primary. -->
702
702
  <div class="flex items-center gap-2.5">
703
703
  {@render statusDot(config.color, running)}
@@ -735,7 +735,7 @@
735
735
  </span>
736
736
  </div>
737
737
  {:else}
738
- <!-- Task-rail variant (artifact ".run"): a single dense line —
738
+ <!-- Task-rail variant (artifact ".run"): a single dense line,
739
739
  time · date · outcome, with a mono exit/duration readout.
740
740
  leading-tight matches the artifact's ~1.2 line-height so the
741
741
  (descender-less) text optically centers instead of riding high
@@ -52,7 +52,7 @@ describe("humanizeCron", () => {
52
52
  expect(result.isHumanized).toBe(false);
53
53
  });
54
54
 
55
- it("falls back to raw for unparseable expressions — never 'Invalid'", () => {
55
+ it("falls back to raw for unparseable expressions, never 'Invalid'", () => {
56
56
  const result = humanizeCron("not a cron");
57
57
  expect(result.humanized).toBe("not a cron");
58
58
  expect(result.isHumanized).toBe(false);
@@ -3,7 +3,7 @@
3
3
  import cronstrue from "cronstrue";
4
4
 
5
5
  interface HumanizedCron {
6
- /** Display text — humanized when possible, otherwise the raw expression. */
6
+ /** Display text, humanized when possible, otherwise the raw expression. */
7
7
  humanized: string;
8
8
  /** The original cron expression, for tooltips. */
9
9
  raw: string;
@@ -51,7 +51,7 @@ function humanizeEvery(duration: string): string | null {
51
51
  /**
52
52
  * humanizeCron renders a cron expression as plain English ("Every 5 minutes").
53
53
  * Handles robfig's `@every <duration>` extension (cronstrue throws on it) and
54
- * falls back to the raw expression on anything unparseable — never "Invalid".
54
+ * falls back to the raw expression on anything unparseable, never "Invalid".
55
55
  */
56
56
  export function humanizeCron(cron: string): HumanizedCron {
57
57
  const raw = cron.trim();
@@ -42,7 +42,7 @@ const base = (overrides: Partial<RunsListFilters> = {}): RunsListFilters => ({
42
42
  });
43
43
 
44
44
  describe("emptyRunFilters", () => {
45
- it("is a fully-open filter — no dimension active, newest-first", () => {
45
+ it("is a fully-open filter, no dimension active, newest-first", () => {
46
46
  const f = emptyRunFilters();
47
47
  expect(f.statuses).toEqual([]);
48
48
  expect(f.sortDirection).toBe("desc");
@@ -132,7 +132,7 @@ describe("statusChipLabel", () => {
132
132
  describe("STATUS_BUCKETS", () => {
133
133
  // The Failed bucket is the failure sentinel (resolved server-side to the
134
134
  // per-task is_failure classification), so the execution-failure reasons are
135
- // not bucketed — they stay reachable via the popover's Advanced expander.
135
+ // not bucketed, they stay reachable via the popover's Advanced expander.
136
136
  const FAILURE_REASONS = [
137
137
  "failed",
138
138
  "crashed",