@runwisp/ui 1.5.1 → 1.7.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 (53) hide show
  1. package/package.json +5 -7
  2. package/src/lib/actions/fold-to-fit.test.ts +36 -0
  3. package/src/lib/actions/fold-to-fit.ts +89 -0
  4. package/src/lib/actions/modal-dialog.test.ts +47 -0
  5. package/src/lib/actions/modal-dialog.ts +48 -0
  6. package/src/lib/components/AccordionItem.svelte +16 -5
  7. package/src/lib/components/Badge.svelte +3 -24
  8. package/src/lib/components/Button.svelte +2 -9
  9. package/src/lib/components/CodeBlock.svelte +7 -6
  10. package/src/lib/components/Drawer.svelte +22 -30
  11. package/src/lib/components/DurationInput.svelte +6 -25
  12. package/src/lib/components/FilterBar.svelte +2 -6
  13. package/src/lib/components/Link.svelte +9 -4
  14. package/src/lib/components/LogConsole.svelte +95 -418
  15. package/src/lib/components/Modal.svelte +7 -30
  16. package/src/lib/components/Spinner.svelte +10 -5
  17. package/src/lib/components/StatusIndicator.svelte +37 -104
  18. package/src/lib/components/Tabs.svelte +15 -1
  19. package/src/lib/components/dashboard/RunActions.svelte +156 -0
  20. package/src/lib/components/dashboard/RunConsoleFrame.svelte +319 -0
  21. package/src/lib/components/dashboard/RunDetailPanel.svelte +130 -717
  22. package/src/lib/components/dashboard/RunFacts.svelte +119 -0
  23. package/src/lib/components/dashboard/RunFilterChips.svelte +52 -0
  24. package/src/lib/components/dashboard/RunFilterPopover.svelte +4 -5
  25. package/src/lib/components/dashboard/RunHeaderLine.svelte +295 -0
  26. package/src/lib/components/dashboard/RunListSkeleton.svelte +43 -0
  27. package/src/lib/components/dashboard/RunRow.svelte +202 -0
  28. package/src/lib/components/dashboard/RunsList.svelte +265 -543
  29. package/src/lib/components/dashboard/bulk-selection.svelte.test.ts +42 -0
  30. package/src/lib/components/dashboard/bulk-selection.svelte.ts +50 -0
  31. package/src/lib/components/dashboard/cron-format.test.ts +9 -0
  32. package/src/lib/components/dashboard/cron-format.ts +5 -1
  33. package/src/lib/components/dashboard/run-filters.test.ts +60 -0
  34. package/src/lib/components/dashboard/run-filters.ts +78 -6
  35. package/src/lib/components/dashboard/run-helpers.test.ts +122 -12
  36. package/src/lib/components/dashboard/run-helpers.ts +122 -6
  37. package/src/lib/components/dashboard/status-config.ts +18 -0
  38. package/src/lib/components/dashboard/types.ts +6 -0
  39. package/src/lib/index.ts +13 -2
  40. package/src/lib/log-console/AnsiLine.svelte +12 -0
  41. package/src/lib/log-console/FrameHistoryBlock.svelte +57 -0
  42. package/src/lib/log-console/LogConsoleFooter.svelte +88 -0
  43. package/src/lib/log-console/frame-history.svelte.test.ts +47 -0
  44. package/src/lib/log-console/frame-history.svelte.ts +71 -0
  45. package/src/lib/log-console/wrap-layout.svelte.test.ts +92 -0
  46. package/src/lib/log-console/wrap-layout.svelte.ts +188 -0
  47. package/src/lib/utils/format.test.ts +122 -0
  48. package/src/lib/utils/format.ts +97 -4
  49. package/src/lib/utils/typing-target.ts +10 -0
  50. package/src/stories/Accordion.stories.svelte +60 -1
  51. package/src/stories/CodeBlock.stories.svelte +13 -0
  52. package/src/stories/Link.stories.svelte +25 -1
  53. package/src/stories/Tabs.stories.svelte +17 -0
@@ -0,0 +1,119 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import { Check, Hash, RotateCw, SlidersHorizontal } from "@lucide/svelte";
6
+ import Popover from "../Popover.svelte";
7
+ import { CopyFeedback } from "../../utils/clipboard.svelte.js";
8
+ import { formatBytes, formatCalendarDate, formatClockTime } from "../../utils/format.js";
9
+ import { formatShortId } from "../../utils/id.js";
10
+ import type { ResourceUsage, Run } from "@runwisp/common";
11
+ import {
12
+ formatTriggeredByLabel,
13
+ runRetryLabel,
14
+ runStartDelay,
15
+ runUsageLabel,
16
+ } from "./run-helpers.js";
17
+
18
+ let { run, live }: { run: Run; live: ResourceUsage | undefined } = $props();
19
+
20
+ const runIdCopy = new CopyFeedback(1200);
21
+
22
+ let startDelay = $derived(runStartDelay(run));
23
+ let retry = $derived(runRetryLabel(run));
24
+ let usage = $derived(runUsageLabel(run));
25
+ let paramEntries = $derived(run.params ? Object.entries(run.params) : []);
26
+ </script>
27
+
28
+ <!-- Facts: when it happened and what shaped it. Queue wait, retries and
29
+ parameters are absent on an ordinary run and appear inline the moment they
30
+ exist. -->
31
+ <div
32
+ class="mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[11.5px] text-on-surface-faint tabular-nums"
33
+ >
34
+ <!-- No hover-for-the-full-timestamp here: the date sits right beside the
35
+ clock time, so a tooltip could only repeat it. The line wraps at narrow
36
+ widths instead of dropping the date and hiding it behind a hover. -->
37
+ <span data-testid="run-started" class="text-on-surface-muted"
38
+ >{run.startedAt ? formatClockTime(run.startedAt) : "—"}</span
39
+ >
40
+ <span class="text-outline-hover" aria-hidden="true">·</span>
41
+ <span>{formatCalendarDate(run.startedAt ?? run.createdAt)}</span>
42
+ <span class="text-outline-hover" aria-hidden="true">·</span>
43
+ <span>via {formatTriggeredByLabel(run.triggeredBy).toLowerCase()}</span>
44
+ {#if startDelay}
45
+ <span class="text-outline-hover" aria-hidden="true">·</span>
46
+ <span
47
+ class="text-warning-soft-text"
48
+ title="Waited between its scheduled tick and actually starting"
49
+ >{startDelay} queued</span
50
+ >
51
+ {/if}
52
+ {#if retry}
53
+ <span class="text-outline-hover" aria-hidden="true">·</span>
54
+ <span class="inline-flex items-center gap-1">
55
+ <RotateCw size={11} />
56
+ retry #{run.retryAttempt}{#if run.retryOfRunId}&nbsp;of
57
+ {formatShortId(run.retryOfRunId)}{/if}
58
+ </span>
59
+ {/if}
60
+ {#if paramEntries.length > 0}
61
+ <span class="text-outline-hover" aria-hidden="true">·</span>
62
+ <Popover placement="bottom-start">
63
+ {#snippet trigger()}
64
+ <span
65
+ class="inline-flex cursor-pointer items-center gap-1 border-b border-dotted border-outline-hover text-on-surface-muted hover:text-primary"
66
+ >
67
+ <SlidersHorizontal size={11} />
68
+ {paramEntries.length}
69
+ {paramEntries.length === 1 ? "parameter" : "parameters"}
70
+ </span>
71
+ {/snippet}
72
+ <div class="max-w-md min-w-48">
73
+ <span
74
+ class="font-mono text-xs font-semibold tracking-[0.14em] text-on-surface-faint uppercase"
75
+ >Parameters</span
76
+ >
77
+ <dl
78
+ class="mt-2 grid max-h-72 grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 overflow-y-auto"
79
+ >
80
+ {#each paramEntries as [key, value] (key)}
81
+ <dt class="font-mono text-xs text-on-surface-muted">{key}</dt>
82
+ <dd class="font-mono text-xs font-medium break-all text-on-surface">
83
+ {value}
84
+ </dd>
85
+ {/each}
86
+ </dl>
87
+ </div>
88
+ </Popover>
89
+ {/if}
90
+ {#if live}
91
+ <span class="text-outline-hover" aria-hidden="true">·</span>
92
+ <span
93
+ data-testid="run-live-usage"
94
+ class="text-on-surface-muted"
95
+ title="Live CPU (100% = one core) and memory of the run's processes"
96
+ >CPU {Math.round(live.cpuPercent)}% · {formatBytes(live.memoryBytes)}</span
97
+ >
98
+ {/if}
99
+ {#if usage}
100
+ <span class="text-outline-hover" aria-hidden="true">·</span>
101
+ <span data-testid="run-usage" title="CPU time and most memory the run's processes used"
102
+ >{usage}</span
103
+ >
104
+ {/if}
105
+ <!-- Run-id chip: click to copy the full ULID. Last, because it is the fact
106
+ you reach for least and the only one you interact with. -->
107
+ <button
108
+ type="button"
109
+ onclick={() => void runIdCopy.copy(run.id)}
110
+ title="Copy run ID"
111
+ class="inline-flex items-center gap-1 rounded-[3px] border border-outline-faint bg-surface-sunken px-1.5 text-on-surface-faint hover:border-outline-hover hover:text-primary"
112
+ >
113
+ {#if runIdCopy.copied}
114
+ <Check size={11} class="text-success-surface" />Copied
115
+ {:else}
116
+ <Hash size={11} />{run.id}
117
+ {/if}
118
+ </button>
119
+ </div>
@@ -0,0 +1,52 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import { X } from "@lucide/svelte";
6
+ import {
7
+ activeDimensions,
8
+ clearDimension,
9
+ filterChipLabel,
10
+ type RunsListFilters,
11
+ } from "./run-filters.js";
12
+
13
+ let {
14
+ filters = $bindable(),
15
+ showTask,
16
+ }: {
17
+ filters: RunsListFilters;
18
+ /** `task` only chips on the cross-task view; elsewhere it is the page scope. */
19
+ showTask: boolean;
20
+ } = $props();
21
+
22
+ const chips = $derived(
23
+ activeDimensions(filters)
24
+ .filter((dim) => dim !== "task" || showTask)
25
+ .map((dim) => ({ dimension: dim, label: filterChipLabel(filters, dim) })),
26
+ );
27
+ </script>
28
+
29
+ <!-- Rendered only when filters are set, so the header stays clean when empty;
30
+ each chip's X clears its dimension. The filter controls themselves live in
31
+ the popover. -->
32
+ {#if chips.length > 0}
33
+ <div
34
+ class="flex shrink-0 flex-wrap gap-1 border-b border-outline-faint bg-surface-sunken px-3 py-2"
35
+ >
36
+ {#each chips as chip (chip.dimension)}
37
+ <span
38
+ class="inline-flex items-center gap-1 rounded-[3px] border border-primary-soft-border bg-primary-soft py-0.5 pr-1 pl-2 font-mono text-2xs font-medium text-primary-soft-text"
39
+ >
40
+ {chip.label}
41
+ <button
42
+ type="button"
43
+ onclick={() => (filters = clearDimension(filters, chip.dimension))}
44
+ class="flex size-3.5 items-center justify-center rounded-[3px] text-primary-soft-text/70 hover:bg-primary/15 hover:text-primary-soft-text"
45
+ aria-label="Remove {chip.label} filter"
46
+ >
47
+ <X size={11} />
48
+ </button>
49
+ </span>
50
+ {/each}
51
+ </div>
52
+ {/if}
@@ -70,10 +70,6 @@
70
70
  const selectClass =
71
71
  "h-9 w-full appearance-none rounded-[3px] border border-outline bg-surface-raised px-2.5 font-mono text-sm text-on-surface focus:border-ring focus:outline-none";
72
72
 
73
- function statusDot(status: RunStatus): string {
74
- return RUN_STATUS_CONFIG[status].dot.replace(" animate-pulse", "");
75
- }
76
-
77
73
  // Reflect a tri-state bucket onto the native checkbox (no `indeterminate`
78
74
  // HTML attribute exists, it must be set on the DOM node).
79
75
  function indeterminate(node: HTMLInputElement, value: boolean) {
@@ -202,7 +198,10 @@
202
198
  onchange={() => toggleStatus(status)}
203
199
  class="size-3.5 shrink-0 cursor-pointer rounded border-outline accent-primary"
204
200
  />
205
- <span class="size-2 shrink-0 rounded-full {statusDot(status)}"
201
+ <span
202
+ class="size-2 shrink-0 rounded-full {RUN_STATUS_CONFIG[
203
+ status
204
+ ].solidDot}"
206
205
  ></span>
207
206
  <span class="truncate text-on-surface">
208
207
  {humanizeStatus(status)}
@@ -0,0 +1,295 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ import type { Snippet } from "svelte";
6
+ import { ArrowLeft, Check, Copy, Ellipsis, SlidersHorizontal } from "@lucide/svelte";
7
+ import Popover from "../Popover.svelte";
8
+ import { foldToFit } from "../../actions/fold-to-fit.js";
9
+ import { CopyFeedback } from "../../utils/clipboard.svelte.js";
10
+ import {
11
+ formatBytes,
12
+ formatClockTime,
13
+ formatDayMonth,
14
+ formatFullDateTime,
15
+ } from "../../utils/format.js";
16
+ import { formatShortId } from "../../utils/id.js";
17
+ import type { ResourceUsage, Run, RunStatus } from "@runwisp/common";
18
+ import { RUN_STATUS_CONFIG } from "./status-config.js";
19
+ import {
20
+ formatTriggeredByLabel,
21
+ runRetryLabel,
22
+ runStartDelay,
23
+ runUsageLabel,
24
+ RUN_VERDICTS,
25
+ } from "./run-helpers.js";
26
+
27
+ let {
28
+ run,
29
+ status,
30
+ duration,
31
+ suffix,
32
+ live,
33
+ onBack,
34
+ actions,
35
+ }: {
36
+ run: Run;
37
+ status: RunStatus;
38
+ duration: string | undefined;
39
+ suffix: string;
40
+ live: ResourceUsage | undefined;
41
+ onBack: (() => void) | undefined;
42
+ actions: Snippet;
43
+ } = $props();
44
+
45
+ // The facts after the verdict, in reading order. `fold` is the order they
46
+ // give way when the line runs out of room: the ID you reach for least goes
47
+ // first, what the run used goes last.
48
+ type FactKey = "start" | "queued" | "via" | "retry" | "params" | "instance" | "usage" | "id";
49
+ const FOLD_ORDER: FactKey[] = [
50
+ "id",
51
+ "via",
52
+ "queued",
53
+ "start",
54
+ "params",
55
+ "retry",
56
+ "instance",
57
+ "usage",
58
+ ];
59
+ interface Fact {
60
+ key: FactKey;
61
+ label: string;
62
+ value: string;
63
+ }
64
+
65
+ const runIdCopy = new CopyFeedback(1200);
66
+
67
+ let config = $derived(RUN_STATUS_CONFIG[status]);
68
+ let verdict = $derived(RUN_VERDICTS[status]);
69
+ let paramEntries = $derived(run.params ? Object.entries(run.params) : []);
70
+ let usage = $derived(
71
+ live
72
+ ? `CPU ${String(Math.round(live.cpuPercent))}% · RAM ${formatBytes(live.memoryBytes)}`
73
+ : runUsageLabel(run),
74
+ );
75
+ let facts = $derived.by(() => {
76
+ const list: Fact[] = [];
77
+ const started = run.startedAt;
78
+ if (started) {
79
+ list.push({
80
+ key: "start",
81
+ label: "Started",
82
+ value: `${formatDayMonth(started)} ${formatClockTime(started)}`,
83
+ });
84
+ }
85
+ const delay = runStartDelay(run);
86
+ if (delay) list.push({ key: "queued", label: "Queued", value: `${delay} queued` });
87
+ list.push({
88
+ key: "via",
89
+ label: "Triggered",
90
+ value: `via ${formatTriggeredByLabel(run.triggeredBy).toLowerCase()}`,
91
+ });
92
+ if (runRetryLabel(run)) {
93
+ const of = run.retryOfRunId ? ` of ${formatShortId(run.retryOfRunId)}` : "";
94
+ list.push({
95
+ key: "retry",
96
+ label: "Retry",
97
+ value: `retry #${String(run.retryAttempt)}${of}`,
98
+ });
99
+ }
100
+ if (paramEntries.length > 0) {
101
+ const n = paramEntries.length;
102
+ list.push({
103
+ key: "params",
104
+ label: "Parameters",
105
+ value: `${String(n)} ${n === 1 ? "parameter" : "parameters"}`,
106
+ });
107
+ }
108
+ if (suffix) list.push({ key: "instance", label: "Instance", value: `instance ${suffix}` });
109
+ if (usage) list.push({ key: "usage", label: live ? "Now" : "Used", value: usage });
110
+ list.push({ key: "id", label: "Run ID", value: `#${formatShortId(run.id)}` });
111
+ return list;
112
+ });
113
+ let order = $derived(FOLD_ORDER.filter((key) => facts.some((f) => f.key === key)));
114
+
115
+ let folded = $state<string[]>([]);
116
+ let foldedFacts = $derived(facts.filter((f) => folded.includes(f.key)));
117
+
118
+ const FACT = "before:mx-1.5 before:text-outline-hover before:content-['·']";
119
+ </script>
120
+
121
+ {#snippet paramList()}
122
+ <dl class="grid max-h-72 grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 overflow-y-auto">
123
+ {#each paramEntries as [key, value] (key)}
124
+ <dt class="font-mono text-xs text-on-surface-muted">{key}</dt>
125
+ <dd class="font-mono text-xs font-medium break-all text-on-surface">{value}</dd>
126
+ {/each}
127
+ </dl>
128
+ {/snippet}
129
+
130
+ {#snippet copyId()}
131
+ <button
132
+ type="button"
133
+ onclick={() => void runIdCopy.copy(run.id)}
134
+ class="inline-flex items-center gap-1 text-on-surface-muted hover:text-primary"
135
+ title="Copy run ID"
136
+ >
137
+ {#if runIdCopy.copied}
138
+ <Check size={11} class="text-success-surface" />Copied
139
+ {:else}
140
+ <Copy size={11} />copy
141
+ {/if}
142
+ </button>
143
+ {/snippet}
144
+
145
+ <!-- One line: verdict, then the facts that fit, then the actions. Facts that
146
+ don't fit fold into ⋯, the ID first. -->
147
+ <div
148
+ class="head-line @container relative flex shrink-0 items-center gap-2.5 border-b border-outline-faint py-[7px] pr-3.5 pl-[18px]"
149
+ style="--rw-oc: {config.alarm ?? 'var(--color-surface-raised)'}"
150
+ >
151
+ {#if onBack}
152
+ <button
153
+ type="button"
154
+ onclick={() => onBack()}
155
+ class="-ml-1.5 shrink-0 rounded-[3px] p-1.5 text-on-surface-muted hover:bg-surface-sunken hover:text-primary"
156
+ title="Back to runs"
157
+ aria-label="Back to runs"
158
+ >
159
+ <ArrowLeft size={18} />
160
+ </button>
161
+ {/if}
162
+
163
+ <div
164
+ use:foldToFit={{ order, onfold: (keys) => (folded = keys) }}
165
+ class="flex min-w-0 flex-1 items-baseline gap-2.5 overflow-hidden whitespace-nowrap"
166
+ >
167
+ <!-- A plain title, not Tooltip: the line clips anything that overflows it. -->
168
+ <h2
169
+ class="flex shrink-0 items-baseline gap-x-1.5 text-[15px]"
170
+ data-testid="run-verdict"
171
+ data-status={status}
172
+ data-run={run.id}
173
+ title={config.description}
174
+ >
175
+ <config.icon
176
+ size={14}
177
+ strokeWidth={2.5}
178
+ class="shrink-0 self-center {config.color} {status === 'running'
179
+ ? 'animate-spin'
180
+ : ''}"
181
+ />
182
+ <span class="font-sans font-semibold {config.alarm ? config.color : 'text-on-surface'}"
183
+ >{verdict.verb}</span
184
+ >
185
+ {#if verdict.timed && duration}
186
+ <span
187
+ class="font-mono text-[13px] font-medium text-on-surface tabular-nums"
188
+ data-testid="run-duration">{duration}</span
189
+ >
190
+ {/if}
191
+ {#if status === "failed"}
192
+ <span class="font-mono text-[13px] font-medium tabular-nums">
193
+ <span class="text-on-surface-faint" aria-hidden="true">·</span>
194
+ <span class="text-danger-surface" data-testid="run-exit"
195
+ >exit {run.exitCode}</span
196
+ >
197
+ </span>
198
+ {/if}
199
+ </h2>
200
+
201
+ <span
202
+ data-fold-shrink
203
+ class="min-w-0 truncate font-mono text-[11.5px] text-on-surface-faint tabular-nums"
204
+ >
205
+ {#each facts as fact (fact.key)}
206
+ {#if fact.key === "start" && run.startedAt}
207
+ <span
208
+ data-fold="start"
209
+ data-testid="run-started"
210
+ class={FACT}
211
+ title={formatFullDateTime(run.startedAt)}>{fact.value}</span
212
+ >
213
+ {:else if fact.key === "params"}
214
+ <span data-fold="params" class={FACT}>
215
+ <Popover placement="bottom-start">
216
+ {#snippet trigger()}
217
+ <span
218
+ class="inline-flex cursor-pointer items-center gap-1 border-b border-dotted border-outline-hover text-on-surface-muted hover:text-primary"
219
+ >
220
+ <SlidersHorizontal size={11} />{fact.value}
221
+ </span>
222
+ {/snippet}
223
+ <div class="max-w-md min-w-48">{@render paramList()}</div>
224
+ </Popover>
225
+ </span>
226
+ {:else if fact.key === "usage"}
227
+ <span
228
+ data-fold="usage"
229
+ data-testid={live ? "run-live-usage" : "run-usage"}
230
+ class="{FACT} text-on-surface-muted"
231
+ title={live
232
+ ? "Live CPU (100% = one core) and memory of the run's processes"
233
+ : "CPU time and most memory the run's processes used"}
234
+ >{fact.value}</span
235
+ >
236
+ {:else if fact.key === "id"}
237
+ <button
238
+ type="button"
239
+ data-fold="id"
240
+ onclick={() => void runIdCopy.copy(run.id)}
241
+ class="{FACT} hover:text-primary"
242
+ title="Copy run ID {run.id}"
243
+ >{runIdCopy.copied ? "Copied" : fact.value}</button
244
+ >
245
+ {:else}
246
+ <span
247
+ data-fold={fact.key}
248
+ class="{FACT} {fact.key === 'queued' ? 'text-warning-soft-text' : ''}"
249
+ >{fact.value}</span
250
+ >
251
+ {/if}
252
+ {/each}
253
+ </span>
254
+ </div>
255
+
256
+ <div class="flex shrink-0 items-center gap-2">
257
+ {@render actions()}
258
+ {#if foldedFacts.length > 0}
259
+ <Popover placement="bottom-end">
260
+ {#snippet trigger()}
261
+ <span
262
+ class="flex cursor-pointer 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"
263
+ title="More about this run"
264
+ aria-label="More about this run"
265
+ >
266
+ <Ellipsis size={15} />
267
+ </span>
268
+ {/snippet}
269
+ <dl class="grid min-w-56 grid-cols-[max-content_1fr] gap-x-4 gap-y-2 text-xs">
270
+ {#each foldedFacts as fact (fact.key)}
271
+ <dt class="text-on-surface-muted">{fact.label}</dt>
272
+ <dd class="font-mono text-on-surface">
273
+ {#if fact.key === "id"}
274
+ <span class="mr-2 break-all">{run.id}</span>{@render copyId()}
275
+ {:else if fact.key === "params"}
276
+ {@render paramList()}
277
+ {:else if fact.key === "start" && run.startedAt}
278
+ {formatFullDateTime(run.startedAt)}
279
+ {:else}
280
+ {fact.value}
281
+ {/if}
282
+ </dd>
283
+ {/each}
284
+ </dl>
285
+ </Popover>
286
+ {/if}
287
+ </div>
288
+ </div>
289
+
290
+ <style>
291
+ /* Same wash as the stacked header: only a run worth triaging tints it. */
292
+ .head-line {
293
+ background: color-mix(in srgb, var(--rw-oc) 7%, var(--color-surface-raised));
294
+ }
295
+ </style>
@@ -0,0 +1,43 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <script lang="ts">
5
+ let { label, showTaskName }: { label: string; showTaskName: boolean } = $props();
6
+ </script>
7
+
8
+ <!-- Placeholders shaped like the list's run rows (same padding, dot, text and
9
+ right readout), while a search or the first page is in flight. -->
10
+ <div class="flex flex-col gap-0.5" role="status" aria-busy="true" aria-label={label}>
11
+ {#each [0, 1, 2, 3, 4, 5] as i (i)}
12
+ <div
13
+ class="rounded-[3px] border border-transparent {showTaskName
14
+ ? 'p-3'
15
+ : 'px-3 py-[11px]'}"
16
+ >
17
+ <div class="flex items-center {showTaskName ? 'gap-2.5' : 'gap-[11px]'}">
18
+ <span class="size-[9px] shrink-0 animate-pulse rounded-full bg-outline-hover"
19
+ ></span>
20
+ {#if showTaskName}
21
+ <span class="flex min-w-0 flex-1 flex-col gap-1.5 py-0.5">
22
+ <span
23
+ class="h-3 animate-pulse rounded-[3px] bg-outline-hover"
24
+ style:width="{62 - (i % 3) * 12}%"
25
+ ></span>
26
+ <span class="h-2 w-2/5 animate-pulse rounded-[3px] bg-outline-hover"></span>
27
+ </span>
28
+ <span
29
+ class="h-2.5 w-9 shrink-0 animate-pulse self-start rounded-[3px] bg-outline-hover"
30
+ ></span>
31
+ {:else}
32
+ <span
33
+ class="h-3 animate-pulse rounded-[3px] bg-outline-hover"
34
+ style:width="{58 - (i % 3) * 8}%"
35
+ ></span>
36
+ <span
37
+ class="ml-auto h-2.5 w-9 shrink-0 animate-pulse rounded-[3px] bg-outline-hover"
38
+ ></span>
39
+ {/if}
40
+ </div>
41
+ </div>
42
+ {/each}
43
+ </div>