@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.
- package/package.json +4 -5
- package/src/lib/actions/floating.ts +73 -0
- package/src/lib/actions/focusTrap.ts +3 -3
- package/src/lib/actions/portal.ts +5 -1
- package/src/lib/components/Accordion.svelte +6 -2
- package/src/lib/components/AccordionItem.svelte +80 -23
- package/src/lib/components/Avatar.svelte +0 -1
- package/src/lib/components/Badge.svelte +1 -1
- package/src/lib/components/CodeBlock.svelte +84 -35
- package/src/lib/components/CronInput.svelte +5 -2
- package/src/lib/components/DataGrid.svelte +100 -138
- package/src/lib/components/Drawer.svelte +2 -6
- package/src/lib/components/Dropdown.svelte +19 -57
- package/src/lib/components/EmptyState.svelte +2 -1
- package/src/lib/components/ErrorState.svelte +2 -1
- package/src/lib/components/FilterBar.svelte +1 -1
- package/src/lib/components/FilterPill.svelte +2 -2
- package/src/lib/components/FormField.svelte +1 -1
- package/src/lib/components/Heading.svelte +19 -12
- package/src/lib/components/Kbd.svelte +25 -10
- package/src/lib/components/Link.svelte +7 -1
- package/src/lib/components/LogConsole.svelte +40 -42
- package/src/lib/components/Modal.svelte +44 -33
- package/src/lib/components/NotificationBell.svelte +2 -1
- package/src/lib/components/PageContainer.svelte +1 -10
- package/src/lib/components/PageHeader.svelte +8 -4
- package/src/lib/components/Popover.svelte +9 -51
- package/src/lib/components/Progress.svelte +19 -6
- package/src/lib/components/Radio.svelte +16 -4
- package/src/lib/components/RadioGroup.svelte +15 -2
- package/src/lib/components/SearchInput.svelte +1 -1
- package/src/lib/components/Select.svelte +16 -47
- package/src/lib/components/Sparkline.svelte +2 -2
- package/src/lib/components/StatusDot.svelte +1 -1
- package/src/lib/components/Table.svelte +13 -4
- package/src/lib/components/TableBody.svelte +2 -1
- package/src/lib/components/TableCell.svelte +38 -8
- package/src/lib/components/TableHead.svelte +12 -4
- package/src/lib/components/TableRow.svelte +23 -3
- package/src/lib/components/TaskCard.svelte +1 -1
- package/src/lib/components/TimezoneSelect.svelte +2 -2
- package/src/lib/components/Tooltip.svelte +1 -1
- package/src/lib/components/accordion.svelte.ts +19 -0
- package/src/lib/components/dashboard/RunDetailPanel.svelte +106 -79
- package/src/lib/components/dashboard/RunFilterPopover.svelte +12 -21
- package/src/lib/components/dashboard/RunsList.svelte +17 -26
- package/src/lib/components/dashboard/cron-format.test.ts +1 -1
- package/src/lib/components/dashboard/cron-format.ts +3 -3
- package/src/lib/components/dashboard/run-filters.test.ts +4 -60
- package/src/lib/components/dashboard/run-filters.ts +29 -144
- package/src/lib/components/dashboard/run-helpers.test.ts +25 -6
- package/src/lib/components/dashboard/run-helpers.ts +40 -39
- package/src/lib/components/dashboard/status-config.ts +38 -83
- package/src/lib/components/dashboard/types.ts +0 -26
- package/src/lib/components/data-grid-selection.ts +2 -9
- package/src/lib/components/filter-spec.ts +3 -3
- package/src/lib/components/radio-group.ts +11 -0
- package/src/lib/index.ts +8 -43
- package/src/lib/log-console/LogCache.svelte.ts +1 -1
- package/src/lib/log-console/LogFetcher.svelte.ts +3 -14
- package/src/lib/log-console/ansi.ts +1 -1
- package/src/lib/log-console/highlight-scroll.test.ts +59 -0
- package/src/lib/log-console/highlight-scroll.ts +41 -0
- package/src/lib/theme-tokens.css +21 -21
- package/src/lib/theme.css +22 -16
- package/src/lib/utils/clipboard.svelte.test.ts +92 -0
- package/src/lib/utils/clipboard.svelte.ts +73 -0
- package/src/lib/utils/format.test.ts +3 -3
- package/src/lib/utils/format.ts +65 -94
- package/src/lib/utils/id.test.ts +3 -7
- package/src/lib/utils/id.ts +3 -2
- package/src/lib/utils/theme.svelte.test.ts +0 -6
- package/src/lib/utils/theme.svelte.ts +3 -5
- package/src/lib/utils/toast.svelte.ts +5 -14
- package/src/stories/Accordion.stories.svelte +61 -0
- package/src/stories/CodeBlock.stories.svelte +55 -0
- package/src/stories/DataGrid.stories.svelte +0 -5
- package/src/stories/Heading.stories.svelte +31 -0
- package/src/stories/Kbd.stories.svelte +36 -0
- package/src/stories/Link.stories.svelte +38 -0
- package/src/stories/LogConsole.stories.svelte +2 -2
- package/src/stories/PageHeader.stories.svelte +31 -0
- package/src/stories/Progress.stories.svelte +26 -0
- package/src/stories/RadioGroup.stories.svelte +43 -0
- package/src/stories/Table.stories.svelte +69 -0
- package/src/svelte-shims.d.ts +0 -5
- package/src/lib/components/dashboard/status-config.test.ts +0 -18
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { createHighlightScroll } from "./highlight-scroll.js";
|
|
6
|
+
|
|
7
|
+
// `sync` is what LogConsole calls inside its $effect. The effect re-runs it
|
|
8
|
+
// whenever something it READ changes, so "no layout reads after the reveal"
|
|
9
|
+
// (ready not consulted again) is exactly "layout changes can't re-scroll".
|
|
10
|
+
function setup(initial: { line: number | null; ready: boolean }) {
|
|
11
|
+
const state = { ...initial };
|
|
12
|
+
const ready = vi.fn(() => state.ready);
|
|
13
|
+
const reveal = vi.fn();
|
|
14
|
+
const clear = vi.fn();
|
|
15
|
+
const sync = createHighlightScroll({ line: () => state.line, ready, reveal, clear });
|
|
16
|
+
return { state, ready, reveal, clear, sync };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("createHighlightScroll", () => {
|
|
20
|
+
it("reveals once and stops reading layout afterwards", () => {
|
|
21
|
+
const { ready, reveal, sync } = setup({ line: 5, ready: true });
|
|
22
|
+
sync();
|
|
23
|
+
expect(reveal).toHaveBeenCalledExactlyOnceWith(5);
|
|
24
|
+
expect(ready).toHaveBeenCalledTimes(1);
|
|
25
|
+
|
|
26
|
+
// A running run appends lines or the pane resizes: the effect re-runs,
|
|
27
|
+
// but must neither scroll again nor re-read the layout.
|
|
28
|
+
sync();
|
|
29
|
+
sync();
|
|
30
|
+
expect(reveal).toHaveBeenCalledTimes(1);
|
|
31
|
+
expect(ready).toHaveBeenCalledTimes(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("waits until the line can be placed, then reveals once", () => {
|
|
35
|
+
const { state, reveal, sync } = setup({ line: 5, ready: false });
|
|
36
|
+
sync();
|
|
37
|
+
expect(reveal).not.toHaveBeenCalled();
|
|
38
|
+
state.ready = true;
|
|
39
|
+
sync();
|
|
40
|
+
sync();
|
|
41
|
+
expect(reveal).toHaveBeenCalledExactlyOnceWith(5);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("reveals again for a new line and after the highlight is cleared", () => {
|
|
45
|
+
const { state, reveal, clear, sync } = setup({ line: 5, ready: true });
|
|
46
|
+
sync();
|
|
47
|
+
state.line = 9;
|
|
48
|
+
sync();
|
|
49
|
+
expect(reveal).toHaveBeenLastCalledWith(9);
|
|
50
|
+
|
|
51
|
+
state.line = null;
|
|
52
|
+
sync();
|
|
53
|
+
expect(clear).toHaveBeenCalledOnce();
|
|
54
|
+
|
|
55
|
+
state.line = 9;
|
|
56
|
+
sync();
|
|
57
|
+
expect(reveal).toHaveBeenCalledTimes(3);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { untrack } from "svelte";
|
|
5
|
+
|
|
6
|
+
export interface HighlightScrollOptions {
|
|
7
|
+
/** Reactive getter for the line to reveal; null/undefined means none. */
|
|
8
|
+
line: () => number | null | undefined;
|
|
9
|
+
/** Reactive getter: true once the layout can place `line` (container measured, line exists). */
|
|
10
|
+
ready: (line: number) => boolean;
|
|
11
|
+
/** Scrolls to `line`. Runs untracked, so layout reads here never re-trigger the sync. */
|
|
12
|
+
reveal: (line: number) => void;
|
|
13
|
+
/** Called when the highlight is cleared. */
|
|
14
|
+
clear: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Returns a function to call from inside an `$effect`. It reveals each
|
|
19
|
+
* highlighted line exactly once: after the first reveal, layout changes (new
|
|
20
|
+
* log lines, resizes) no longer re-run the scroll, so the operator can scroll
|
|
21
|
+
* away from a deep-linked search hit and stay there. It waits until `ready`,
|
|
22
|
+
* and a changed or re-set `line` reveals again.
|
|
23
|
+
*/
|
|
24
|
+
export function createHighlightScroll(options: HighlightScrollOptions): () => void {
|
|
25
|
+
let revealed: number | null = null;
|
|
26
|
+
return () => {
|
|
27
|
+
const target = options.line();
|
|
28
|
+
// Not `!target`: line 0 is a valid highlight.
|
|
29
|
+
if (typeof target !== "number") {
|
|
30
|
+
revealed = null;
|
|
31
|
+
options.clear();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// Checked before `ready` so a revealed line leaves no layout dependency behind.
|
|
35
|
+
if (revealed === target || !options.ready(target)) return;
|
|
36
|
+
revealed = target;
|
|
37
|
+
untrack(() => {
|
|
38
|
+
options.reveal(target);
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
}
|
package/src/lib/theme-tokens.css
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/src/lib/theme.css
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Copyright (c) PoppyCake, s.r.o. SPDX-License-Identifier: Apache-2.0 */
|
|
2
2
|
|
|
3
|
-
/* RunWisp Design System
|
|
3
|
+
/* RunWisp Design System, Tailwind v4 entrypoint.
|
|
4
4
|
*
|
|
5
5
|
* Raw OKLch palette + semantic tokens live in ./theme-tokens.css (plain CSS),
|
|
6
6
|
* so apps/docs (Starlight, no Tailwind pipeline) can consume the same source.
|
|
@@ -19,7 +19,11 @@
|
|
|
19
19
|
@plugin "@tailwindcss/typography";
|
|
20
20
|
|
|
21
21
|
@theme {
|
|
22
|
-
/*
|
|
22
|
+
/* Wide enough for the sidebar, run list and run detail to stay side by
|
|
23
|
+
* side for good; below it the operator can fold the first two away. */
|
|
24
|
+
--breakpoint-3xl: 100rem;
|
|
25
|
+
|
|
26
|
+
/* Palette aliases, Tailwind utilities (bg-wisp-500, text-mist-900, …)
|
|
23
27
|
* resolve through to --rw-color-* declared in theme-tokens.css. */
|
|
24
28
|
--color-wisp-50: var(--rw-color-wisp-50);
|
|
25
29
|
--color-wisp-100: var(--rw-color-wisp-100);
|
|
@@ -83,7 +87,7 @@
|
|
|
83
87
|
--color-warning-600: var(--rw-color-warning-600);
|
|
84
88
|
--color-warning-700: var(--rw-color-warning-700);
|
|
85
89
|
|
|
86
|
-
/* Typography
|
|
90
|
+
/* Typography, stacks and @font-face live in theme-tokens.css so the
|
|
87
91
|
* non-Tailwind consumers (apps/docs) share one definition. */
|
|
88
92
|
--font-sans: var(--rw-font-sans);
|
|
89
93
|
--font-mono: var(--rw-font-mono);
|
|
@@ -122,18 +126,18 @@
|
|
|
122
126
|
--spacing-20: 5rem;
|
|
123
127
|
--spacing-24: 6rem;
|
|
124
128
|
|
|
125
|
-
/* Border radius
|
|
129
|
+
/* Border radius, runbook scale (small radii are signature). lg/xl/2xl/3xl
|
|
126
130
|
* remain for legacy call sites but should not be used going forward. */
|
|
127
131
|
--radius-none: 0;
|
|
128
|
-
--radius-sm: 0.1875rem; /* 3px
|
|
129
|
-
--radius-md: 0.25rem; /* 4px
|
|
132
|
+
--radius-sm: 0.1875rem; /* 3px, controls */
|
|
133
|
+
--radius-md: 0.25rem; /* 4px, panels / cards */
|
|
130
134
|
--radius-lg: 0.5rem;
|
|
131
135
|
--radius-xl: 0.75rem;
|
|
132
136
|
--radius-2xl: 1rem;
|
|
133
137
|
--radius-3xl: 1.5rem;
|
|
134
138
|
--radius-full: 9999px;
|
|
135
139
|
|
|
136
|
-
/* Shadows
|
|
140
|
+
/* Shadows, mapped to the flip tokens in theme-tokens.css so every
|
|
137
141
|
* shadow-* utility resolves to `none` in dark (borders-only look). */
|
|
138
142
|
--shadow-xs: var(--rw-shadow-xs);
|
|
139
143
|
--shadow-sm: var(--rw-shadow-sm);
|
|
@@ -142,7 +146,7 @@
|
|
|
142
146
|
--shadow-xl: var(--rw-shadow-xl);
|
|
143
147
|
}
|
|
144
148
|
|
|
145
|
-
/* Semantic Design Tokens
|
|
149
|
+
/* Semantic Design Tokens, Tailwind utility aliases.
|
|
146
150
|
* Components use `bg-surface`, `text-primary`, etc.; the values live in
|
|
147
151
|
* theme-tokens.css under the --rw-* namespace and flip on light/dark. */
|
|
148
152
|
|
|
@@ -202,7 +206,7 @@
|
|
|
202
206
|
--color-warning-soft-border: var(--rw-warning-soft-border);
|
|
203
207
|
--color-info-soft-border: var(--rw-info-soft-border);
|
|
204
208
|
|
|
205
|
-
/* Terminal surface
|
|
209
|
+
/* Terminal surface, fixed dark in both themes */
|
|
206
210
|
--color-term: var(--rw-term);
|
|
207
211
|
--color-term-2: var(--rw-term-2);
|
|
208
212
|
--color-term-line: var(--rw-term-line);
|
|
@@ -224,8 +228,8 @@
|
|
|
224
228
|
|
|
225
229
|
/* Motion. Deliberately minimal: hover, focus and state changes are
|
|
226
230
|
* instant, so there is no scale of durations to pick from. The few things
|
|
227
|
-
* that still move
|
|
228
|
-
* height, a progress bar's width
|
|
231
|
+
* that still move, a drawer sliding, a toggle knob, an accordion's
|
|
232
|
+
* height, a progress bar's width, set their own timing inline. */
|
|
229
233
|
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
|
230
234
|
}
|
|
231
235
|
|
|
@@ -251,10 +255,12 @@ body {
|
|
|
251
255
|
color: var(--rw-on-surface);
|
|
252
256
|
}
|
|
253
257
|
|
|
254
|
-
/* Headings are mono chrome
|
|
255
|
-
h1
|
|
256
|
-
|
|
257
|
-
|
|
258
|
+
/* Headings are mono chrome, the runbook voice. Unlayered, so it beats the
|
|
259
|
+
weight/leading/tracking utilities on a raw <h1>-<h3>: every heading reads the
|
|
260
|
+
same. The Heading component opts out (data-rw-heading) and carries this look
|
|
261
|
+
in its own classes, so its props are the source of truth. :is()/:where()
|
|
262
|
+
keep the selector at element specificity, so app stylesheets still win. */
|
|
263
|
+
:is(h1, h2, h3):where(:not([data-rw-heading])) {
|
|
258
264
|
font-family: var(--font-mono);
|
|
259
265
|
font-weight: 800;
|
|
260
266
|
line-height: 1.1;
|
|
@@ -289,7 +295,7 @@ h3 {
|
|
|
289
295
|
backdrop-filter: var(--rw-glass-blur);
|
|
290
296
|
}
|
|
291
297
|
|
|
292
|
-
/* Legacy aliases
|
|
298
|
+
/* Legacy aliases, prefer .rw-bg-glass which adapts to light/dark */
|
|
293
299
|
.rw-bg-glass-dark {
|
|
294
300
|
background: var(--rw-glass);
|
|
295
301
|
backdrop-filter: var(--rw-glass-blur);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { CopyFeedback, copyText } from "./clipboard.svelte.js";
|
|
6
|
+
|
|
7
|
+
describe("copyText", () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.unstubAllGlobals();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("writes through navigator.clipboard", async () => {
|
|
13
|
+
const writeText = vi.fn(() => Promise.resolve());
|
|
14
|
+
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
|
15
|
+
expect(await copyText("runwisp")).toBe(true);
|
|
16
|
+
expect(writeText).toHaveBeenCalledWith("runwisp");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("falls back to execCommand when the clipboard API is missing", async () => {
|
|
20
|
+
vi.stubGlobal("navigator", {});
|
|
21
|
+
const textarea = {
|
|
22
|
+
value: "",
|
|
23
|
+
readOnly: false,
|
|
24
|
+
style: {},
|
|
25
|
+
select: vi.fn(),
|
|
26
|
+
remove: vi.fn(),
|
|
27
|
+
};
|
|
28
|
+
const execCommand = vi.fn(() => true);
|
|
29
|
+
class FakeElement {
|
|
30
|
+
focus = vi.fn();
|
|
31
|
+
}
|
|
32
|
+
const focused = new FakeElement();
|
|
33
|
+
vi.stubGlobal("HTMLElement", FakeElement);
|
|
34
|
+
vi.stubGlobal("document", {
|
|
35
|
+
activeElement: focused,
|
|
36
|
+
createElement: () => textarea,
|
|
37
|
+
body: { appendChild: vi.fn() },
|
|
38
|
+
execCommand,
|
|
39
|
+
});
|
|
40
|
+
expect(await copyText("runwisp")).toBe(true);
|
|
41
|
+
expect(textarea.value).toBe("runwisp");
|
|
42
|
+
expect(execCommand).toHaveBeenCalledWith("copy");
|
|
43
|
+
expect(textarea.remove).toHaveBeenCalled();
|
|
44
|
+
expect(focused.focus).toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("reports failure when no copy path exists", async () => {
|
|
48
|
+
vi.stubGlobal("navigator", {});
|
|
49
|
+
vi.stubGlobal("document", undefined);
|
|
50
|
+
expect(await copyText("runwisp")).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("CopyFeedback", () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.useFakeTimers();
|
|
57
|
+
vi.stubGlobal("navigator", { clipboard: { writeText: () => Promise.resolve() } });
|
|
58
|
+
});
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
vi.useRealTimers();
|
|
61
|
+
vi.unstubAllGlobals();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("flags the copied key until the reset delay passes", async () => {
|
|
65
|
+
const feedback = new CopyFeedback(1000);
|
|
66
|
+
await feedback.copy("docker run", "docker");
|
|
67
|
+
expect(feedback.copied).toBe(true);
|
|
68
|
+
expect(feedback.isCopied("docker")).toBe(true);
|
|
69
|
+
expect(feedback.isCopied("binary")).toBe(false);
|
|
70
|
+
vi.advanceTimersByTime(1000);
|
|
71
|
+
expect(feedback.copied).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("restarts the reset delay on a second copy", async () => {
|
|
75
|
+
const feedback = new CopyFeedback(1000);
|
|
76
|
+
await feedback.copy("a");
|
|
77
|
+
vi.advanceTimersByTime(800);
|
|
78
|
+
await feedback.copy("b");
|
|
79
|
+
vi.advanceTimersByTime(800);
|
|
80
|
+
expect(feedback.copied).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("stays idle when the copy fails", async () => {
|
|
84
|
+
vi.stubGlobal("navigator", {
|
|
85
|
+
clipboard: { writeText: () => Promise.reject(new Error("denied")) },
|
|
86
|
+
});
|
|
87
|
+
vi.stubGlobal("document", undefined);
|
|
88
|
+
const feedback = new CopyFeedback();
|
|
89
|
+
expect(await feedback.copy("a")).toBe(false);
|
|
90
|
+
expect(feedback.copied).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
const DEFAULT_RESET_MS = 2000;
|
|
5
|
+
|
|
6
|
+
// Copies text to the clipboard and reports whether it worked. navigator.clipboard
|
|
7
|
+
// only exists in secure contexts, so a daemon served over plain http on a LAN IP
|
|
8
|
+
// falls back to the legacy textarea + execCommand path.
|
|
9
|
+
export async function copyText(text: string): Promise<boolean> {
|
|
10
|
+
if (typeof navigator !== "undefined" && "clipboard" in navigator) {
|
|
11
|
+
try {
|
|
12
|
+
await navigator.clipboard.writeText(text);
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
// Denied or blocked: try the legacy path below.
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return legacyCopy(text);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function legacyCopy(text: string): boolean {
|
|
22
|
+
if (typeof document === "undefined") return false;
|
|
23
|
+
const previousFocus = document.activeElement;
|
|
24
|
+
const ta = document.createElement("textarea");
|
|
25
|
+
ta.value = text;
|
|
26
|
+
ta.readOnly = true;
|
|
27
|
+
ta.style.position = "fixed";
|
|
28
|
+
ta.style.opacity = "0";
|
|
29
|
+
document.body.appendChild(ta);
|
|
30
|
+
ta.select();
|
|
31
|
+
let ok = false;
|
|
32
|
+
try {
|
|
33
|
+
// Deprecated, but still the only copy path outside a secure context.
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
35
|
+
ok = document.execCommand("copy"); // NOSONAR
|
|
36
|
+
} catch {
|
|
37
|
+
ok = false;
|
|
38
|
+
}
|
|
39
|
+
ta.remove();
|
|
40
|
+
if (previousFocus instanceof HTMLElement) previousFocus.focus();
|
|
41
|
+
return ok;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// CopyFeedback drives a copy button's transient "copied" state. `key` tells
|
|
45
|
+
// several buttons sharing one instance apart (one per tab or snippet).
|
|
46
|
+
export class CopyFeedback {
|
|
47
|
+
#key = $state<string | null>(null);
|
|
48
|
+
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
49
|
+
readonly #resetMs: number;
|
|
50
|
+
|
|
51
|
+
constructor(resetMs: number = DEFAULT_RESET_MS) {
|
|
52
|
+
this.#resetMs = resetMs;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get copied(): boolean {
|
|
56
|
+
return this.#key !== null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
isCopied(key: string): boolean {
|
|
60
|
+
return this.#key === key;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async copy(text: string, key: string = ""): Promise<boolean> {
|
|
64
|
+
const ok = await copyText(text);
|
|
65
|
+
if (!ok) return false;
|
|
66
|
+
clearTimeout(this.#timer);
|
|
67
|
+
this.#key = key;
|
|
68
|
+
this.#timer = setTimeout(() => {
|
|
69
|
+
this.#key = null;
|
|
70
|
+
}, this.#resetMs);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -86,7 +86,7 @@ describe("formatRelativeTimeWithAbsolute", () => {
|
|
|
86
86
|
it("uses time-only format (HH:MM) for dates within the same day", () => {
|
|
87
87
|
const recent = new Date(Date.now() - 30 * 60_000).toISOString();
|
|
88
88
|
const r = formatRelativeTimeWithAbsolute(recent);
|
|
89
|
-
// Same-day branch produces `relative (HH:MM)
|
|
89
|
+
// Same-day branch produces `relative (HH:MM)`, assert on the colon
|
|
90
90
|
// that only appears inside the time format.
|
|
91
91
|
expect(r).toMatch(/\(\d{1,2}:\d{2}\)$/);
|
|
92
92
|
});
|
|
@@ -131,13 +131,13 @@ describe("formatRelativeTime", () => {
|
|
|
131
131
|
|
|
132
132
|
describe("formatClockTime", () => {
|
|
133
133
|
it("renders three numeric groups (hours, minutes, seconds)", () => {
|
|
134
|
-
// 24-hour h:m:s
|
|
134
|
+
// 24-hour h:m:s, the seconds component is what sets it apart from formatTimeHM.
|
|
135
135
|
expect(numericGroups(formatClockTime("2026-06-22T17:15:02Z"))).toBe(3);
|
|
136
136
|
});
|
|
137
137
|
});
|
|
138
138
|
|
|
139
139
|
describe("formatTimeHM", () => {
|
|
140
|
-
it("renders two numeric groups (hours, minutes)
|
|
140
|
+
it("renders two numeric groups (hours, minutes), no seconds", () => {
|
|
141
141
|
expect(numericGroups(formatTimeHM("2026-06-22T17:15:02Z"))).toBe(2);
|
|
142
142
|
});
|
|
143
143
|
});
|