@runwisp/ui 0.7.0 → 0.8.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 (48) hide show
  1. package/package.json +4 -12
  2. package/src/lib/components/Alert.svelte +4 -4
  3. package/src/lib/components/Avatar.svelte +8 -8
  4. package/src/lib/components/Badge.svelte +23 -23
  5. package/src/lib/components/CronInput.svelte +1 -1
  6. package/src/lib/components/Dropdown.svelte +16 -3
  7. package/src/lib/components/DurationInput.svelte +4 -2
  8. package/src/lib/components/EmptyState.svelte +1 -1
  9. package/src/lib/components/FormField.svelte +4 -2
  10. package/src/lib/components/Input.svelte +4 -2
  11. package/src/lib/components/LogConsole.svelte +22 -29
  12. package/src/lib/components/Progress.svelte +4 -4
  13. package/src/lib/components/RadioGroup.svelte +3 -1
  14. package/src/lib/components/Select.svelte +1 -1
  15. package/src/lib/components/Sparkline.svelte +11 -3
  16. package/src/lib/components/TaskCard.svelte +4 -4
  17. package/src/lib/components/Textarea.svelte +2 -2
  18. package/src/lib/components/ToastContainer.svelte +4 -4
  19. package/src/lib/components/Tooltip.svelte +11 -2
  20. package/src/lib/components/dashboard/RunDetailPanel.svelte +24 -17
  21. package/src/lib/components/dashboard/RunsList.svelte +19 -2
  22. package/src/lib/components/dashboard/cron-format.test.ts +67 -0
  23. package/src/lib/components/dashboard/cron-format.ts +71 -0
  24. package/src/lib/components/dashboard/status-config.test.ts +18 -0
  25. package/src/lib/components/dashboard/status-config.ts +19 -0
  26. package/src/lib/index.ts +1 -4
  27. package/src/lib/log-console/ansi.test.ts +50 -0
  28. package/src/lib/log-console/ansi.ts +39 -0
  29. package/src/lib/theme-tokens.css +33 -0
  30. package/src/lib/theme.css +6 -0
  31. package/src/lib/utils/format.test.ts +37 -1
  32. package/src/lib/utils/format.ts +8 -6
  33. package/src/stories/Avatar.stories.svelte +1 -1
  34. package/src/stories/Breadcrumb.stories.svelte +3 -3
  35. package/src/stories/Card.stories.svelte +21 -19
  36. package/src/stories/Checkbox.stories.svelte +2 -2
  37. package/src/stories/DataGrid.stories.svelte +1 -1
  38. package/src/stories/Dropdown.stories.svelte +3 -3
  39. package/src/stories/EmptyState.stories.svelte +1 -1
  40. package/src/stories/LogConsole.stories.svelte +75 -0
  41. package/src/stories/LogConsoleDemo.svelte +60 -0
  42. package/src/stories/Logo.stories.svelte +9 -9
  43. package/src/stories/Modal.stories.svelte +12 -10
  44. package/src/stories/Progress.stories.svelte +6 -6
  45. package/src/stories/Tabs.stories.svelte +16 -12
  46. package/src/stories/Toggle.stories.svelte +2 -2
  47. package/src/stories/Tooltip.stories.svelte +5 -5
  48. package/src/lib/utils/toast.ts +0 -60
@@ -15,6 +15,7 @@
15
15
  import Button from "../Button.svelte";
16
16
  import EmptyState from "../EmptyState.svelte";
17
17
  import LogConsole from "../LogConsole.svelte";
18
+ import Tooltip from "../Tooltip.svelte";
18
19
  import type { Run } from "./types.js";
19
20
  import type { LogEvent, LogSlice } from "../../log-console/types.js";
20
21
  import { formatDateTime } from "../../utils/format.js";
@@ -94,8 +95,8 @@
94
95
  {@const DetailIcon = config.icon}
95
96
  {@const duration = runDuration(run)}
96
97
  <!-- Detailed Header -->
97
- <div class="shrink-0 border-b border-outline-faint bg-surface-raised p-6">
98
- <div class="flex flex-col justify-between gap-6 lg:flex-row lg:items-start">
98
+ <div class="@container shrink-0 border-b border-outline-faint bg-surface-raised p-6">
99
+ <div class="flex flex-col justify-between gap-6 @4xl:flex-row @4xl:items-start">
99
100
  <div class="flex items-start gap-4">
100
101
  <!-- Large Status Icon -->
101
102
  <div
@@ -119,15 +120,17 @@
119
120
  {/if}
120
121
  {/if}
121
122
  </h2>
122
- <Badge
123
- variant={runDisplayStatus(run) === "success"
124
- ? "success"
125
- : run.status === "running"
126
- ? "info"
127
- : "danger"}
128
- >
129
- {runDisplayStatus(run).toUpperCase()}
130
- </Badge>
123
+ <Tooltip content={config.description} position="bottom" wide>
124
+ <Badge
125
+ variant={runDisplayStatus(run) === "success"
126
+ ? "success"
127
+ : run.status === "running"
128
+ ? "info"
129
+ : "danger"}
130
+ >
131
+ {runDisplayStatus(run).toUpperCase()}
132
+ </Badge>
133
+ </Tooltip>
131
134
  {#if canDelete}
132
135
  <Button
133
136
  variant="ghost"
@@ -187,7 +190,7 @@
187
190
 
188
191
  <!-- Stats Grid -->
189
192
  <div
190
- class="grid grid-cols-3 gap-6 rounded-xl border border-outline-faint bg-surface-sunken px-6 py-3"
193
+ class="grid grid-cols-1 gap-3 rounded-xl border border-outline-faint bg-surface-sunken px-6 py-3 @lg:grid-cols-3 @lg:gap-6 @4xl:shrink-0"
191
194
  >
192
195
  <div class="flex flex-col gap-1">
193
196
  <span
@@ -199,7 +202,9 @@
199
202
  </span>
200
203
  </div>
201
204
 
202
- <div class="flex flex-col gap-1 border-l border-outline pl-6">
205
+ <div
206
+ class="flex flex-col gap-1 border-t border-outline pt-3 @lg:border-t-0 @lg:border-l @lg:pt-0 @lg:pl-6"
207
+ >
203
208
  <span
204
209
  class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
205
210
  >Duration</span
@@ -209,7 +214,9 @@
209
214
  </span>
210
215
  </div>
211
216
 
212
- <div class="flex flex-col gap-1 border-l border-outline pl-6">
217
+ <div
218
+ class="flex flex-col gap-1 border-t border-outline pt-3 @lg:border-t-0 @lg:border-l @lg:pt-0 @lg:pl-6"
219
+ >
213
220
  <span
214
221
  class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
215
222
  >Exited</span
@@ -227,9 +234,9 @@
227
234
  </div>
228
235
 
229
236
  <!-- Console View -->
230
- <div class="flex min-h-0 flex-1 flex-col bg-slate-950">
237
+ <div class="flex min-h-0 flex-1 flex-col bg-mist-950">
231
238
  <div
232
- class="flex shrink-0 items-center justify-between border-b border-slate-800 bg-slate-900/50 px-4 py-2 font-mono text-xs text-slate-400"
239
+ class="flex shrink-0 items-center justify-between border-b border-mist-800 bg-mist-900/50 px-4 py-2 font-mono text-xs text-mist-400"
233
240
  >
234
241
  <div class="flex items-center gap-2">
235
242
  <TerminalIcon size={14} />
@@ -254,7 +261,7 @@
254
261
  run.id,
255
262
  )}/log/raw"
256
263
  download="{run.task_name}-{run.id}.log"
257
- class="flex items-center gap-1.5 rounded border border-slate-700 px-2 py-1 text-slate-300 transition-colors hover:border-slate-500 hover:bg-slate-800 hover:text-slate-100"
264
+ class="flex items-center gap-1.5 rounded border border-mist-700 px-2 py-1 text-mist-200 transition-colors hover:border-mist-500 hover:bg-mist-800 hover:text-mist-100"
258
265
  title="Download the full log (rotated and current parts as one file)"
259
266
  >
260
267
  <Download size={12} />
@@ -28,6 +28,7 @@
28
28
  import Button from "../Button.svelte";
29
29
  import Badge from "../Badge.svelte";
30
30
  import EmptyState from "../EmptyState.svelte";
31
+ import Tooltip from "../Tooltip.svelte";
31
32
  import type { Run } from "./types.js";
32
33
  import type { RunSelector } from "@runwisp/common";
33
34
  import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
@@ -50,6 +51,7 @@
50
51
  taskNameFilter,
51
52
  headerLabel = "Run History",
52
53
  emptyText = "No runs yet",
54
+ emptyDescription,
53
55
  bulkActions = false,
54
56
  onBulkCancel,
55
57
  onBulkDelete,
@@ -67,6 +69,7 @@
67
69
  taskNameFilter?: string;
68
70
  headerLabel?: string;
69
71
  emptyText?: string;
72
+ emptyDescription?: string;
70
73
  bulkActions?: boolean;
71
74
  onBulkCancel?: BulkHandler;
72
75
  onBulkDelete?: BulkHandler;
@@ -341,7 +344,13 @@
341
344
 
342
345
  <div bind:this={scrollElement} class="min-h-0 flex-1 overflow-y-auto p-2">
343
346
  {#if items.length === 0 && !loading}
344
- <EmptyState title={emptyText} icon={Clock} iconSize={32} class="py-8" />
347
+ <EmptyState
348
+ title={emptyText}
349
+ description={emptyDescription}
350
+ icon={Clock}
351
+ iconSize={32}
352
+ class="py-8"
353
+ />
345
354
  {:else}
346
355
  <div
347
356
  style:height="{$virtualizer.getTotalSize()}px"
@@ -463,7 +472,15 @@
463
472
 
464
473
  <div class="flex items-center justify-between text-xs">
465
474
  <div class="flex items-center gap-2 text-on-surface-muted">
466
- <span class="capitalize">{runDisplayStatus(run)}</span>
475
+ <Tooltip
476
+ content={config.description}
477
+ position="right"
478
+ wide
479
+ >
480
+ <span class="capitalize"
481
+ >{runDisplayStatus(run)}</span
482
+ >
483
+ </Tooltip>
467
484
  {#if run.instance_index > 0}
468
485
  <span
469
486
  class="font-mono text-2xs text-on-surface-faint"
@@ -0,0 +1,67 @@
1
+ // Copyright (c) PoppyCake, s.r.o. SPDX-License-Identifier: Apache-2.0
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { humanizeCron } from "./cron-format.js";
5
+
6
+ describe("humanizeCron", () => {
7
+ it("humanizes a standard 5-field expression", () => {
8
+ const result = humanizeCron("*/5 * * * *");
9
+ expect(result.humanized).toBe("Every 5 minutes");
10
+ expect(result.raw).toBe("*/5 * * * *");
11
+ expect(result.isHumanized).toBe(true);
12
+ });
13
+
14
+ it("humanizes a daily expression", () => {
15
+ const result = humanizeCron("0 3 * * *");
16
+ expect(result.humanized).toBe("At 03:00 AM");
17
+ expect(result.isHumanized).toBe(true);
18
+ });
19
+
20
+ it("humanizes @hourly via cronstrue", () => {
21
+ const result = humanizeCron("@hourly");
22
+ expect(result.humanized).toBe("Every hour");
23
+ expect(result.isHumanized).toBe(true);
24
+ });
25
+
26
+ it("handles robfig's @every with a compound duration", () => {
27
+ const result = humanizeCron("@every 1h30m");
28
+ expect(result.humanized).toBe("Every 1 hour 30 minutes");
29
+ expect(result.raw).toBe("@every 1h30m");
30
+ expect(result.isHumanized).toBe(true);
31
+ });
32
+
33
+ it("handles @every with a single unit", () => {
34
+ expect(humanizeCron("@every 10s").humanized).toBe("Every 10 seconds");
35
+ expect(humanizeCron("@every 1m").humanized).toBe("Every 1 minute");
36
+ expect(humanizeCron("@every 2h").humanized).toBe("Every 2 hours");
37
+ });
38
+
39
+ it("skips zero-valued duration segments", () => {
40
+ expect(humanizeCron("@every 1h0m").humanized).toBe("Every 1 hour");
41
+ });
42
+
43
+ it("falls back to raw for @every with sub-second units", () => {
44
+ const result = humanizeCron("@every 500ms");
45
+ expect(result.humanized).toBe("@every 500ms");
46
+ expect(result.isHumanized).toBe(false);
47
+ });
48
+
49
+ it("falls back to raw for @every with a garbage duration", () => {
50
+ const result = humanizeCron("@every banana");
51
+ expect(result.humanized).toBe("@every banana");
52
+ expect(result.isHumanized).toBe(false);
53
+ });
54
+
55
+ it("falls back to raw for unparseable expressions — never 'Invalid'", () => {
56
+ const result = humanizeCron("not a cron");
57
+ expect(result.humanized).toBe("not a cron");
58
+ expect(result.isHumanized).toBe(false);
59
+ expect(result.humanized).not.toMatch(/invalid/i);
60
+ });
61
+
62
+ it("trims surrounding whitespace", () => {
63
+ const result = humanizeCron(" */5 * * * * ");
64
+ expect(result.raw).toBe("*/5 * * * *");
65
+ expect(result.isHumanized).toBe(true);
66
+ });
67
+ });
@@ -0,0 +1,71 @@
1
+ // Copyright (c) PoppyCake, s.r.o. SPDX-License-Identifier: Apache-2.0
2
+
3
+ import cronstrue from "cronstrue";
4
+
5
+ export interface HumanizedCron {
6
+ /** Display text — humanized when possible, otherwise the raw expression. */
7
+ humanized: string;
8
+ /** The original cron expression, for tooltips. */
9
+ raw: string;
10
+ /** False when humanizing failed and `humanized` is just `raw`. */
11
+ isHumanized: boolean;
12
+ }
13
+
14
+ const EVERY_RE = /^@every\s+(\S+)$/;
15
+
16
+ const UNIT_NAMES: Record<string, [string, string]> = {
17
+ h: ["hour", "hours"],
18
+ m: ["minute", "minutes"],
19
+ s: ["second", "seconds"],
20
+ };
21
+
22
+ // humanizeEvery turns robfig's `@every 1h30m` (Go duration syntax) into
23
+ // "Every 1 hour 30 minutes". Returns null when the duration doesn't parse.
24
+ function humanizeEvery(duration: string): string | null {
25
+ const parts = [...duration.matchAll(/(\d+(?:\.\d+)?)(h|m|s|ms|us|µs|ns)/g)];
26
+ if (parts.length === 0 || parts.map((p) => p[0]).join("") !== duration) {
27
+ return null;
28
+ }
29
+
30
+ const words: string[] = [];
31
+ for (const [, amount, unit] of parts) {
32
+ if (!amount || !unit) return null;
33
+ const value = Number(amount);
34
+ if (value === 0) continue;
35
+ const names = UNIT_NAMES[unit];
36
+ if (!names) {
37
+ // Sub-second units are valid Go durations but absurd as schedules;
38
+ // show the raw expression rather than "Every 500 milliseconds".
39
+ return null;
40
+ }
41
+ words.push(`${amount} ${value === 1 ? names[0] : names[1]}`);
42
+ }
43
+ if (words.length === 0) return null;
44
+
45
+ return `Every ${words.join(" ")}`;
46
+ }
47
+
48
+ /**
49
+ * humanizeCron renders a cron expression as plain English ("Every 5 minutes").
50
+ * Handles robfig's `@every <duration>` extension (cronstrue throws on it) and
51
+ * falls back to the raw expression on anything unparseable — never "Invalid".
52
+ */
53
+ export function humanizeCron(cron: string): HumanizedCron {
54
+ const raw = cron.trim();
55
+
56
+ const every = EVERY_RE.exec(raw);
57
+ if (every?.[1]) {
58
+ const humanized = humanizeEvery(every[1]);
59
+ if (humanized) {
60
+ return { humanized, raw, isHumanized: true };
61
+ }
62
+ return { humanized: raw, raw, isHumanized: false };
63
+ }
64
+
65
+ try {
66
+ const humanized = cronstrue.toString(raw, { throwExceptionOnParseError: true });
67
+ return { humanized, raw, isHumanized: true };
68
+ } catch {
69
+ return { humanized: raw, raw, isHumanized: false };
70
+ }
71
+ }
@@ -0,0 +1,18 @@
1
+ // Copyright (c) PoppyCake, s.r.o. SPDX-License-Identifier: Apache-2.0
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { RUN_STATUSES } from "@runwisp/common";
5
+ import { RUN_STATUS_CONFIG } from "./status-config.js";
6
+
7
+ describe("RUN_STATUS_CONFIG", () => {
8
+ it("has a non-empty description for every run status", () => {
9
+ for (const status of RUN_STATUSES) {
10
+ const config = RUN_STATUS_CONFIG[status];
11
+ expect(config, `missing config for status "${status}"`).toBeDefined();
12
+ expect(
13
+ config.description.trim().length,
14
+ `empty description for status "${status}"`,
15
+ ).toBeGreaterThan(0);
16
+ }
17
+ });
18
+ });
@@ -22,6 +22,8 @@ export interface RunStatusConfig {
22
22
  border: string;
23
23
  dot: string;
24
24
  badge: string;
25
+ /** One-sentence explanation of what this status means, for tooltips. */
26
+ description: string;
25
27
  }
26
28
 
27
29
  export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
@@ -32,6 +34,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
32
34
  border: "border-info-soft",
33
35
  dot: "bg-info-surface animate-pulse",
34
36
  badge: "bg-info-soft text-info-soft-text",
37
+ description: "This run is executing right now.",
35
38
  },
36
39
  success: {
37
40
  icon: CircleCheck,
@@ -40,6 +43,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
40
43
  border: "border-success-soft",
41
44
  dot: "bg-success-surface",
42
45
  badge: "bg-success-soft text-success-soft-text",
46
+ description: "The run finished with exit code 0.",
43
47
  },
44
48
  failed: {
45
49
  icon: CircleX,
@@ -48,6 +52,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
48
52
  border: "border-danger-soft",
49
53
  dot: "bg-danger-surface",
50
54
  badge: "bg-danger-soft text-danger-soft-text",
55
+ description: "The run exited with a non-zero code.",
51
56
  },
52
57
  crashed: {
53
58
  icon: CircleAlert,
@@ -56,6 +61,8 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
56
61
  border: "border-danger-soft",
57
62
  dot: "bg-danger-surface",
58
63
  badge: "bg-danger-soft text-danger-soft-text",
64
+ description:
65
+ "The process was killed, or the daemon found it still 'running' after a hard crash and marked it crashed (exit -2). It was not resumed.",
59
66
  },
60
67
  stopped: {
61
68
  icon: CircleStop,
@@ -64,6 +71,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
64
71
  border: "border-warning-soft",
65
72
  dot: "bg-warning-surface",
66
73
  badge: "bg-warning-soft text-warning-soft-text",
74
+ description: "An operator stopped this run before it finished.",
67
75
  },
68
76
  timeout: {
69
77
  icon: TimerOff,
@@ -72,6 +80,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
72
80
  border: "border-warning-soft",
73
81
  dot: "bg-warning-surface",
74
82
  badge: "bg-warning-soft text-warning-soft-text",
83
+ description: "The run exceeded its configured timeout and was terminated.",
75
84
  },
76
85
  skipped: {
77
86
  icon: SkipForward,
@@ -80,6 +89,8 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
80
89
  border: "border-outline-faint",
81
90
  dot: "bg-on-surface-faint",
82
91
  badge: "bg-surface-sunken text-on-surface",
92
+ description:
93
+ 'Skipped by the concurrency policy (on_overlap = "skip") because a previous run was still going.',
83
94
  },
84
95
  log_overflow: {
85
96
  icon: FileExclamationPoint,
@@ -88,6 +99,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
88
99
  border: "border-danger-soft",
89
100
  dot: "bg-danger-surface",
90
101
  badge: "bg-danger-soft text-danger-soft-text",
102
+ description: "The run hit log_max_size and was handled per log_on_full.",
91
103
  },
92
104
  queue_full: {
93
105
  icon: SkipForward,
@@ -96,6 +108,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
96
108
  border: "border-warning-soft",
97
109
  dot: "bg-warning-surface",
98
110
  badge: "bg-warning-soft text-warning-soft-text",
111
+ description: "Skipped because the task's queue was already at queue_max.",
99
112
  },
100
113
  dst_skipped: {
101
114
  icon: SkipForward,
@@ -104,6 +117,8 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
104
117
  border: "border-outline-faint",
105
118
  dot: "bg-on-surface-faint",
106
119
  badge: "bg-surface-sunken text-on-surface",
120
+ description:
121
+ "Skipped: this cron tick was the duplicate half of a DST fall-back, so it was recorded but not run.",
107
122
  },
108
123
  daemon_stopped: {
109
124
  icon: CircleStop,
@@ -112,6 +127,8 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
112
127
  border: "border-warning-soft",
113
128
  dot: "bg-warning-surface",
114
129
  badge: "bg-warning-soft text-warning-soft-text",
130
+ description:
131
+ "The daemon shut down while this run was in flight and it exceeded shutdown_timeout; it was not resumed.",
115
132
  },
116
133
  pending: {
117
134
  icon: Clock,
@@ -120,6 +137,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
120
137
  border: "border-outline",
121
138
  dot: "bg-on-surface-faint",
122
139
  badge: "bg-surface-sunken text-on-surface",
140
+ description: "Queued and waiting to start.",
123
141
  },
124
142
  ended: {
125
143
  icon: CircleDashed,
@@ -128,6 +146,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
128
146
  border: "border-outline-faint",
129
147
  dot: "bg-on-surface-faint",
130
148
  badge: "bg-surface-sunken text-on-surface",
149
+ description: "The run finished. No specific end reason was recorded.",
131
150
  },
132
151
  };
133
152
 
package/src/lib/index.ts CHANGED
@@ -43,8 +43,6 @@ export { default as AccordionItem } from "./components/AccordionItem.svelte";
43
43
  export { default as Breadcrumb } from "./components/Breadcrumb.svelte";
44
44
  export { default as Pagination } from "./components/Pagination.svelte";
45
45
  export { default as Tabs } from "./components/Tabs.svelte";
46
- export { default as ThemeToggle } from "./components/ThemeToggle.svelte";
47
-
48
46
  export { default as Avatar } from "./components/Avatar.svelte";
49
47
  export { default as Badge } from "./components/Badge.svelte";
50
48
  export { default as Card } from "./components/Card.svelte";
@@ -71,8 +69,6 @@ export { default as ToastContainer } from "./components/ToastContainer.svelte";
71
69
  export { default as Tooltip } from "./components/Tooltip.svelte";
72
70
 
73
71
  export { default as CodeBlock } from "./components/CodeBlock.svelte";
74
- export { default as FeatureCard } from "./components/FeatureCard.svelte";
75
- export { default as FeatureGrid } from "./components/FeatureGrid.svelte";
76
72
  export { default as TaskCard } from "./components/TaskCard.svelte";
77
73
  export type { TaskCardAccent } from "./components/TaskCard.svelte";
78
74
 
@@ -95,6 +91,7 @@ export {
95
91
  type RunStatusConfig,
96
92
  } from "./components/dashboard/status-config.js";
97
93
  export { runDuration, runPhaseOrder } from "./components/dashboard/run-helpers.js";
94
+ export { humanizeCron, type HumanizedCron } from "./components/dashboard/cron-format.js";
98
95
  export type { DaemonState, DaemonStats } from "./components/dashboard/types.js";
99
96
 
100
97
  export { toast, type Toast, type ToastType } from "./utils/toast.svelte.js";
@@ -0,0 +1,50 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { describe, expect, it } from "vitest";
5
+ import { ansiLineToHtml } from "./ansi.js";
6
+
7
+ describe("ansiLineToHtml", () => {
8
+ it("passes plain text through unchanged", () => {
9
+ expect(ansiLineToHtml("hello world")).toBe("hello world");
10
+ });
11
+
12
+ it("maps standard foreground codes to theme tokens", () => {
13
+ expect(ansiLineToHtml("\x1b[31merror\x1b[0m")).toBe(
14
+ '<span style="color:var(--rw-ansi-red)">error</span>',
15
+ );
16
+ expect(ansiLineToHtml("\x1b[32mok\x1b[0m")).toBe(
17
+ '<span style="color:var(--rw-ansi-green)">ok</span>',
18
+ );
19
+ expect(ansiLineToHtml("\x1b[36minfo\x1b[0m")).toBe(
20
+ '<span style="color:var(--rw-ansi-cyan)">info</span>',
21
+ );
22
+ });
23
+
24
+ it("maps bright foreground codes to bright theme tokens", () => {
25
+ expect(ansiLineToHtml("\x1b[91mwarn\x1b[0m")).toBe(
26
+ '<span style="color:var(--rw-ansi-bright-red)">warn</span>',
27
+ );
28
+ expect(ansiLineToHtml("\x1b[97mtext\x1b[0m")).toBe(
29
+ '<span style="color:var(--rw-ansi-bright-white)">text</span>',
30
+ );
31
+ });
32
+
33
+ it("maps background codes to theme tokens", () => {
34
+ expect(ansiLineToHtml("\x1b[41mfail\x1b[0m")).toBe(
35
+ '<span style="background-color:var(--rw-ansi-red)">fail</span>',
36
+ );
37
+ });
38
+
39
+ it("escapes HTML in log content", () => {
40
+ expect(ansiLineToHtml("<script>alert(1)</script>")).toBe(
41
+ "&lt;script&gt;alert(1)&lt;/script&gt;",
42
+ );
43
+ });
44
+
45
+ it("does not leak colour state between calls", () => {
46
+ // An unterminated colour in one line must not affect the next.
47
+ ansiLineToHtml("\x1b[31munclosed red");
48
+ expect(ansiLineToHtml("plain")).toBe("plain");
49
+ });
50
+ });
@@ -0,0 +1,39 @@
1
+ // SPDX-FileCopyrightText: PoppyCake, s.r.o.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import Convert from "ansi-to-html";
5
+
6
+ // Theme-derived 16-color ANSI palette. ansi-to-html inlines these values
7
+ // verbatim into style attributes, so CSS var() references resolve against
8
+ // the --rw-ansi-* tokens in theme-tokens.css. Codes 16-255 keep the
9
+ // library's computed RGB defaults.
10
+ const ANSI_PALETTE: Record<number, string> = Object.freeze({
11
+ 0: "var(--rw-ansi-black)",
12
+ 1: "var(--rw-ansi-red)",
13
+ 2: "var(--rw-ansi-green)",
14
+ 3: "var(--rw-ansi-yellow)",
15
+ 4: "var(--rw-ansi-blue)",
16
+ 5: "var(--rw-ansi-magenta)",
17
+ 6: "var(--rw-ansi-cyan)",
18
+ 7: "var(--rw-ansi-white)",
19
+ 8: "var(--rw-ansi-bright-black)",
20
+ 9: "var(--rw-ansi-bright-red)",
21
+ 10: "var(--rw-ansi-bright-green)",
22
+ 11: "var(--rw-ansi-bright-yellow)",
23
+ 12: "var(--rw-ansi-bright-blue)",
24
+ 13: "var(--rw-ansi-bright-magenta)",
25
+ 14: "var(--rw-ansi-bright-cyan)",
26
+ 15: "var(--rw-ansi-bright-white)",
27
+ });
28
+
29
+ // Convert ANSI escape sequences in a single log line to safe HTML spans.
30
+ // A fresh converter per call ensures no colour state leaks between
31
+ // independently virtualised lines.
32
+ export function ansiLineToHtml(text: string): string {
33
+ return new Convert({
34
+ escapeXML: true,
35
+ fg: "var(--rw-color-mist-200)",
36
+ bg: "var(--rw-color-mist-950)",
37
+ colors: ANSI_PALETTE,
38
+ }).toHtml(text);
39
+ }
@@ -88,6 +88,27 @@
88
88
  --rw-color-warning-600: oklch(0.65 0.14 60);
89
89
  --rw-color-warning-700: oklch(0.55 0.12 55);
90
90
 
91
+ /* ANSI console palette — 16-color map for the log console, tuned for
92
+ * its always-dark surface (no .dark override needed). Hues follow the
93
+ * semantic scales: red→danger, green→success, yellow→warning,
94
+ * blue→wisp, cyan→aurora, grays→mist. */
95
+ --rw-ansi-black: var(--rw-color-mist-700);
96
+ --rw-ansi-red: var(--rw-color-danger-400);
97
+ --rw-ansi-green: var(--rw-color-success-400);
98
+ --rw-ansi-yellow: var(--rw-color-warning-400);
99
+ --rw-ansi-blue: var(--rw-color-wisp-400);
100
+ --rw-ansi-magenta: oklch(0.68 0.14 320); /* between wisp and danger hues */
101
+ --rw-ansi-cyan: var(--rw-color-aurora-400);
102
+ --rw-ansi-white: var(--rw-color-mist-300);
103
+ --rw-ansi-bright-black: var(--rw-color-mist-500);
104
+ --rw-ansi-bright-red: var(--rw-color-danger-300);
105
+ --rw-ansi-bright-green: var(--rw-color-success-300);
106
+ --rw-ansi-bright-yellow: var(--rw-color-warning-300);
107
+ --rw-ansi-bright-blue: var(--rw-color-wisp-300);
108
+ --rw-ansi-bright-magenta: oklch(0.78 0.1 320);
109
+ --rw-ansi-bright-cyan: var(--rw-color-aurora-300);
110
+ --rw-ansi-bright-white: var(--rw-color-mist-50);
111
+
91
112
  /* Light-mode semantic tokens (default) */
92
113
  /* Surfaces */
93
114
  --rw-surface: var(--rw-color-mist-50);
@@ -136,6 +157,12 @@
136
157
  --rw-on-info: white;
137
158
  --rw-info-soft: var(--rw-color-aurora-50);
138
159
  --rw-info-soft-text: var(--rw-color-aurora-700);
160
+ /* Soft callout borders — sit on the matching *-soft surface */
161
+ --rw-primary-soft-border: var(--rw-color-wisp-200);
162
+ --rw-danger-soft-border: var(--rw-color-danger-200);
163
+ --rw-success-soft-border: var(--rw-color-success-200);
164
+ --rw-warning-soft-border: var(--rw-color-warning-200);
165
+ --rw-info-soft-border: var(--rw-color-aurora-200);
139
166
  /* Glass */
140
167
  --rw-glass: oklch(1 0 0 / 0.8);
141
168
  --rw-glass-blur: blur(12px);
@@ -189,6 +216,12 @@
189
216
  --rw-on-info: white;
190
217
  --rw-info-soft: oklch(0.2 0.04 180);
191
218
  --rw-info-soft-text: var(--rw-color-aurora-300);
219
+ /* Soft callout borders */
220
+ --rw-primary-soft-border: oklch(0.32 0.06 270);
221
+ --rw-danger-soft-border: oklch(0.32 0.06 355);
222
+ --rw-success-soft-border: oklch(0.32 0.06 165);
223
+ --rw-warning-soft-border: oklch(0.32 0.06 85);
224
+ --rw-info-soft-border: oklch(0.32 0.06 180);
192
225
  /* Glass */
193
226
  --rw-glass: oklch(0.12 0.012 280 / 0.9);
194
227
  }
package/src/lib/theme.css CHANGED
@@ -201,6 +201,12 @@
201
201
  --color-on-info: var(--rw-on-info);
202
202
  --color-info-soft: var(--rw-info-soft);
203
203
  --color-info-soft-text: var(--rw-info-soft-text);
204
+ /* Soft callout borders */
205
+ --color-primary-soft-border: var(--rw-primary-soft-border);
206
+ --color-danger-soft-border: var(--rw-danger-soft-border);
207
+ --color-success-soft-border: var(--rw-success-soft-border);
208
+ --color-warning-soft-border: var(--rw-warning-soft-border);
209
+ --color-info-soft-border: var(--rw-info-soft-border);
204
210
 
205
211
  /* Transition durations */
206
212
  --duration-fast: 100ms;
@@ -2,7 +2,12 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import { describe, expect, it } from "vitest";
5
- import { formatBytes, formatDuration, formatRelativeTimeWithAbsolute } from "./format.js";
5
+ import {
6
+ formatBytes,
7
+ formatDuration,
8
+ formatRelativeTime,
9
+ formatRelativeTimeWithAbsolute,
10
+ } from "./format.js";
6
11
 
7
12
  describe("formatBytes", () => {
8
13
  it("formats 0 bytes", () => {
@@ -85,4 +90,35 @@ describe("formatRelativeTimeWithAbsolute", () => {
85
90
  expect(r).toMatch(/\([^)]*\d[^)]*\)$/);
86
91
  expect(r).not.toMatch(/\(\d{1,2}:\d{2}\)$/);
87
92
  });
93
+ it("computes the relative part against the supplied now", () => {
94
+ const date = new Date("2024-01-01T12:00:00Z");
95
+ const twoHoursBefore = formatRelativeTimeWithAbsolute(
96
+ date,
97
+ new Date("2024-01-01T10:00:00Z"),
98
+ );
99
+ const oneHourBefore = formatRelativeTimeWithAbsolute(
100
+ date,
101
+ new Date("2024-01-01T11:00:00Z"),
102
+ );
103
+ expect(twoHoursBefore).toMatch(/^in 2 hours/);
104
+ expect(oneHourBefore).toMatch(/^in 1 hour/);
105
+ });
106
+ it("picks the day-boundary branch against the supplied now", () => {
107
+ const date = new Date("2024-01-01T12:00:00Z");
108
+ // 2 days after the date → calendar-date suffix, not HH:MM.
109
+ const r = formatRelativeTimeWithAbsolute(date, new Date("2024-01-03T12:00:00Z"));
110
+ expect(r).not.toMatch(/\(\d{1,2}:\d{2}\)$/);
111
+ });
112
+ });
113
+
114
+ describe("formatRelativeTime", () => {
115
+ it("advances as the supplied now advances", () => {
116
+ const date = new Date("2024-01-01T12:00:00Z");
117
+ expect(formatRelativeTime(date, new Date("2024-01-01T14:00:00Z"))).toBe(
118
+ "about 2 hours ago",
119
+ );
120
+ expect(formatRelativeTime(date, new Date("2024-01-01T15:00:00Z"))).toBe(
121
+ "about 3 hours ago",
122
+ );
123
+ });
88
124
  });