@runwisp/ui 0.9.0 → 0.11.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.
@@ -2,7 +2,13 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
- import { runDuration, runStartDelay } from "./run-helpers.js";
5
+ import {
6
+ runDuration,
7
+ runStartDelay,
8
+ formatTriggeredByLabel,
9
+ runRetryLabel,
10
+ instanceSuffix,
11
+ } from "./run-helpers.js";
6
12
 
7
13
  describe("runDuration", () => {
8
14
  it("returns undefined when start_at is not set", () => {
@@ -59,3 +65,46 @@ describe("runStartDelay", () => {
59
65
  ).toBe("7m 12s");
60
66
  });
61
67
  });
68
+
69
+ describe("formatTriggeredByLabel", () => {
70
+ it("humanizes each trigger source", () => {
71
+ expect(formatTriggeredByLabel("api")).toBe("API");
72
+ expect(formatTriggeredByLabel("cron")).toBe("Cron");
73
+ expect(formatTriggeredByLabel("service")).toBe("Service");
74
+ expect(formatTriggeredByLabel("startup")).toBe("Startup");
75
+ expect(formatTriggeredByLabel("cloud")).toBe("Cloud");
76
+ });
77
+ });
78
+
79
+ describe("runRetryLabel", () => {
80
+ it("returns undefined for a first attempt that is not a retry", () => {
81
+ expect(runRetryLabel({ retry_attempt: 0 })).toBeUndefined();
82
+ });
83
+
84
+ it("labels a run with a positive attempt number", () => {
85
+ expect(runRetryLabel({ retry_attempt: 2 })).toBe("retry #2");
86
+ });
87
+
88
+ it("labels a run that points back at the run it re-attempts", () => {
89
+ expect(runRetryLabel({ retry_attempt: 1, retry_of_run_id: "01JABC" })).toBe("retry #1");
90
+ });
91
+ });
92
+
93
+ describe("instanceSuffix", () => {
94
+ it("returns no suffix for a single-instance task", () => {
95
+ expect(instanceSuffix(0, 1)).toBe("");
96
+ });
97
+
98
+ it("returns no suffix for a non-service (count 0)", () => {
99
+ expect(instanceSuffix(0, 0)).toBe("");
100
+ });
101
+
102
+ it("suffixes slot 0 of a multi-instance service as #1 (1-based)", () => {
103
+ expect(instanceSuffix(0, 3)).toBe("#1");
104
+ });
105
+
106
+ it("maps the stored 0-based slot to a 1-based suffix", () => {
107
+ expect(instanceSuffix(1, 3)).toBe("#2");
108
+ expect(instanceSuffix(2, 3)).toBe("#3");
109
+ });
110
+ });
@@ -26,6 +26,44 @@ export function runStartDelay(run: Pick<Run, "created_at" | "start_at">): string
26
26
  return formatDuration(delay);
27
27
  }
28
28
 
29
+ /**
30
+ * Display suffix for a run's instance slot. A service configured with more than
31
+ * one instance gets a 1-based suffix (`#1`, `#2`, …) on every one of its runs;
32
+ * a single-instance task (or any non-service, where `instanceCount` is 1)
33
+ * returns an empty string so the bare task name is shown. `instanceIndex` is
34
+ * the stored 0-based slot; `instanceCount` is the task's currently configured
35
+ * instance count.
36
+ */
37
+ export function instanceSuffix(instanceIndex: number, instanceCount: number): string {
38
+ if (instanceCount > 1) {
39
+ return `#${String(instanceIndex + 1)}`;
40
+ }
41
+ return "";
42
+ }
43
+
44
+ /** Human label for why a run fired (the `triggered_by` source). */
45
+ export function formatTriggeredByLabel(triggeredBy: Run["triggered_by"]): string {
46
+ if (triggeredBy === "api") return "API";
47
+ if (triggeredBy === "cron") return "Cron";
48
+ if (triggeredBy === "service") return "Service";
49
+ if (triggeredBy === "startup") return "Startup";
50
+ return "Cloud";
51
+ }
52
+
53
+ /**
54
+ * "retry #N" label when a run is a retry of an earlier one, else undefined.
55
+ * A run is a retry if it carries a positive attempt number or points back at
56
+ * the run it re-attempts.
57
+ */
58
+ export function runRetryLabel(
59
+ run: Pick<Run, "retry_attempt" | "retry_of_run_id">,
60
+ ): string | undefined {
61
+ if (run.retry_attempt > 0 || run.retry_of_run_id) {
62
+ return `retry #${String(run.retry_attempt)}`;
63
+ }
64
+ return undefined;
65
+ }
66
+
29
67
  /**
30
68
  * Monotonic position of a run's status in its lifecycle. Used to reject stale
31
69
  * updates (e.g. a `pending` HTTP response arriving after an SSE already
package/src/lib/index.ts CHANGED
@@ -80,17 +80,39 @@ export { LogFetcher } from "./log-console/LogFetcher.svelte.js";
80
80
 
81
81
  export { default as RunDetailPanel } from "./components/dashboard/RunDetailPanel.svelte";
82
82
  export { default as RunsList } from "./components/dashboard/RunsList.svelte";
83
- export type {
84
- RunsListFilters,
85
- RunsListSortDirection,
86
- } from "./components/dashboard/RunsList.svelte";
83
+ export { default as RunFilterPopover } from "./components/dashboard/RunFilterPopover.svelte";
84
+ export type { RunOutputMatch } from "./components/dashboard/RunsList.svelte";
85
+ export {
86
+ emptyRunFilters,
87
+ NEEDS_ATTENTION_STATUSES,
88
+ isNeedsAttention,
89
+ activeFilterCount,
90
+ activeDimensions,
91
+ dimensionActive,
92
+ clearDimension,
93
+ clearPopoverFilters,
94
+ humanizeStatus,
95
+ statusChipLabel,
96
+ exitCodeRange,
97
+ type ExitCodeRange,
98
+ type RunsListFilters,
99
+ type RunsListSortDirection,
100
+ type FilterDimension,
101
+ } from "./components/dashboard/run-filters.js";
87
102
  export {
88
103
  getRunStatusConfig,
89
104
  runDisplayStatus,
90
105
  RUN_STATUS_CONFIG,
91
106
  type RunStatusConfig,
92
107
  } from "./components/dashboard/status-config.js";
93
- export { runDuration, runStartDelay, runPhaseOrder } from "./components/dashboard/run-helpers.js";
108
+ export {
109
+ runDuration,
110
+ runStartDelay,
111
+ runPhaseOrder,
112
+ formatTriggeredByLabel,
113
+ runRetryLabel,
114
+ instanceSuffix,
115
+ } from "./components/dashboard/run-helpers.js";
94
116
  export { humanizeCron, type HumanizedCron } from "./components/dashboard/cron-format.js";
95
117
  export type { DaemonState, DaemonStats } from "./components/dashboard/types.js";
96
118
 
@@ -1,8 +1,9 @@
1
1
  // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import type { LogSlice, LogEvent } from "./types.js";
4
+ import type { LogSlice, LogEvent, RegionUpdate } from "./types.js";
5
5
  import { SvelteMap } from "svelte/reactivity";
6
+ import { visibleColumns } from "./ansi.js";
6
7
 
7
8
  /**
8
9
  * LogCache provides high-performance storage and pruning for log data.
@@ -13,10 +14,20 @@ import { SvelteMap } from "svelte/reactivity";
13
14
  */
14
15
  export class LogCache {
15
16
  lines = $state(new SvelteMap<number, string>());
17
+ // Live-region overlay, keyed by stream (stdout/stderr). Each stream has at
18
+ // most one active region; the frame is replaced wholesale on each update and
19
+ // never persisted. Rendered below the committed lines as an in-place tail.
20
+ regions = $state(new SvelteMap<string, { epoch: number; rows: string[] }>());
21
+ // Per-line frame-history counts. A non-zero entry marks a settled progress
22
+ // bar / multi-line redraw whose prior frames can be fetched on click.
23
+ frameCounts = $state(new SvelteMap<number, number>());
16
24
  totalLines = $state(0);
17
25
  totalBytes = $state(0);
18
26
  finished = $state(false);
19
27
  firstAvailableLine = $state(0);
28
+ // Widest visible line seen so far, in monospace columns. Monotonic so the
29
+ // horizontal scroll surface never shrinks as windowed lines are pruned.
30
+ maxLineColumns = $state(0);
20
31
 
21
32
  get size() {
22
33
  return this.lines.size;
@@ -38,6 +49,7 @@ export class LogCache {
38
49
  const prev = this.lines.get(line);
39
50
  if (prev !== v) {
40
51
  this.lines.set(line, v);
52
+ this.maxLineColumns = Math.max(this.maxLineColumns, visibleColumns(v));
41
53
  touched = true;
42
54
  }
43
55
  }
@@ -45,13 +57,53 @@ export class LogCache {
45
57
  return { touched, min, max };
46
58
  }
47
59
 
60
+ // Flattened overlay rows for rendering, stdout before stderr (then any
61
+ // other streams, sorted) for stable ordering. Reactive: reads the regions
62
+ // map so it recomputes on update.
63
+ get overlayRows(): string[] {
64
+ const preferred = ["stdout", "stderr"];
65
+ const rest = [...this.regions.keys()]
66
+ .filter((s) => !preferred.includes(s))
67
+ .sort((a, b) => a.localeCompare(b));
68
+ const out: string[] = [];
69
+ for (const stream of [...preferred, ...rest]) {
70
+ const region = this.regions.get(stream);
71
+ if (region) out.push(...region.rows);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ applyRegion(update: RegionUpdate) {
77
+ if (update.rows.length === 0) {
78
+ this.regions.delete(update.stream);
79
+ return;
80
+ }
81
+ this.regions.set(update.stream, { epoch: update.epoch, rows: update.rows });
82
+ for (const row of update.rows) {
83
+ this.maxLineColumns = Math.max(this.maxLineColumns, visibleColumns(row));
84
+ }
85
+ }
86
+
48
87
  applyEvent(event: LogEvent) {
88
+ if (event.region) {
89
+ this.applyRegion(event.region);
90
+ return { touched: false, min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY };
91
+ }
49
92
  this.totalLines = Math.max(this.totalLines, Math.floor(event.sizeLines));
50
93
  this.totalBytes = Math.max(this.totalBytes, Math.floor(event.sizeBytes ?? 0));
51
94
  this.finished = event.finished;
95
+ // A finished run has no live region; drop any lingering overlay so a
96
+ // dropped clear-frame can't leave a stale tail painted forever.
97
+ if (event.finished && this.regions.size > 0) this.regions.clear();
52
98
  if (event.firstAvailableLine !== undefined && event.firstAvailableLine > 0) {
53
99
  this.firstAvailableLine = Math.max(this.firstAvailableLine, event.firstAvailableLine);
54
100
  }
101
+ if (event.frameCounts) {
102
+ for (const [k, v] of Object.entries(event.frameCounts)) {
103
+ const line = Number(k);
104
+ if (Number.isFinite(line) && line >= 0 && v > 0) this.frameCounts.set(line, v);
105
+ }
106
+ }
55
107
 
56
108
  return this.mergeSlice(event.lines);
57
109
  }
@@ -73,10 +125,13 @@ export class LogCache {
73
125
 
74
126
  reset() {
75
127
  this.lines.clear();
128
+ this.regions.clear();
129
+ this.frameCounts.clear();
76
130
  this.totalLines = 0;
77
131
  this.totalBytes = 0;
78
132
  this.finished = false;
79
133
  this.firstAvailableLine = 0;
134
+ this.maxLineColumns = 0;
80
135
  }
81
136
 
82
137
  isRangeComplete(start: number, end: number) {
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import { describe, expect, it } from "vitest";
5
- import { ansiLineToHtml } from "./ansi.js";
5
+ import { ansiLineToHtml, visibleColumns } from "./ansi.js";
6
6
 
7
7
  describe("ansiLineToHtml", () => {
8
8
  it("passes plain text through unchanged", () => {
@@ -48,3 +48,20 @@ describe("ansiLineToHtml", () => {
48
48
  expect(ansiLineToHtml("plain")).toBe("plain");
49
49
  });
50
50
  });
51
+
52
+ describe("visibleColumns", () => {
53
+ it("counts plain characters", () => {
54
+ expect(visibleColumns("hello")).toBe(5);
55
+ expect(visibleColumns("")).toBe(0);
56
+ });
57
+
58
+ it("ignores ANSI escape sequences", () => {
59
+ expect(visibleColumns("\x1b[31merror\x1b[0m")).toBe(5);
60
+ expect(visibleColumns("\x1b[1;36minfo\x1b[0m done")).toBe(9);
61
+ });
62
+
63
+ it("charges tabs the full tab width", () => {
64
+ expect(visibleColumns("\t")).toBe(8);
65
+ expect(visibleColumns("ab\tcd")).toBe(12);
66
+ });
67
+ });
@@ -37,3 +37,20 @@ export function ansiLineToHtml(text: string): string {
37
37
  colors: ANSI_PALETTE,
38
38
  }).toHtml(text);
39
39
  }
40
+
41
+ // Matches CSI escape sequences (colour codes, cursor moves, etc.), which
42
+ // render to zero visible width. The ESC control char is intentional here.
43
+ // eslint-disable-next-line no-control-regex
44
+ const ANSI_ESCAPE = /\x1b\[[0-9;?]*[A-Za-z]/g;
45
+ const TAB_WIDTH = 8;
46
+
47
+ // Count the visible columns a log line occupies once ANSI escape sequences
48
+ // are stripped. Tabs are charged the full tab width — an over-estimate that
49
+ // only adds trailing slack to the horizontal scroll surface, never clips.
50
+ export function visibleColumns(text: string): number {
51
+ let columns = 0;
52
+ for (const ch of text.replace(ANSI_ESCAPE, "")) {
53
+ columns += ch === "\t" ? TAB_WIDTH : 1;
54
+ }
55
+ return columns;
56
+ }
@@ -3,12 +3,29 @@
3
3
 
4
4
  export type LogSlice = Record<number, string>;
5
5
 
6
+ /**
7
+ * RegionUpdate is a live snapshot of a still-animating output region (a `\r`
8
+ * progress bar or a multi-line ANSI redraw). It is never persisted: the cache
9
+ * holds it in a transient overlay keyed by stream, replacing the whole frame on
10
+ * each update. Empty `rows` clears the overlay for that stream.
11
+ */
12
+ export type RegionUpdate = {
13
+ stream: string;
14
+ epoch: number;
15
+ rows: string[];
16
+ };
17
+
6
18
  export type LogEvent = {
7
19
  lines: LogSlice;
8
20
  sizeLines: number;
9
21
  sizeBytes?: number;
10
22
  finished: boolean;
11
23
  firstAvailableLine?: number;
24
+ region?: RegionUpdate;
25
+ // Per-line count of recorded prior frames, keyed by line number. Present only
26
+ // for settled progress-bar / multi-line-redraw anchor lines, marking them as
27
+ // clickable to rewind through earlier states. Plain lines are absent.
28
+ frameCounts?: Record<number, number>;
12
29
  };
13
30
 
14
31
  export function isLogEvent(value: unknown): value is LogEvent {
@@ -20,73 +20,76 @@
20
20
  */
21
21
 
22
22
  :root {
23
- /* Primary — Wisp Violet */
24
- --rw-color-wisp-50: oklch(0.97 0.02 280);
25
- --rw-color-wisp-100: oklch(0.94 0.04 280);
26
- --rw-color-wisp-200: oklch(0.88 0.08 280);
27
- --rw-color-wisp-300: oklch(0.78 0.12 280);
28
- --rw-color-wisp-400: oklch(0.68 0.16 275);
29
- --rw-color-wisp-500: oklch(0.58 0.18 270);
30
- --rw-color-wisp-600: oklch(0.5 0.18 268);
31
- --rw-color-wisp-700: oklch(0.42 0.16 268);
32
- --rw-color-wisp-800: oklch(0.35 0.12 268);
33
- --rw-color-wisp-900: oklch(0.28 0.08 268);
34
- --rw-color-wisp-950: oklch(0.18 0.06 268);
23
+ /* Primary — Wisp Teal (brand = #0E7C86, the artifact's deep teal). Deeper
24
+ * and less cyan than Aurora (running/info) so the two stay distinct. */
25
+ --rw-color-wisp-50: oklch(0.97 0.02 205);
26
+ --rw-color-wisp-100: oklch(0.95 0.035 205);
27
+ --rw-color-wisp-200: oklch(0.89 0.055 204);
28
+ --rw-color-wisp-300: oklch(0.8 0.075 204);
29
+ --rw-color-wisp-400: oklch(0.68 0.088 203);
30
+ --rw-color-wisp-500: oklch(0.6 0.09 204);
31
+ --rw-color-wisp-600: oklch(0.536 0.089 205); /* ≈ #0E7C86 */
32
+ --rw-color-wisp-700: oklch(0.46 0.08 206);
33
+ --rw-color-wisp-800: oklch(0.38 0.066 207);
34
+ --rw-color-wisp-900: oklch(0.3 0.048 208);
35
+ --rw-color-wisp-950: oklch(0.2 0.034 210);
35
36
 
36
- /* Accent — Aurora Teal */
37
- --rw-color-aurora-50: oklch(0.97 0.02 180);
38
- --rw-color-aurora-100: oklch(0.94 0.05 180);
39
- --rw-color-aurora-200: oklch(0.88 0.1 178);
40
- --rw-color-aurora-300: oklch(0.78 0.14 176);
41
- --rw-color-aurora-400: oklch(0.68 0.16 174);
42
- --rw-color-aurora-500: oklch(0.58 0.14 172);
43
- --rw-color-aurora-600: oklch(0.5 0.12 172);
44
- --rw-color-aurora-700: oklch(0.42 0.1 172);
45
- --rw-color-aurora-800: oklch(0.35 0.08 172);
46
- --rw-color-aurora-900: oklch(0.28 0.06 172);
37
+ /* Accent — Aurora Teal (running status = #13A0A8, a cyan-leaning teal) */
38
+ --rw-color-aurora-50: oklch(0.97 0.02 192);
39
+ --rw-color-aurora-100: oklch(0.94 0.045 192);
40
+ --rw-color-aurora-200: oklch(0.88 0.075 190);
41
+ --rw-color-aurora-300: oklch(0.78 0.105 189);
42
+ --rw-color-aurora-400: oklch(0.68 0.115 190);
43
+ --rw-color-aurora-500: oklch(0.58 0.115 191);
44
+ --rw-color-aurora-600: oklch(0.5 0.105 192);
45
+ --rw-color-aurora-700: oklch(0.42 0.09 192);
46
+ --rw-color-aurora-800: oklch(0.35 0.07 192);
47
+ --rw-color-aurora-900: oklch(0.28 0.055 192);
47
48
 
48
- /* Mist — neutral grays with a purple tint */
49
- --rw-color-mist-50: oklch(0.98 0.005 280);
50
- --rw-color-mist-100: oklch(0.96 0.008 280);
51
- --rw-color-mist-200: oklch(0.92 0.01 280);
52
- --rw-color-mist-300: oklch(0.86 0.012 280);
53
- --rw-color-mist-400: oklch(0.7 0.012 280);
54
- --rw-color-mist-500: oklch(0.55 0.012 280);
55
- --rw-color-mist-600: oklch(0.45 0.012 280);
56
- --rw-color-mist-700: oklch(0.35 0.012 280);
57
- --rw-color-mist-800: oklch(0.25 0.012 280);
58
- --rw-color-mist-900: oklch(0.18 0.012 280);
59
- --rw-color-mist-950: oklch(0.12 0.012 280);
49
+ /* Mist — cool slate-blue neutrals (the artifact's #EDF1F4 / #E0E6EC /
50
+ * #18212E family). No purple tint; hue ~248, low chroma rising into the
51
+ * mids the way the design's slate does. */
52
+ --rw-color-mist-50: oklch(0.985 0.004 240);
53
+ --rw-color-mist-100: oklch(0.956 0.006 240); /* ≈ ground #EDF1F4 */
54
+ --rw-color-mist-200: oklch(0.922 0.01 248); /* ≈ line #E0E6EC */
55
+ --rw-color-mist-300: oklch(0.83 0.016 248);
56
+ --rw-color-mist-400: oklch(0.676 0.026 248); /* ≈ faint #8B99A7 */
57
+ --rw-color-mist-500: oklch(0.6 0.03 248);
58
+ --rw-color-mist-600: oklch(0.531 0.032 248); /* ≈ muted #5E6E7E */
59
+ --rw-color-mist-700: oklch(0.4 0.031 252);
60
+ --rw-color-mist-800: oklch(0.31 0.029 255);
61
+ --rw-color-mist-900: oklch(0.245 0.028 258); /* ≈ ink #18212E */
62
+ --rw-color-mist-950: oklch(0.17 0.022 258);
60
63
 
61
- /* Success — Aurora Mint */
62
- --rw-color-success-50: oklch(0.97 0.018 165);
63
- --rw-color-success-100: oklch(0.94 0.035 165);
64
- --rw-color-success-200: oklch(0.88 0.06 165);
65
- --rw-color-success-300: oklch(0.78 0.09 165);
66
- --rw-color-success-400: oklch(0.68 0.11 165);
67
- --rw-color-success-500: oklch(0.58 0.115 165);
68
- --rw-color-success-600: oklch(0.5 0.105 165);
69
- --rw-color-success-700: oklch(0.42 0.095 165);
64
+ /* Success — Field Green (success status = #1F9D57, a true grass green) */
65
+ --rw-color-success-50: oklch(0.97 0.02 150);
66
+ --rw-color-success-100: oklch(0.94 0.04 150);
67
+ --rw-color-success-200: oklch(0.88 0.07 150);
68
+ --rw-color-success-300: oklch(0.78 0.11 150);
69
+ --rw-color-success-400: oklch(0.68 0.14 150);
70
+ --rw-color-success-500: oklch(0.58 0.145 150);
71
+ --rw-color-success-600: oklch(0.5 0.13 150);
72
+ --rw-color-success-700: oklch(0.42 0.11 150);
70
73
 
71
- /* Danger — Rose Crimson */
72
- --rw-color-danger-50: oklch(0.97 0.014 355);
73
- --rw-color-danger-100: oklch(0.94 0.028 355);
74
- --rw-color-danger-200: oklch(0.88 0.052 355);
75
- --rw-color-danger-300: oklch(0.78 0.085 355);
76
- --rw-color-danger-400: oklch(0.68 0.115 355);
77
- --rw-color-danger-500: oklch(0.58 0.145 355);
78
- --rw-color-danger-600: oklch(0.5 0.135 355);
79
- --rw-color-danger-700: oklch(0.42 0.12 355);
74
+ /* Danger — Signal Red (failed status = #D6453D, a warm tomato red) */
75
+ --rw-color-danger-50: oklch(0.97 0.016 27);
76
+ --rw-color-danger-100: oklch(0.94 0.03 27);
77
+ --rw-color-danger-200: oklch(0.88 0.06 27);
78
+ --rw-color-danger-300: oklch(0.78 0.1 27);
79
+ --rw-color-danger-400: oklch(0.68 0.14 27);
80
+ --rw-color-danger-500: oklch(0.58 0.175 27);
81
+ --rw-color-danger-600: oklch(0.5 0.17 27);
82
+ --rw-color-danger-700: oklch(0.42 0.145 27);
80
83
 
81
- /* Warning */
82
- --rw-color-warning-50: oklch(0.98 0.03 85);
83
- --rw-color-warning-100: oklch(0.95 0.08 85);
84
- --rw-color-warning-200: oklch(0.9 0.12 80);
85
- --rw-color-warning-300: oklch(0.85 0.16 75);
86
- --rw-color-warning-400: oklch(0.8 0.18 70);
87
- --rw-color-warning-500: oklch(0.75 0.16 65);
88
- --rw-color-warning-600: oklch(0.65 0.14 60);
89
- --rw-color-warning-700: oklch(0.55 0.12 55);
84
+ /* Warning — Ember Amber (stopped status = #D98324, a warm orange-amber) */
85
+ --rw-color-warning-50: oklch(0.97 0.03 78);
86
+ --rw-color-warning-100: oklch(0.94 0.07 74);
87
+ --rw-color-warning-200: oklch(0.9 0.11 72);
88
+ --rw-color-warning-300: oklch(0.84 0.14 70);
89
+ --rw-color-warning-400: oklch(0.77 0.155 67);
90
+ --rw-color-warning-500: oklch(0.69 0.155 64);
91
+ --rw-color-warning-600: oklch(0.6 0.14 62);
92
+ --rw-color-warning-700: oklch(0.52 0.12 60);
90
93
 
91
94
  /* ANSI console palette — 16-color map for the log console, tuned for
92
95
  * its always-dark surface (no .dark override needed). Hues follow the
@@ -109,6 +112,15 @@
109
112
  --rw-ansi-bright-cyan: var(--rw-color-aurora-300);
110
113
  --rw-ansi-bright-white: var(--rw-color-mist-50);
111
114
 
115
+ /* Console device surface — the log console is a "black box" instrument
116
+ * with its own fixed palette that never follows the light/dark UI theme.
117
+ * Exact values from the approved execution-detail design. */
118
+ --rw-con-bg: #0e141b; /* body / gutter backing */
119
+ --rw-con-panel: #11181f; /* toolbar + footer chrome */
120
+ --rw-con-text: #c3cfda; /* default log text */
121
+ --rw-con-dim: #6c7d8c; /* footer + muted readouts */
122
+ --rw-con-gutter: #3c4a58; /* line numbers + sentinel rule */
123
+
112
124
  /* Light-mode semantic tokens (default) */
113
125
  /* Surfaces */
114
126
  --rw-surface: var(--rw-color-mist-50);
package/src/lib/theme.css CHANGED
@@ -197,6 +197,7 @@
197
197
  --color-warning-soft-text: var(--rw-warning-soft-text);
198
198
  /* Info */
199
199
  --color-info: var(--rw-info);
200
+ --color-info-surface: var(--rw-info);
200
201
  --color-info-hover: var(--rw-info-hover);
201
202
  --color-on-info: var(--rw-on-info);
202
203
  --color-info-soft: var(--rw-info-soft);
@@ -4,11 +4,21 @@
4
4
  import { describe, expect, it } from "vitest";
5
5
  import {
6
6
  formatBytes,
7
+ formatCalendarDate,
8
+ formatClockTime,
9
+ formatDayMonth,
7
10
  formatDuration,
8
11
  formatRelativeTime,
9
12
  formatRelativeTimeWithAbsolute,
13
+ formatTimeHM,
10
14
  } from "./format.js";
11
15
 
16
+ // The clock/date helpers below delegate to toLocaleTimeString / toLocaleDateString,
17
+ // whose exact rendering is locale- and timezone-dependent. We assert the
18
+ // locale-invariant *shape* of each format (how many numeric groups, year present
19
+ // or not) rather than a literal string, mirroring formatRelativeTimeWithAbsolute.
20
+ const numericGroups = (s: string): number => (s.match(/\d+/g) ?? []).length;
21
+
12
22
  describe("formatBytes", () => {
13
23
  it("formats 0 bytes", () => {
14
24
  expect(formatBytes(0)).toBe("0 B");
@@ -122,3 +132,31 @@ describe("formatRelativeTime", () => {
122
132
  );
123
133
  });
124
134
  });
135
+
136
+ describe("formatClockTime", () => {
137
+ it("renders three numeric groups (hours, minutes, seconds)", () => {
138
+ // 24-hour h:m:s — the seconds component is what sets it apart from formatTimeHM.
139
+ expect(numericGroups(formatClockTime("2026-06-22T17:15:02Z"))).toBe(3);
140
+ });
141
+ });
142
+
143
+ describe("formatTimeHM", () => {
144
+ it("renders two numeric groups (hours, minutes) — no seconds", () => {
145
+ expect(numericGroups(formatTimeHM("2026-06-22T17:15:02Z"))).toBe(2);
146
+ });
147
+ });
148
+
149
+ describe("formatCalendarDate", () => {
150
+ it("includes the four-digit year", () => {
151
+ // Midday UTC keeps the calendar year stable across every timezone.
152
+ expect(formatCalendarDate("2026-06-22T12:00:00Z")).toContain("2026");
153
+ });
154
+ });
155
+
156
+ describe("formatDayMonth", () => {
157
+ it("includes a day digit but omits the year", () => {
158
+ const r = formatDayMonth("2026-06-22T12:00:00Z");
159
+ expect(r).toMatch(/\d/);
160
+ expect(r).not.toContain("2026");
161
+ });
162
+ });
@@ -53,6 +53,42 @@ export function formatDateTime(dateStr: string): string {
53
53
  });
54
54
  }
55
55
 
56
+ /** Wall-clock time of day, seconds included, 24-hour — e.g. "17:15:02". */
57
+ export function formatClockTime(dateStr: string): string {
58
+ return new Date(dateStr).toLocaleTimeString(undefined, {
59
+ hour: "2-digit",
60
+ minute: "2-digit",
61
+ second: "2-digit",
62
+ hour12: false,
63
+ });
64
+ }
65
+
66
+ /** Calendar date without the time — e.g. "22 Jun 2026". */
67
+ export function formatCalendarDate(dateStr: string): string {
68
+ return new Date(dateStr).toLocaleDateString(undefined, {
69
+ year: "numeric",
70
+ month: "short",
71
+ day: "numeric",
72
+ });
73
+ }
74
+
75
+ /** Time of day, no seconds, 24-hour — e.g. "17:15". */
76
+ export function formatTimeHM(dateStr: string): string {
77
+ return new Date(dateStr).toLocaleTimeString(undefined, {
78
+ hour: "2-digit",
79
+ minute: "2-digit",
80
+ hour12: false,
81
+ });
82
+ }
83
+
84
+ /** Day and month, no year — e.g. "22 Jun". */
85
+ export function formatDayMonth(dateStr: string): string {
86
+ return new Date(dateStr).toLocaleDateString(undefined, {
87
+ month: "short",
88
+ day: "numeric",
89
+ });
90
+ }
91
+
56
92
  export function formatDuration(ms: number): string {
57
93
  if (ms < 1000) return String(ms) + "ms";
58
94
  const s = Math.floor(ms / 1000);
@@ -50,6 +50,15 @@
50
50
  `${ESC}4;36munderlined cyan link${ESC}0m and ${ESC}4;33munderlined yellow${ESC}0m`,
51
51
  ];
52
52
 
53
+ const LONG_LINES = [
54
+ `${ESC}36m[info]${ESC}0m starting request trace`,
55
+ `${ESC}90m2026-06-05T03:00:00Z${ESC}0m GET /api/v1/tasks?include=runs,logs,schedule&limit=100&offset=0 → ${ESC}32m200${ESC}0m ${ESC}90m{"trace":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","upstream":"https://internal.example.com/very/long/path/segment/that/keeps/going/and/going","latency_ms":1287,"bytes":204812}${ESC}0m`,
56
+ `${ESC}31m[error]${ESC}0m panic: runtime error: index out of range [12] with length 8 — goroutine 42 [running]: main.process(0xc0000b4000, 0x1f4) /home/runner/work/app/app/internal/pipeline/process.go:317 +0x4a5`,
57
+ " short line in between",
58
+ `tab-separated\tcolumns\there\tshould\tstay\taligned\tand\textend\tfar\tpast\tthe\tviewport\tedge\tto\tprove\thorizontal\tscroll`,
59
+ `${ESC}32m[done]${ESC}0m ${"=".repeat(400)}|END`,
60
+ ];
61
+
53
62
  const STREAM_TICKS = [
54
63
  `${ESC}36m[info]${ESC}0m processed batch ${ESC}1m#42${ESC}0m ${ESC}90m(1,024 records)${ESC}0m`,
55
64
  `${ESC}32m✓${ESC}0m health check passed`,
@@ -73,3 +82,7 @@
73
82
  <Story name="Truncated" asChild>
74
83
  <LogConsoleDemo lines={SAMPLE_LOG.slice(6)} firstAvailableLine={1500} />
75
84
  </Story>
85
+
86
+ <Story name="Long Lines" asChild>
87
+ <LogConsoleDemo lines={LONG_LINES} />
88
+ </Story>
@@ -1,8 +1,8 @@
1
1
  <svg width="512" height="512" version="1.1" viewBox="0 0 5120 5120" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
2
2
  <defs>
3
3
  <linearGradient id="b">
4
- <stop stop-color="#526ee3" offset="0"/>
5
- <stop stop-color="#009371" offset="1"/>
4
+ <stop stop-color="#15a0a8" offset="0"/>
5
+ <stop stop-color="#0e7c86" offset="1"/>
6
6
  </linearGradient>
7
7
  <radialGradient id="a" cx="2375.3" cy="3240.7" r="1333.9" gradientTransform="matrix(1.01 -.012494 .026846 2.1701 -110.74 -3341.2)" gradientUnits="userSpaceOnUse" xlink:href="#b"/>
8
8
  </defs>