@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
@@ -2,17 +2,12 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import { TRIGGERS, type Trigger } from "@runwisp/common";
5
+ import { TRIGGER_LABELS } from "./run-helpers.js";
5
6
 
6
7
  /**
7
- * The single filter shape shared by the runs list, the filter popover, the
8
- * SSE-merge source, and the bulk selector. Every field beyond `search` and
9
- * `sortDirection` is an optional dimension applied server-side; the zero
10
- * value (`emptyRunFilters`) matches every run.
11
- *
12
- * `statuses` is a multi-select set joined to a comma-separated string only at
13
- * the two wire boundaries (the list query and the bulk selector). Time bounds
14
- * are absolute RFC3339 instants — presets resolve to `now − delta` at apply
15
- * time so a live SSE row created after the boundary keeps matching.
8
+ * The filter shared by the runs list, the filter popover, the SSE-merge source
9
+ * and the bulk selector. Time bounds are absolute RFC3339 instants, so a live
10
+ * SSE row created after a preset was applied keeps matching.
16
11
  */
17
12
  export interface RunsListFilters {
18
13
  search: string;
@@ -20,52 +15,38 @@ export interface RunsListFilters {
20
15
  sortDirection: "asc" | "desc" | "";
21
16
  // Optional dimensions explicitly admit `undefined` so a dimension can be
22
17
  // cleared by reassignment (`{ ...f, x: undefined }`) under the project's
23
- // exactOptionalPropertyTypes — a fresh object reference is what re-triggers
18
+ // exactOptionalPropertyTypes, a fresh object reference is what re-triggers
24
19
  // the parent's fetch effect through several levels of `bind:`.
25
20
  taskName?: string | undefined;
26
21
  createdAfter?: string | undefined;
27
22
  createdBefore?: string | undefined;
28
23
  triggeredBy?: string | undefined;
29
- // A human exit-code expression — a bare code (`137`), a comparison (`>100`,
30
- // `<=150`), or a space-separated combination (`>100 <150`). Normalized to an
31
- // inclusive [min, max] range (`exitCodeRange`) at the wire boundary; the
32
- // server only ever sees `exitCodeMin` / `exitCodeMax`.
24
+ // An exit-code expression such as `137`, `>100` or `>100 <150`; see
25
+ // parseExitCodeRange.
33
26
  exitCode?: string | undefined;
34
27
  retriesOnly?: boolean | undefined;
35
28
  }
36
29
 
37
- /** A fresh default filter — no dimensions active, newest-first. */
30
+ /** A fresh default filter, no dimensions active, newest-first. */
38
31
  export function emptyRunFilters(): RunsListFilters {
39
32
  return { search: "", statuses: [], sortDirection: "desc" };
40
33
  }
41
34
 
42
35
  /**
43
- * Reserved `status` token the "Failed" browse filter sends instead of a fixed
44
- * end-reason list. The server (splitFailureToken in api_types.go) strips it and
45
- * matches on each run's persisted `isFailure` bit, so the browse filter agrees
46
- * with the attention badges, the failed stat, and notifications — all of which
47
- * follow the per-task `failures` policy. It is not a real status/end-reason, so
48
- * it OR-combines with the other buckets. Must match `failureStatusToken` in the
49
- * daemon.
36
+ * Reserved `status` token for the "Failed" filter. The daemon matches it on each
37
+ * run's `isFailure` bit (per-task `failures` policy) instead of a fixed
38
+ * end-reason list. Must match `failureStatusToken` in the daemon.
50
39
  */
51
40
  export const FAILURE_STATUS_TOKEN = "failure";
52
41
 
53
42
  /**
54
- * Outcome buckets: the 14 individual run statuses collapsed into five
55
- * plain-language groups, so the common case is a five-item pick instead of a
56
- * flat checklist. Each bucket is purely a UI grouping over `statuses` — toggling
57
- * one adds/removes its members, and the popover's "Advanced" section still
58
- * exposes the individual statuses for surgical filters (e.g. only `timeout`).
59
- *
60
- * Every status appears in exactly one bucket; `Failed` is the failure sentinel,
61
- * which the server resolves to each run's `isFailure` bit. `dot` mirrors that
62
- * group's color in
63
- * RUN_STATUS_CONFIG so the bucket reads at a glance.
43
+ * Outcome buckets: a UI grouping of the individual run statuses into five
44
+ * plain-language picks. The popover's "Advanced" section still exposes the
45
+ * individual statuses. `dot` mirrors the group's color in RUN_STATUS_CONFIG.
64
46
  */
65
47
  export interface StatusBucket {
66
48
  key: string;
67
49
  label: string;
68
- /** Tailwind background class for the bucket's status dot. */
69
50
  dot: string;
70
51
  statuses: readonly string[];
71
52
  }
@@ -95,7 +76,6 @@ export const STATUS_BUCKETS: readonly StatusBucket[] = [
95
76
 
96
77
  export type BucketState = "on" | "partial" | "off";
97
78
 
98
- /** Whether none, some, or all of a bucket's statuses are currently selected. */
99
79
  export function bucketState(statuses: string[], bucket: StatusBucket): BucketState {
100
80
  const set = new Set(statuses);
101
81
  const present = bucket.statuses.filter((s) => set.has(s)).length;
@@ -122,7 +102,7 @@ function exactBucket(statuses: string[]): StatusBucket | undefined {
122
102
 
123
103
  /**
124
104
  * The popover-managed dimensions, in display (most→least useful) order.
125
- * `task` only ever applies on the cross-task /runs view — on a single task's
105
+ * `task` only ever applies on the cross-task /runs view, on a single task's
126
106
  * page the task name is the page scope (injected at fetch time), never a
127
107
  * popover-set filter, so it stays absent from those filters and is not counted.
128
108
  */
@@ -137,7 +117,6 @@ const DIMENSION_ORDER: FilterDimension[] = [
137
117
  "retries",
138
118
  ];
139
119
 
140
- /** Whether a single dimension is currently constraining the result set. */
141
120
  export function dimensionActive(f: RunsListFilters, dim: FilterDimension): boolean {
142
121
  switch (dim) {
143
122
  case "status":
@@ -155,17 +134,16 @@ export function dimensionActive(f: RunsListFilters, dim: FilterDimension): boole
155
134
  }
156
135
  }
157
136
 
158
- /** The active dimensions, in display order — drives the chip row. */
137
+ /** The active dimensions, in display order, drives the chip row. */
159
138
  export function activeDimensions(f: RunsListFilters): FilterDimension[] {
160
139
  return DIMENSION_ORDER.filter((dim) => dimensionActive(f, dim));
161
140
  }
162
141
 
163
- /** How many dimensions are active — the count badge on the Filter button. */
142
+ /** How many dimensions are active, the count badge on the Filter button. */
164
143
  export function activeFilterCount(f: RunsListFilters): number {
165
144
  return activeDimensions(f).length;
166
145
  }
167
146
 
168
- /** Reset one dimension, leaving the others (and search/sort) untouched. */
169
147
  export function clearDimension(f: RunsListFilters, dim: FilterDimension): RunsListFilters {
170
148
  switch (dim) {
171
149
  case "status":
@@ -190,47 +168,20 @@ export function humanizeStatus(status: string): string {
190
168
  return spaced.charAt(0).toUpperCase() + spaced.slice(1);
191
169
  }
192
170
 
193
- /**
194
- * Descriptive label for a run's trigger source — fuller than the one-word row
195
- * badge (`formatTriggeredByLabel`) for the "cron"/"station"/"service"/"startup"
196
- * sources, where the extra words disambiguate (e.g. "REST API" vs a bare
197
- * "API"). "ui", "cli" and "hook" already read fine as their bare badge word,
198
- * so those three match `formatTriggeredByLabel` exactly.
199
- *
200
- * Note `cron` covers both on-time schedule firings and catch-up for missed
201
- * runs, and `startup` is specifically `run_on_start` (not catch-up).
202
- */
171
+ /** Descriptive label for a run's trigger source (see {@link TRIGGER_LABELS}). */
203
172
  export function triggerDescription(trigger: string): string {
204
- switch (trigger) {
205
- case "cron":
206
- return "Scheduled (cron)";
207
- case "api":
208
- return "REST API";
209
- case "ui":
210
- return "UI";
211
- case "cli":
212
- return "CLI";
213
- case "station":
214
- return "Control plane";
215
- case "service":
216
- return "Service auto-start";
217
- case "startup":
218
- return "On daemon start";
219
- case "hook":
220
- return "Hook";
221
- default:
222
- return humanizeStatus(trigger);
223
- }
173
+ const known = TRIGGERS.find((t) => t === trigger);
174
+ return known ? TRIGGER_LABELS[known].long : humanizeStatus(trigger);
224
175
  }
225
176
 
226
177
  /**
227
178
  * The triggers offered in the filter dropdown. `station` is intentionally
228
- * excluded for now — control-plane runs still carry the `station` trigger, but
179
+ * excluded for now, control-plane runs still carry the `station` trigger, but
229
180
  * it isn't a selectable filter dimension.
230
181
  */
231
182
  export const FILTERABLE_TRIGGERS: readonly Trigger[] = TRIGGERS.filter((t) => t !== "station");
232
183
 
233
- /** Chip label for the status dimension — names a whole bucket when it matches. */
184
+ /** Chip label for the status dimension, names a whole bucket when it matches. */
234
185
  export function statusChipLabel(statuses: string[]): string {
235
186
  const bucket = exactBucket(statuses);
236
187
  if (bucket) return bucket.label;
@@ -239,15 +190,6 @@ export function statusChipLabel(statuses: string[]): string {
239
190
  return `${String(statuses.length)} statuses`;
240
191
  }
241
192
 
242
- // --- Time helpers --------------------------------------------------------
243
- //
244
- // A bare calendar day (`YYYY-MM-DD` from a native date input) maps to a local
245
- // instant: the "from" edge is that day's 00:00, the "to" edge is its end
246
- // (23:59:59.999) so picking the same day for both bounds — or the one-click
247
- // "on day" shortcut — captures the whole day and round-trips back to the same
248
- // date in the inputs. Bounds are stored as absolute RFC3339 (UTC) instants, the
249
- // shape the list query and SSE-merge filter already expect.
250
-
251
193
  /** Parse `YYYY-MM-DD` into the local Date for that day, or null if malformed. */
252
194
  function localDay(dateStr: string): Date | null {
253
195
  const parts = dateStr.split("-");
@@ -295,14 +237,6 @@ export function isWholeDay(after?: string, before?: string): boolean {
295
237
  );
296
238
  }
297
239
 
298
- // --- Exit-code expression ------------------------------------------------
299
- //
300
- // The popover takes a free-form exit-code expression and normalizes it to an
301
- // inclusive integer [min, max] range — the shape the server gates on. Because
302
- // exit codes are integers, a strict comparison collapses to an inclusive
303
- // bound: `>100` is min 101, `<150` is max 149. Tokens combine (`>100 <150`),
304
- // and a bare number is an exact match (min = max = n).
305
-
306
240
  export interface ExitCodeRange {
307
241
  min?: number;
308
242
  max?: number;
@@ -311,10 +245,10 @@ export interface ExitCodeRange {
311
245
  const EXIT_CODE_TOKEN = /^(>=|<=|>|<)?(-?\d+)$/;
312
246
 
313
247
  /**
314
- * Parse an exit-code expression into an inclusive [min, max] range. Returns
315
- * `valid: false` if any token is unparseable, so the UI can flag a typo
316
- * instead of silently applying half of it. An empty expression is valid with
317
- * no bounds (no constraint).
248
+ * Parse an exit-code expression into the inclusive integer range the server
249
+ * filters on: `>100` is min 101, `<150` is max 149, a bare number is an exact
250
+ * match, and tokens combine. Any unparseable token makes the whole expression
251
+ * invalid so the UI can flag the typo. An empty expression is valid and unbounded.
318
252
  */
319
253
  export function parseExitCodeRange(expr: string): { range: ExitCodeRange; valid: boolean } {
320
254
  const range: ExitCodeRange = {};
@@ -353,13 +287,12 @@ export function exitCodeRange(expr: string | undefined): ExitCodeRange {
353
287
  return valid ? range : {};
354
288
  }
355
289
 
356
- /** True when the expression resolves to at least one bound (a real filter). */
357
290
  export function exitCodeRangeActive(expr: string | undefined): boolean {
358
291
  const { min, max } = exitCodeRange(expr);
359
292
  return min !== undefined || max !== undefined;
360
293
  }
361
294
 
362
- /** True when the expression is empty or fully parseable — drives input validation. */
295
+ /** True when the expression is empty or fully parseable, drives input validation. */
363
296
  export function isExitCodeExprValid(expr: string): boolean {
364
297
  return parseExitCodeRange(expr).valid;
365
298
  }
@@ -10,8 +10,17 @@ import {
10
10
  runRetryLabel,
11
11
  runUsageLabel,
12
12
  instanceSuffix,
13
+ runLogDownloadUrl,
13
14
  } from "./run-helpers.js";
14
15
 
16
+ describe("runLogDownloadUrl", () => {
17
+ it("points at the daemon's raw log route", () => {
18
+ expect(runLogDownloadUrl("01J0000000000000000000000A")).toBe(
19
+ "/api/runs/01J0000000000000000000000A/log/raw",
20
+ );
21
+ });
22
+ });
23
+
15
24
  describe("runVerdict", () => {
16
25
  it("phrases a timed outcome so a duration reads after it", () => {
17
26
  expect(runVerdict("succeeded")).toEqual({ verb: "succeeded in", timed: true });
@@ -1,25 +1,17 @@
1
1
  // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import type { Run, RunStatus } from "@runwisp/common";
4
+ import type { APIPaths, Run, RunStatus, Trigger } from "@runwisp/common";
5
5
  import { formatBytes, formatDuration } from "../../utils/format.js";
6
6
 
7
7
  export interface RunVerdict {
8
8
  /** Verb phrase for the outcome, ending in its preposition when `timed`. */
9
9
  verb: string;
10
- /**
11
- * Whether the phrase expects a duration after it. False for statuses that
12
- * never produced one (nothing ran, or nothing has yet), so the caller
13
- * renders the verb alone rather than "skipped after —".
14
- */
10
+ /** Whether a duration follows the verb; false when nothing ran yet. */
15
11
  timed: boolean;
16
12
  }
17
13
 
18
- /**
19
- * The run's outcome as a verb phrase, so a detail view can state it as one
20
- * sentence ("succeeded in 933ms") instead of a status badge competing with a
21
- * separate duration readout for the same glance.
22
- */
14
+ // The run's outcome as a verb phrase, read as one sentence: "succeeded in 933ms".
23
15
  const RUN_VERDICTS: Record<RunStatus, RunVerdict> = {
24
16
  succeeded: { verb: "succeeded in", timed: true },
25
17
  failed: { verb: "failed after", timed: true },
@@ -54,12 +46,8 @@ export function runDuration(
54
46
  }
55
47
 
56
48
  /**
57
- * Gap between when a run was scheduled (`createdAt`, the cron tick) and when
58
- * it actually started (`startedAt`), formatted — or undefined when the two are
59
- * within a second of each other. This is the visible face of `jitter`: a
60
- * jittered run is created at its tick but starts later inside the window, and
61
- * this is by how much. It also surfaces queue-wait, since a queued run is
62
- * created when it joins the line and started when the line clears.
49
+ * How long a run waited between `createdAt` and `startedAt` (jitter or queue
50
+ * wait), or undefined when it started within a second.
63
51
  */
64
52
  export function runStartDelay(run: Pick<Run, "createdAt" | "startedAt">): string | undefined {
65
53
  if (!run.startedAt) return undefined;
@@ -68,14 +56,7 @@ export function runStartDelay(run: Pick<Run, "createdAt" | "startedAt">): string
68
56
  return formatDuration(delay);
69
57
  }
70
58
 
71
- /**
72
- * Display suffix for a run's instance slot. A service configured with more than
73
- * one instance gets a 1-based suffix (`#1`, `#2`, …) on every one of its runs;
74
- * a single-instance task (or any non-service, where `instanceCount` is 1)
75
- * returns an empty string so the bare task name is shown. `instanceIndex` is
76
- * the stored 0-based slot; `instanceCount` is the task's currently configured
77
- * instance count.
78
- */
59
+ /** 1-based `#N` suffix for a multi-instance service run, else "". */
79
60
  export function instanceSuffix(instanceIndex: number, instanceCount: number): string {
80
61
  if (instanceCount > 1) {
81
62
  return `#${String(instanceIndex + 1)}`;
@@ -83,23 +64,32 @@ export function instanceSuffix(instanceIndex: number, instanceCount: number): st
83
64
  return "";
84
65
  }
85
66
 
67
+ // Typed against the generated paths so a route rename fails the build.
68
+ const RAW_LOG_PATH: keyof APIPaths = "/api/runs/{runId}/log/raw";
69
+
70
+ /** The full log of a run as one plain-text download. */
71
+ export function runLogDownloadUrl(runId: string): string {
72
+ return RAW_LOG_PATH.replace("{runId}", encodeURIComponent(runId));
73
+ }
74
+
75
+ /** Labels per trigger source: `short` for the row badge, `long` for descriptions. */
76
+ export const TRIGGER_LABELS: Record<Trigger, { short: string; long: string }> = {
77
+ cron: { short: "Cron", long: "Scheduled (cron)" },
78
+ api: { short: "API", long: "REST API" },
79
+ ui: { short: "UI", long: "UI" },
80
+ cli: { short: "CLI", long: "CLI" },
81
+ station: { short: "Station", long: "Control plane" },
82
+ service: { short: "Service", long: "Service auto-start" },
83
+ startup: { short: "Startup", long: "On daemon start" },
84
+ hook: { short: "Hook", long: "Hook" },
85
+ };
86
+
86
87
  /** Human label for why a run fired (the `triggeredBy` source). */
87
88
  export function formatTriggeredByLabel(triggeredBy: Run["triggeredBy"]): string {
88
- if (triggeredBy === "api") return "API";
89
- if (triggeredBy === "ui") return "UI";
90
- if (triggeredBy === "cli") return "CLI";
91
- if (triggeredBy === "cron") return "Cron";
92
- if (triggeredBy === "service") return "Service";
93
- if (triggeredBy === "startup") return "Startup";
94
- if (triggeredBy === "hook") return "Hook";
95
- return "Station";
89
+ return TRIGGER_LABELS[triggeredBy].short;
96
90
  }
97
91
 
98
- /**
99
- * "retry #N" label when a run is a retry of an earlier one, else undefined.
100
- * A run is a retry if it carries a positive attempt number or points back at
101
- * the run it re-attempts.
102
- */
92
+ /** "retry #N" when the run re-attempts an earlier one, else undefined. */
103
93
  export function runRetryLabel(run: Pick<Run, "retryAttempt" | "retryOfRunId">): string | undefined {
104
94
  if (run.retryAttempt > 0 || run.retryOfRunId) {
105
95
  return `retry #${String(run.retryAttempt)}`;
@@ -1,14 +1,6 @@
1
1
  // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- // DataGrid's header checkbox (checked / indeterminate / unchecked) must
5
- // reflect which rows on the current page are ACTUALLY selected, not merely
6
- // how many rows are selected. A bare count comparison is wrong the moment
7
- // `selectedRows` holds a different set of rows than `pagedRows` but happens
8
- // to be the same size — e.g. rows selected on a page the grid has since
9
- // paged/filtered away from. Extracted so the membership check is unit
10
- // testable without a component-render harness.
11
-
12
4
  export interface SelectionState {
13
5
  allSelected: boolean;
14
6
  someSelected: boolean;
@@ -17,7 +9,8 @@ export interface SelectionState {
17
9
  /**
18
10
  * Header checkbox state for `pagedRows`: fully checked only when every row
19
11
  * currently on the page is selected, indeterminate when some (but not all)
20
- * of them are. Matches rows by `rowKey`, not by array position or count.
12
+ * of them are. Matches rows by `rowKey`, not by count: the selection can
13
+ * hold rows from a page the grid has since moved away from.
21
14
  */
22
15
  export function selectionState<T>(
23
16
  pagedRows: readonly T[],
@@ -3,7 +3,7 @@
3
3
 
4
4
  // Declarative filter spec. A `FilterField[]` + one bound `FilterValues` object
5
5
  // drives <FilterBar>, the client engine (`applyFilters`), and the URL sync
6
- // helper — the same way DataGrid's `Column[]` drives the table. Add a field
6
+ // helper, the same way DataGrid's `Column[]` drives the table. Add a field
7
7
  // type here, and every table that adopts it renders identically.
8
8
 
9
9
  import type { SelectOption } from "./Select.svelte";
@@ -44,7 +44,7 @@ export type FilterField =
44
44
  primary?: boolean;
45
45
  };
46
46
 
47
- // All values are strings — DOM inputs are string-valued and URL params are
47
+ // All values are strings, DOM inputs are string-valued and URL params are
48
48
  // strings; the client engine coerces (e.g. Number()) where a field needs it.
49
49
  export type FilterValues = Record<string, string>;
50
50
 
@@ -65,7 +65,7 @@ export function isBlank(v: string | undefined): boolean {
65
65
  return !v || v === "all";
66
66
  }
67
67
 
68
- // Which value keys a field owns — daterange owns two, everything else one.
68
+ // Which value keys a field owns, daterange owns two, everything else one.
69
69
  // Used by chips, active-state detection, clear, and URL (de)serialization so
70
70
  // none of them has to special-case the range type.
71
71
  export function fieldKeys(field: FilterField): string[] {
@@ -14,7 +14,7 @@ import { visibleColumns } from "./ansi.js";
14
14
  */
15
15
  // Streams pinned to the front of the overlay, in render order. The companion
16
16
  // Set backs the membership test in `overlayRows`. Both are module-level,
17
- // immutable, and non-reactive — a fixed lookup table, not component state.
17
+ // immutable, and non-reactive, a fixed lookup table, not component state.
18
18
  const PREFERRED_STREAM_ORDER = ["stdout", "stderr"] as const;
19
19
  const PREFERRED_STREAMS: ReadonlySet<string> = new Set(PREFERRED_STREAM_ORDER);
20
20
 
@@ -45,7 +45,7 @@ const ANSI_ESCAPE = /\x1b\[[0-9;?]*[A-Za-z]/g;
45
45
  const TAB_WIDTH = 8;
46
46
 
47
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
48
+ // are stripped. Tabs are charged the full tab width, an over-estimate that
49
49
  // only adds trailing slack to the horizontal scroll surface, never clips.
50
50
  export function visibleColumns(text: string): number {
51
51
  let columns = 0;
@@ -1,7 +1,7 @@
1
1
  /* SPDX-FileCopyrightText: PoppyCake, s.r.o. */
2
2
  /* SPDX-License-Identifier: Apache-2.0 */
3
3
 
4
- /* RunWisp Design Tokens — plain CSS, no Tailwind directives.
4
+ /* RunWisp Design Tokens, plain CSS, no Tailwind directives.
5
5
  *
6
6
  * Single source of truth for the OKLch palette and the semantic `--rw-*`
7
7
  * tokens that drive all apps:
@@ -19,7 +19,7 @@
19
19
  * The union selector covers both. Light mode is the default.
20
20
  *
21
21
  * Webfonts are @imported here (not in theme.css) so every consumer that reads
22
- * these tokens also gets the faces they name — apps/docs imports this file
22
+ * these tokens also gets the faces they name, apps/docs imports this file
23
23
  * alone. Both families ship in the binary; nothing is fetched from a CDN.
24
24
  */
25
25
 
@@ -30,13 +30,13 @@
30
30
  @import "@fontsource-variable/geist-mono/index.css";
31
31
 
32
32
  :root {
33
- /* Typography — body sans for prose, mono for all chrome and identifiers.
33
+ /* Typography, body sans for prose, mono for all chrome and identifiers.
34
34
  * Geist Mono is variable (100–900), so heading weight 800 is a real cut. */
35
35
  --rw-font-sans: "TASA Orbiter", system-ui, -apple-system, sans-serif;
36
36
  --rw-font-mono:
37
37
  "Geist Mono Variable", "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
38
38
 
39
- /* Primary — Wisp Teal (brand = #0E7C86, the artifact's deep teal). Deeper
39
+ /* Primary, Wisp Teal (brand = #0E7C86, the artifact's deep teal). Deeper
40
40
  * and less cyan than Aurora (running/info) so the two stay distinct. */
41
41
  --rw-color-wisp-50: oklch(0.97 0.02 205);
42
42
  --rw-color-wisp-100: oklch(0.95 0.035 205);
@@ -50,7 +50,7 @@
50
50
  --rw-color-wisp-900: oklch(0.3 0.048 208);
51
51
  --rw-color-wisp-950: oklch(0.2 0.034 210);
52
52
 
53
- /* Accent — Aurora Teal (running status = #13A0A8, a cyan-leaning teal) */
53
+ /* Accent, Aurora Teal (running status = #13A0A8, a cyan-leaning teal) */
54
54
  --rw-color-aurora-50: oklch(0.97 0.02 192);
55
55
  --rw-color-aurora-100: oklch(0.94 0.045 192);
56
56
  --rw-color-aurora-200: oklch(0.88 0.075 190);
@@ -62,7 +62,7 @@
62
62
  --rw-color-aurora-800: oklch(0.35 0.07 192);
63
63
  --rw-color-aurora-900: oklch(0.28 0.055 192);
64
64
 
65
- /* Mist — teal-gray neutrals (the runbook's #F3F6F6 / #E0E7E7 /
65
+ /* Mist, teal-gray neutrals (the runbook's #F3F6F6 / #E0E7E7 /
66
66
  * #14262A / #0C1719 family). No purple tint; hue ~195-205, low chroma,
67
67
  * so bg-mist-* utilities agree with the new teal-black surfaces. The light
68
68
  * end stays near-neutral to match the desaturated canvas. */
@@ -78,7 +78,7 @@
78
78
  --rw-color-mist-900: oklch(0.266 0.025 204); /* ≈ #16292B */
79
79
  --rw-color-mist-950: oklch(0.195 0.016 208); /* ≈ #0C1719 */
80
80
 
81
- /* Success — Field Green (success status = #1F9D57, a true grass green) */
81
+ /* Success, Field Green (success status = #1F9D57, a true grass green) */
82
82
  --rw-color-success-50: oklch(0.97 0.02 150);
83
83
  --rw-color-success-100: oklch(0.94 0.04 150);
84
84
  --rw-color-success-200: oklch(0.88 0.07 150);
@@ -88,7 +88,7 @@
88
88
  --rw-color-success-600: oklch(0.5 0.13 150);
89
89
  --rw-color-success-700: oklch(0.42 0.11 150);
90
90
 
91
- /* Danger — Signal Red (failed status = #D6453D, a warm tomato red) */
91
+ /* Danger, Signal Red (failed status = #D6453D, a warm tomato red) */
92
92
  --rw-color-danger-50: oklch(0.97 0.016 27);
93
93
  --rw-color-danger-100: oklch(0.94 0.03 27);
94
94
  --rw-color-danger-200: oklch(0.88 0.06 27);
@@ -98,7 +98,7 @@
98
98
  --rw-color-danger-600: oklch(0.5 0.17 27);
99
99
  --rw-color-danger-700: oklch(0.42 0.145 27);
100
100
 
101
- /* Warning — Ember Amber (stopped status = #D98324, a warm orange-amber) */
101
+ /* Warning, Ember Amber (stopped status = #D98324, a warm orange-amber) */
102
102
  --rw-color-warning-50: oklch(0.97 0.03 78);
103
103
  --rw-color-warning-100: oklch(0.94 0.07 74);
104
104
  --rw-color-warning-200: oklch(0.9 0.11 72);
@@ -108,7 +108,7 @@
108
108
  --rw-color-warning-600: oklch(0.6 0.14 62);
109
109
  --rw-color-warning-700: oklch(0.52 0.12 60);
110
110
 
111
- /* ANSI console palette — 16-color map for the log console, tuned for
111
+ /* ANSI console palette, 16-color map for the log console, tuned for
112
112
  * its always-dark surface (no .dark override needed). Hues follow the
113
113
  * semantic scales: red→danger, green→success, yellow→warning,
114
114
  * blue→wisp, cyan→aurora, grays→mist. */
@@ -129,11 +129,11 @@
129
129
  --rw-ansi-bright-cyan: var(--rw-color-aurora-300);
130
130
  --rw-ansi-bright-white: var(--rw-color-mist-50);
131
131
 
132
- /* Fixed-dark sibling palettes — the `--rw-con-*` log console (a "black box"
132
+ /* Fixed-dark sibling palettes, the `--rw-con-*` log console (a "black box"
133
133
  * device) and the `--rw-term-*`/`--rw-syn-*` website terminal surfaces both
134
134
  * keep their own dark palette in BOTH themes; they never flip light/dark. */
135
135
 
136
- /* Console device surface — the log console instrument.
136
+ /* Console device surface, the log console instrument.
137
137
  * Exact values from the approved execution-detail design. */
138
138
  --rw-con-bg: #0e141b; /* body / gutter backing */
139
139
  --rw-con-panel: #11181f; /* toolbar + footer chrome */
@@ -141,7 +141,7 @@
141
141
  --rw-con-dim: #6c7d8c; /* footer + muted readouts */
142
142
  --rw-con-gutter: #3c4a58; /* line numbers + sentinel rule */
143
143
 
144
- /* Terminal surface — always dark in both themes (website --term-* set) */
144
+ /* Terminal surface, always dark in both themes (website --term-* set) */
145
145
  --rw-term: #0a1116;
146
146
  --rw-term-2: #0d151b;
147
147
  --rw-term-line: #1d2932;
@@ -161,7 +161,7 @@
161
161
  --rw-syn-punc: #9aa7b0;
162
162
  --rw-syn-fn: #2fd4dd;
163
163
 
164
- /* Elevation — light-mode drop shadows; flipped to `none` in dark so the
164
+ /* Elevation, light-mode drop shadows; flipped to `none` in dark so the
165
165
  * UI reads as borders-only there. */
166
166
  --rw-shadow-xs: 0 1px 2px rgba(16, 41, 43, 0.04);
167
167
  --rw-shadow-sm: 0 1px 2px rgba(16, 41, 43, 0.04), 0 12px 28px -20px rgba(16, 41, 43, 0.22);
@@ -169,7 +169,7 @@
169
169
  --rw-shadow-lg: 0 1px 2px rgba(16, 41, 43, 0.06), 0 30px 60px -28px rgba(16, 41, 43, 0.45);
170
170
  --rw-shadow-xl: 0 1px 2px rgba(16, 41, 43, 0.06), 0 40px 80px -30px rgba(16, 41, 43, 0.5);
171
171
 
172
- /* Light-mode semantic tokens (default) — runbook palette (exact hex).
172
+ /* Light-mode semantic tokens (default), runbook palette (exact hex).
173
173
  *
174
174
  * The canvas carries only a whisper of the brand teal. The website can
175
175
  * afford a minty `--ground` because dark panes and terminals cover most
@@ -223,7 +223,7 @@
223
223
  --rw-warning-soft: rgba(196, 120, 26, 0.1);
224
224
  --rw-warning-soft-text: #c4781a;
225
225
  --rw-warning-soft-border: rgba(196, 120, 26, 0.25);
226
- /* Info — matches primary teal */
226
+ /* Info, matches primary teal */
227
227
  --rw-info: #15a0a8;
228
228
  --rw-info-hover: #0e7c86;
229
229
  --rw-on-info: #ffffff;
@@ -235,7 +235,7 @@
235
235
  --rw-glass-blur: blur(12px);
236
236
  }
237
237
 
238
- /* Dark-mode semantic tokens — `.dark` class for Svelte apps, `[data-theme="dark"]`
238
+ /* Dark-mode semantic tokens, `.dark` class for Svelte apps, `[data-theme="dark"]`
239
239
  * attribute for Starlight. Both selectors apply the same token set. */
240
240
  :root.dark,
241
241
  :root[data-theme="dark"] {
@@ -254,7 +254,7 @@
254
254
  --rw-outline-faint: #1d2932;
255
255
  --rw-ring: #2fd4dd;
256
256
  --rw-backdrop: rgba(4, 10, 12, 0.7);
257
- /* Primary — bright teal in dark, matches info */
257
+ /* Primary, bright teal in dark, matches info */
258
258
  --rw-primary: #2fd4dd;
259
259
  --rw-primary-hover: #5fe3ea;
260
260
  --rw-primary-active: #5fe3ea;
@@ -284,7 +284,7 @@
284
284
  --rw-warning-soft: rgba(224, 165, 46, 0.14);
285
285
  --rw-warning-soft-text: #e0a52e;
286
286
  --rw-warning-soft-border: rgba(224, 165, 46, 0.28);
287
- /* Info — bright teal in dark */
287
+ /* Info, bright teal in dark */
288
288
  --rw-info: #2fd4dd;
289
289
  --rw-info-hover: #5fe3ea;
290
290
  --rw-on-info: #04181a;
@@ -293,7 +293,7 @@
293
293
  --rw-info-soft-border: rgba(47, 212, 221, 0.28);
294
294
  /* Glass */
295
295
  --rw-glass: rgba(8, 15, 17, 0.9);
296
- /* Elevation — borders-only in dark, no drop shadow */
296
+ /* Elevation, borders-only in dark, no drop shadow */
297
297
  --rw-shadow-xs: none;
298
298
  --rw-shadow-sm: none;
299
299
  --rw-shadow-md: none;
@@ -301,7 +301,7 @@
301
301
  --rw-shadow-xl: none;
302
302
  }
303
303
 
304
- /* Platform polish — focus ring, text selection, scrollbar. Lifted from
304
+ /* Platform polish, focus ring, text selection, scrollbar. Lifted from
305
305
  * theme.css so apps/docs (no Tailwind) gets the same treatment as the
306
306
  * Svelte apps. */
307
307