@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
@@ -15,8 +15,9 @@
15
15
  RotateCw,
16
16
  RefreshCcw,
17
17
  ChevronDown,
18
- RotateCcwClock,
18
+ ArrowLeft,
19
19
  PanelLeftClose,
20
+ PanelLeftOpen,
20
21
  SlidersHorizontal,
21
22
  Maximize2,
22
23
  Minimize2,
@@ -25,24 +26,28 @@
25
26
  } from "@lucide/svelte";
26
27
  import Button from "../Button.svelte";
27
28
  import EmptyState from "../EmptyState.svelte";
29
+ import Kbd from "../Kbd.svelte";
28
30
  import LogConsole from "../LogConsole.svelte";
29
31
  import Popover from "../Popover.svelte";
30
32
  import Tooltip from "../Tooltip.svelte";
31
33
  import { portal } from "../../actions/portal.js";
32
34
  import { prefersReducedMotion } from "../../actions/row-motion.js";
33
35
  import type { RunMotion } from "../../utils/run-motion.js";
34
- import type { Run } from "./types.js";
35
36
  import { isLogEvent, type LogEvent, type LogSlice } from "../../log-console/types.js";
36
- import { formatClockTime, formatCalendarDate } from "../../utils/format.js";
37
+ import { formatBytes, formatClockTime, formatCalendarDate } from "../../utils/format.js";
37
38
  import { formatShortId } from "../../utils/id.js";
38
39
  import { TickingNow } from "../../utils/ticking-now.svelte.js";
39
- import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
40
+ import { CopyFeedback } from "../../utils/clipboard.svelte.js";
41
+ import { displayStatus, type ResourceUsage, type Run } from "@runwisp/common";
42
+ import { RUN_STATUS_CONFIG } from "./status-config.js";
40
43
  import {
41
44
  runDuration,
42
45
  runStartDelay,
43
46
  runVerdict,
44
47
  formatTriggeredByLabel,
45
48
  runRetryLabel,
49
+ runUsageLabel,
50
+ runLogDownloadUrl,
46
51
  instanceSuffix,
47
52
  } from "./run-helpers.js";
48
53
 
@@ -61,10 +66,12 @@
61
66
  onRestartService,
62
67
  serviceStopped = false,
63
68
  serviceBusy = false,
64
- onToggleHistory,
65
- historyVisible = false,
69
+ onBack,
70
+ onToggleList,
71
+ listVisible = true,
66
72
  highlightLine = null,
67
73
  getInstanceCount = () => 1,
74
+ getLiveUsage = () => undefined,
68
75
  motion,
69
76
  notFound = false,
70
77
  loading = false,
@@ -96,23 +103,29 @@
96
103
  // Stop the selected run while it is live. When provided and the run is
97
104
  // running, the action cluster shows Stop alongside Run.
98
105
  onStop?: ((runId: string) => void) | undefined;
99
- // 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
100
107
  // cold-start path so a never-run task is still launchable from here.
101
108
  onRunTask?: (() => void) | undefined;
102
109
  // Service lifecycle controls. When either is set the cluster swaps the
103
- // Run control for Stop Service / Restart Service (chosen by serviceStopped).
110
+ // Run control for Stop / Restart (chosen by serviceStopped).
104
111
  onStopService?: (() => void) | undefined;
105
112
  onRestartService?: (() => void) | undefined;
106
113
  serviceStopped?: boolean;
107
114
  serviceBusy?: boolean;
108
- // Toggle the (single-instance service) history rail from the panel header,
109
- // since these tasks hide the rail by default and there is no top bar.
110
- onToggleHistory?: (() => void) | undefined;
111
- historyVisible?: boolean;
115
+ // Return to the run list when the panel replaces it (a phone), shown as
116
+ // a back arrow in the header's top-left corner.
117
+ onBack?: (() => void) | undefined;
118
+ // Fold the run list away (or back) when it sits beside the panel on a
119
+ // screen too narrow to keep both comfortably, in the same corner.
120
+ onToggleList?: (() => void) | undefined;
121
+ listVisible?: boolean;
112
122
  highlightLine?: number | null;
113
123
  // Resolves a task's currently configured instance count so multi-instance
114
124
  // services render a 1-based #N suffix. Defaults to single-instance.
115
125
  getInstanceCount?: (taskName: string) => number;
126
+ // Live CPU and memory of a running run, by run ID; undefined when it
127
+ // isn't measured. Shown in the header while the run is running.
128
+ getLiveUsage?: (runId: string) => ResourceUsage | undefined;
116
129
  // Which runs arrived or were removed live moments ago. The panel eases in
117
130
  // when a new run takes it (triggered, or a scheduled run auto-selected)
118
131
  // or when its run was deleted and another takes its place; picking a
@@ -120,7 +133,7 @@
120
133
  motion?: RunMotion;
121
134
  // True when a deep-linked run id resolved to no run (deleted by retention,
122
135
  // or never existed). The empty state then says so plainly instead of the
123
- // generic "Select a run" — the caller must not silently substitute another.
136
+ // generic "Select a run", the caller must not silently substitute another.
124
137
  notFound?: boolean;
125
138
  // True while there is no run to show *yet* (the run list or a deep-linked
126
139
  // run is still loading), so the empty state doesn't claim "No runs yet".
@@ -148,7 +161,7 @@
148
161
 
149
162
  let canDelete = $derived.by(() => {
150
163
  if (!run || !onDelete) return false;
151
- const status = runDisplayStatus(run);
164
+ const status = displayStatus(run.status, run.endReason);
152
165
  return status !== "running" && status !== "pending";
153
166
  });
154
167
 
@@ -182,8 +195,8 @@
182
195
  // below) and paints it into the console. Resolves the line the live
183
196
  // stream should resume from, or null when there's nothing left to stream
184
197
  // (the run already ended, or the caller cancelled while we were waiting).
185
- // A failed fetch still falls back to an SSE tail backfill from scratch —
186
- // 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
187
200
  // failure so LogConsole can show it instead of looking like a quiet run.
188
201
  async function seedConsoleTail(
189
202
  id: string,
@@ -248,7 +261,7 @@
248
261
  });
249
262
 
250
263
  // Maximize the console to a full-bleed overlay. The console wrapper is
251
- // never remounted — `maximizePortal` relocates the *live* node to <body>
264
+ // never remounted, `maximizePortal` relocates the *live* node to <body>
252
265
  // (escaping any transformed/blurred ancestor that would otherwise trap a
253
266
  // position:fixed child) and moves it back on restore, so the SSE stream,
254
267
  // scroll position, and the {#key run.id} mount all survive the toggle.
@@ -266,23 +279,11 @@
266
279
  let confirmDeleteOpen = $state(false);
267
280
  // Dropdown half of the Run split button (the "reuse parameters" variant).
268
281
  let runMenuOpen = $state(false);
269
- let copiedId = $state(false);
270
- let copyTimer: ReturnType<typeof setTimeout> | null = null;
271
-
272
- async function copyRunId(id: string) {
273
- try {
274
- await navigator.clipboard.writeText(id);
275
- copiedId = true;
276
- if (copyTimer) clearTimeout(copyTimer);
277
- copyTimer = setTimeout(() => (copiedId = false), 1200);
278
- } catch {
279
- // Clipboard blocked (insecure context / denied) — leave the chip as-is.
280
- }
281
- }
282
+ const runIdCopy = new CopyFeedback(1200);
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") &&
@@ -362,8 +363,8 @@
362
363
  <svelte:window onkeydown={handleConsoleKeydown} />
363
364
 
364
365
  {#if run}
365
- {@const status = runDisplayStatus(run)}
366
- {@const config = getRunStatusConfig(status)}
366
+ {@const status = displayStatus(run.status, run.endReason)}
367
+ {@const config = RUN_STATUS_CONFIG[status]}
367
368
  {@const DetailIcon = config.icon}
368
369
  {@const duration = runDuration(
369
370
  run,
@@ -372,6 +373,8 @@
372
373
  {@const startDelay = runStartDelay(run)}
373
374
  {@const startedAt = run.startedAt ?? run.createdAt}
374
375
  {@const retry = runRetryLabel(run)}
376
+ {@const usage = runUsageLabel(run)}
377
+ {@const live = run.status === "running" ? getLiveUsage(run.id) : undefined}
375
378
  {@const paramEntries = run.params ? Object.entries(run.params) : []}
376
379
  {@const suffix = instanceSuffix(run.instanceIndex, getInstanceCount(run.taskName))}
377
380
  {@const spine = config.dot.replace(" animate-pulse", "")}
@@ -409,15 +412,43 @@
409
412
  <!-- Detailed header: a readout whose ink scales with how much went wrong.
410
413
  Every run states its outcome in one phrase over one quiet fact line;
411
414
  only a run worth triaging colours the phrase and tints the surface.
412
- 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
413
416
  true, so the header's height is itself a signal. -->
414
417
  <div
415
418
  class="head-region @container relative shrink-0 border-b border-outline-faint"
416
419
  style="--rw-oc: {alarm ?? 'var(--color-surface-raised)'}"
417
420
  >
418
421
  <div class="pt-[18px] pr-[22px] pb-[16px] pl-[26px]">
419
- <div class="flex items-start gap-4">
420
- <div class="min-w-0 flex-1">
422
+ <!-- The actions wrap onto their own row rather than squeeze the
423
+ readout below a legible width on a narrow panel. -->
424
+ <div class="flex flex-wrap items-start gap-x-3 gap-y-3">
425
+ {#if onBack}
426
+ <button
427
+ type="button"
428
+ onclick={() => onBack()}
429
+ class="-mt-1 -ml-2 shrink-0 rounded-[3px] p-1.5 text-on-surface-muted hover:bg-surface-sunken hover:text-primary"
430
+ title="Back to runs"
431
+ aria-label="Back to runs"
432
+ >
433
+ <ArrowLeft size={20} />
434
+ </button>
435
+ {:else if onToggleList}
436
+ <button
437
+ type="button"
438
+ onclick={() => onToggleList()}
439
+ class="-mt-1 -ml-2 shrink-0 rounded-[3px] p-1.5 text-on-surface-muted hover:bg-surface-sunken hover:text-primary"
440
+ title={listVisible ? "Hide run list" : "Show run list"}
441
+ aria-label={listVisible ? "Hide run list" : "Show run list"}
442
+ aria-expanded={listVisible}
443
+ >
444
+ {#if listVisible}
445
+ <PanelLeftClose size={20} />
446
+ {:else}
447
+ <PanelLeftOpen size={20} />
448
+ {/if}
449
+ </button>
450
+ {/if}
451
+ <div class="min-w-60 flex-1">
421
452
  <!-- Identity, but only where the page around the panel isn't
422
453
  already saying it: the cross-task runs list needs the task
423
454
  name, a task's own page already has it in the breadcrumb. -->
@@ -431,9 +462,9 @@
431
462
 
432
463
  <!-- Verdict: the outcome as one sentence, and the only large
433
464
  type in the panel. It is prose, so the phrase is sans and
434
- only the tokens inside it — the duration, a failing exit —
465
+ only the tokens inside it, the duration, a failing exit,
435
466
  are mono (DESIGN.md's mono-vs-sans rule). The glyph is bare
436
- — 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,
437
468
  and it alone carries the outcome colour on a healthy run:
438
469
  green words are ink spent on nothing being wrong. -->
439
470
  <Tooltip content={config.description} position="right" wide>
@@ -544,16 +575,35 @@
544
575
  </div>
545
576
  </Popover>
546
577
  {/if}
578
+ {#if live}
579
+ <span class="text-outline-hover" aria-hidden="true">·</span>
580
+ <span
581
+ data-testid="run-live-usage"
582
+ class="text-on-surface-muted"
583
+ title="Live CPU (100% = one core) and memory of the run's processes"
584
+ >CPU {Math.round(live.cpuPercent)}% · {formatBytes(
585
+ live.memoryBytes,
586
+ )}</span
587
+ >
588
+ {/if}
589
+ {#if usage}
590
+ <span class="text-outline-hover" aria-hidden="true">·</span>
591
+ <span
592
+ data-testid="run-usage"
593
+ title="Peak memory and CPU time of the run's processes"
594
+ >{usage}</span
595
+ >
596
+ {/if}
547
597
  <!-- Run-id chip: click to copy the full ULID. Last, because
548
598
  it is the fact you reach for least and the only one you
549
599
  interact with. -->
550
600
  <button
551
601
  type="button"
552
- onclick={() => copyRunId(run.id)}
602
+ onclick={() => void runIdCopy.copy(run.id)}
553
603
  title="Copy run ID"
554
604
  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"
555
605
  >
556
- {#if copiedId}
606
+ {#if runIdCopy.copied}
557
607
  <Check size={11} class="text-success-surface" />Copied
558
608
  {:else}
559
609
  <Hash size={11} />{run.id}
@@ -562,27 +612,9 @@
562
612
  </div>
563
613
  </div>
564
614
 
565
- <!-- Actions: history toggle · run (split) / service lifecycle ·
615
+ <!-- Actions: run (split) / service lifecycle ·
566
616
  stop · download · delete (delete behind an inline confirm) -->
567
- <div class="flex shrink-0 items-center gap-2">
568
- {#if onToggleHistory}
569
- <button
570
- type="button"
571
- onclick={() => onToggleHistory()}
572
- 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"
573
- title={historyVisible ? "Hide run history" : "Show run history"}
574
- aria-label={historyVisible
575
- ? "Hide run history"
576
- : "Show run history"}
577
- >
578
- {#if historyVisible}
579
- <PanelLeftClose size={15} />
580
- {:else}
581
- <RotateCcwClock size={15} />
582
- {/if}
583
- </button>
584
- {/if}
585
-
617
+ <div class="flex flex-wrap items-center gap-2">
586
618
  {#if onStopService || onRestartService}
587
619
  <!-- Service lifecycle replaces the run control.
588
620
  Restart is always available (it cancels + respawns
@@ -604,7 +636,7 @@
604
636
  {:else}
605
637
  <RefreshCcw size={15} />
606
638
  {/if}
607
- <span class="@max-md:hidden"
639
+ <span class="@max-xs:hidden"
608
640
  >{serviceStopped ? "Start" : "Restart"}</span
609
641
  >
610
642
  </button>
@@ -618,7 +650,7 @@
618
650
  class="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-[3px] border border-danger-soft-border bg-surface-raised px-3 font-mono text-sm font-medium text-danger-surface hover:bg-danger-soft active:translate-y-px disabled:cursor-not-allowed disabled:opacity-60"
619
651
  >
620
652
  <Square size={15} fill="currentColor" stroke="none" />
621
- <span class="@max-md:hidden">Stop Service</span>
653
+ <span class="@max-xs:hidden">Stop</span>
622
654
  </button>
623
655
  {/if}
624
656
  {:else}
@@ -635,7 +667,7 @@
635
667
  class="inline-flex h-9 cursor-pointer items-center gap-1.5 bg-surface-raised px-3 font-mono text-sm font-medium text-primary hover:bg-primary-soft active:translate-y-px"
636
668
  >
637
669
  <Play size={15} fill="currentColor" stroke="none" />
638
- <span class="@max-md:hidden">Run</span>
670
+ <span class="@max-xs:hidden">Run</span>
639
671
  </button>
640
672
  {#if onRunAgain}
641
673
  <Popover bind:open={runMenuOpen} placement="bottom-end">
@@ -681,14 +713,12 @@
681
713
  class="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-[3px] border border-danger-soft-border bg-surface-raised px-3 font-mono text-sm font-medium text-danger-surface hover:bg-danger-soft active:translate-y-px"
682
714
  >
683
715
  <Square size={15} fill="currentColor" stroke="none" />
684
- <span class="@max-md:hidden">Stop</span>
716
+ <span class="@max-xs:hidden">Stop</span>
685
717
  </button>
686
718
  {/if}
687
719
  {/if}
688
720
  <a
689
- href="/api/tasks/{encodeURIComponent(
690
- run.taskName,
691
- )}/runs/{encodeURIComponent(run.id)}/log/raw"
721
+ href={runLogDownloadUrl(run.id)}
692
722
  download="{run.taskName}-{run.id}.log"
693
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"
694
724
  title="Download the full log (rotated and current parts as one file)"
@@ -742,7 +772,7 @@
742
772
  </div>
743
773
  </div>
744
774
 
745
- <!-- Console hero — maximizes to a full-bleed overlay via portal -->
775
+ <!-- Console hero, maximizes to a full-bleed overlay via portal -->
746
776
  {#if consoleMaximized}
747
777
  <div
748
778
  use:portal
@@ -758,7 +788,7 @@
758
788
  : "ml-1 flex min-h-[300px] flex-1 flex-col overflow-hidden border-t border-[var(--rw-con-gutter)] bg-[var(--rw-con-bg)]"}
759
789
  >
760
790
  <div
761
- class="flex shrink-0 items-center justify-between gap-3 border-b border-[var(--rw-con-gutter)] bg-[var(--rw-con-panel)] px-3.5 py-[9px] font-mono text-[11.5px] text-[var(--rw-con-dim)]"
791
+ class="@container flex shrink-0 items-center justify-between gap-3 border-b border-[var(--rw-con-gutter)] bg-[var(--rw-con-panel)] px-3.5 py-[9px] font-mono text-[11.5px] text-[var(--rw-con-dim)]"
762
792
  >
763
793
  <div class="flex min-w-0 items-center gap-3">
764
794
  {#if consoleMaximized}
@@ -773,12 +803,12 @@
773
803
  </span>
774
804
  {:else}
775
805
  <span
776
- class="flex items-center gap-2 font-semibold text-[var(--rw-con-text)]"
806
+ class="flex items-center gap-2 font-semibold whitespace-nowrap text-[var(--rw-con-text)]"
777
807
  >
778
808
  <TerminalIcon size={14} class="opacity-70" />
779
809
  Console output
780
810
  </span>
781
- <span class="hidden text-[var(--rw-con-gutter)] sm:inline"
811
+ <span class="hidden text-[var(--rw-con-gutter)] @lg:inline"
782
812
  >stdout + stderr</span
783
813
  >
784
814
  {/if}
@@ -795,7 +825,7 @@
795
825
  aria-label="Toggle line wrapping"
796
826
  >
797
827
  <TextWrap size={13} />
798
- <span class="hidden sm:inline">Wrap</span>
828
+ <span class="hidden @sm:inline">Wrap</span>
799
829
  </button>
800
830
  <button
801
831
  type="button"
@@ -806,10 +836,7 @@
806
836
  >
807
837
  {#if consoleMaximized}
808
838
  <Minimize2 size={13} />
809
- <span
810
- class="rounded-[3px] border border-[var(--rw-con-gutter)] px-1.5 text-[10px] tracking-wide"
811
- >Esc</span
812
- >
839
+ <Kbd keys="Esc" size="xs" tone="console" />
813
840
  {:else}
814
841
  <Maximize2 size={13} />
815
842
  Expand
@@ -874,7 +901,7 @@
874
901
  <EmptyState
875
902
  title={notFound ? "Run not found" : onRunTask ? "No runs yet" : "Select a run"}
876
903
  description={notFound
877
- ? "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."
878
905
  : onRunTask
879
906
  ? "This task hasn't run yet. Trigger it to see its output here."
880
907
  : "Pick a run from the list to view details and logs."}
@@ -895,7 +922,7 @@
895
922
 
896
923
  <style>
897
924
  /* Only a run worth triaging is washed in its outcome colour (--rw-oc, set
898
- inline). Everything else — success included — passes the surface itself so
925
+ inline). Everything else, success included, passes the surface itself so
899
926
  the mix collapses to a plain surface, which is what lets a genuine failure
900
927
  be the one lit thing on the page. */
901
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
@@ -40,7 +40,6 @@
40
40
  dayStartIso,
41
41
  dayEndIso,
42
42
  isExitCodeExprValid,
43
- exitCodePropChanged,
44
43
  type RunsListFilters,
45
44
  } from "./run-filters.js";
46
45
 
@@ -48,13 +47,10 @@
48
47
  filters = $bindable(),
49
48
  showTask = false,
50
49
  tasks = [],
51
- compact = false,
52
50
  }: {
53
51
  filters: RunsListFilters;
54
52
  showTask?: boolean;
55
53
  tasks?: { name: string }[];
56
- // Icon-only trigger for narrow rails where a "Filter" label wouldn't fit.
57
- compact?: boolean;
58
54
  } = $props();
59
55
 
60
56
  // The individual statuses behind the "Advanced" expander, grouped the way
@@ -79,7 +75,7 @@
79
75
  }
80
76
 
81
77
  // Reflect a tri-state bucket onto the native checkbox (no `indeterminate`
82
- // HTML attribute exists — it must be set on the DOM node).
78
+ // HTML attribute exists, it must be set on the DOM node).
83
79
  function indeterminate(node: HTMLInputElement, value: boolean) {
84
80
  node.indeterminate = value;
85
81
  return {
@@ -99,7 +95,7 @@
99
95
  // --- Time -------------------------------------------------------------
100
96
  //
101
97
  // Independent From/To date bounds. A bare date is that day's 00:00 (From) or
102
- // 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
103
99
  // "everything since", To alone "everything before", and the same date in
104
100
  // both captures the whole day.
105
101
 
@@ -121,18 +117,14 @@
121
117
  filters = { ...filters, triggeredBy: value || undefined };
122
118
  }
123
119
 
124
- // Exit code: a free-form expression (`137`, `>100`, `>100 <150`) normalized
125
- // to an inclusive range at the wire. Edited in a local buffer for smooth
126
- // typing and committed on change. `RunFilterPopover` itself is long-lived
127
- // (its trigger button stays mounted while the popover is closed), so the
128
- // buffer does NOT re-seed on its own — it must explicitly resync whenever
129
- // `filters.exitCode` changes from outside (a chip removal, "clear
130
- // filters"), without clobbering in-progress typing when some unrelated
131
- // filter dimension changes instead.
120
+ // Exit-code text is edited in a local buffer and committed on change. The
121
+ // component outlives the popover, so the buffer resyncs only when the
122
+ // committed `exitCode` itself moves (chip removed, filters cleared); an
123
+ // update to another dimension must not clobber in-progress typing.
132
124
  let exitCodeInput = $state(filters.exitCode ?? "");
133
125
  let lastExitCode = filters.exitCode;
134
126
  $effect(() => {
135
- if (exitCodePropChanged(lastExitCode, filters.exitCode)) {
127
+ if (lastExitCode !== filters.exitCode) {
136
128
  exitCodeInput = filters.exitCode ?? "";
137
129
  }
138
130
  lastExitCode = filters.exitCode;
@@ -152,8 +144,8 @@
152
144
  <Popover placement="right-start" mobileSheet>
153
145
  {#snippet trigger()}
154
146
  <span
155
- class="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-[3px] border font-mono text-xs font-medium {compact &&
156
- count === 0
147
+ class="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-[3px] border font-mono text-xs font-medium {count ===
148
+ 0
157
149
  ? 'w-7 justify-center px-0'
158
150
  : 'px-2'} {count > 0
159
151
  ? 'border-primary-soft-border bg-primary-soft text-primary-soft-text'
@@ -161,7 +153,6 @@
161
153
  title="Filter runs"
162
154
  >
163
155
  <Funnel size={13} />
164
- {#if !compact}Filter{/if}
165
156
  {#if count > 0}
166
157
  <Badge variant="primary" size="sm" class="px-1.5 py-0">{count}</Badge>
167
158
  {/if}
@@ -195,7 +186,7 @@
195
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"
196
187
  >
197
188
  <ChevronRight size={12} class={advancedOpen ? "rotate-90" : ""} />
198
- Advanced — pick exact statuses
189
+ Advanced: pick exact statuses
199
190
  </summary>
200
191
  <div class="mt-2 flex flex-col gap-1.5">
201
192
  {#each STATUS_GROUPS as group (group.label)}