@runwisp/ui 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/package.json +4 -5
  2. package/src/lib/actions/floating.ts +73 -0
  3. package/src/lib/actions/focusTrap.ts +3 -3
  4. package/src/lib/actions/portal.ts +5 -1
  5. package/src/lib/components/Accordion.svelte +6 -2
  6. package/src/lib/components/AccordionItem.svelte +80 -23
  7. package/src/lib/components/Avatar.svelte +0 -1
  8. package/src/lib/components/Badge.svelte +1 -1
  9. package/src/lib/components/CodeBlock.svelte +84 -35
  10. package/src/lib/components/CronInput.svelte +5 -2
  11. package/src/lib/components/DataGrid.svelte +100 -138
  12. package/src/lib/components/Drawer.svelte +2 -6
  13. package/src/lib/components/Dropdown.svelte +19 -57
  14. package/src/lib/components/EmptyState.svelte +2 -1
  15. package/src/lib/components/ErrorState.svelte +2 -1
  16. package/src/lib/components/FilterBar.svelte +1 -1
  17. package/src/lib/components/FilterPill.svelte +2 -2
  18. package/src/lib/components/FormField.svelte +1 -1
  19. package/src/lib/components/Heading.svelte +19 -12
  20. package/src/lib/components/Kbd.svelte +25 -10
  21. package/src/lib/components/Link.svelte +7 -1
  22. package/src/lib/components/LogConsole.svelte +40 -42
  23. package/src/lib/components/Modal.svelte +44 -33
  24. package/src/lib/components/NotificationBell.svelte +2 -1
  25. package/src/lib/components/PageContainer.svelte +1 -10
  26. package/src/lib/components/PageHeader.svelte +8 -4
  27. package/src/lib/components/Popover.svelte +9 -51
  28. package/src/lib/components/Progress.svelte +19 -6
  29. package/src/lib/components/Radio.svelte +16 -4
  30. package/src/lib/components/RadioGroup.svelte +15 -2
  31. package/src/lib/components/SearchInput.svelte +1 -1
  32. package/src/lib/components/Select.svelte +16 -47
  33. package/src/lib/components/Sparkline.svelte +2 -2
  34. package/src/lib/components/StatusDot.svelte +1 -1
  35. package/src/lib/components/Table.svelte +13 -4
  36. package/src/lib/components/TableBody.svelte +2 -1
  37. package/src/lib/components/TableCell.svelte +38 -8
  38. package/src/lib/components/TableHead.svelte +12 -4
  39. package/src/lib/components/TableRow.svelte +23 -3
  40. package/src/lib/components/TaskCard.svelte +1 -1
  41. package/src/lib/components/TimezoneSelect.svelte +2 -2
  42. package/src/lib/components/Tooltip.svelte +1 -1
  43. package/src/lib/components/accordion.svelte.ts +19 -0
  44. package/src/lib/components/dashboard/RunDetailPanel.svelte +106 -79
  45. package/src/lib/components/dashboard/RunFilterPopover.svelte +12 -21
  46. package/src/lib/components/dashboard/RunsList.svelte +17 -26
  47. package/src/lib/components/dashboard/cron-format.test.ts +1 -1
  48. package/src/lib/components/dashboard/cron-format.ts +3 -3
  49. package/src/lib/components/dashboard/run-filters.test.ts +4 -60
  50. package/src/lib/components/dashboard/run-filters.ts +29 -144
  51. package/src/lib/components/dashboard/run-helpers.test.ts +25 -6
  52. package/src/lib/components/dashboard/run-helpers.ts +40 -39
  53. package/src/lib/components/dashboard/status-config.ts +38 -83
  54. package/src/lib/components/dashboard/types.ts +0 -26
  55. package/src/lib/components/data-grid-selection.ts +2 -9
  56. package/src/lib/components/filter-spec.ts +3 -3
  57. package/src/lib/components/radio-group.ts +11 -0
  58. package/src/lib/index.ts +8 -43
  59. package/src/lib/log-console/LogCache.svelte.ts +1 -1
  60. package/src/lib/log-console/LogFetcher.svelte.ts +3 -14
  61. package/src/lib/log-console/ansi.ts +1 -1
  62. package/src/lib/log-console/highlight-scroll.test.ts +59 -0
  63. package/src/lib/log-console/highlight-scroll.ts +41 -0
  64. package/src/lib/theme-tokens.css +21 -21
  65. package/src/lib/theme.css +22 -16
  66. package/src/lib/utils/clipboard.svelte.test.ts +92 -0
  67. package/src/lib/utils/clipboard.svelte.ts +73 -0
  68. package/src/lib/utils/format.test.ts +3 -3
  69. package/src/lib/utils/format.ts +65 -94
  70. package/src/lib/utils/id.test.ts +3 -7
  71. package/src/lib/utils/id.ts +3 -2
  72. package/src/lib/utils/theme.svelte.test.ts +0 -6
  73. package/src/lib/utils/theme.svelte.ts +3 -5
  74. package/src/lib/utils/toast.svelte.ts +5 -14
  75. package/src/stories/Accordion.stories.svelte +61 -0
  76. package/src/stories/CodeBlock.stories.svelte +55 -0
  77. package/src/stories/DataGrid.stories.svelte +0 -5
  78. package/src/stories/Heading.stories.svelte +31 -0
  79. package/src/stories/Kbd.stories.svelte +36 -0
  80. package/src/stories/Link.stories.svelte +38 -0
  81. package/src/stories/LogConsole.stories.svelte +2 -2
  82. package/src/stories/PageHeader.stories.svelte +31 -0
  83. package/src/stories/Progress.stories.svelte +26 -0
  84. package/src/stories/RadioGroup.stories.svelte +43 -0
  85. package/src/stories/Table.stories.svelte +69 -0
  86. package/src/svelte-shims.d.ts +0 -5
  87. package/src/lib/components/dashboard/status-config.test.ts +0 -18
@@ -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
+ }
@@ -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
 
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 — Tailwind v4 entrypoint.
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
- /* Palette aliases — Tailwind utilities (bg-wisp-500, text-mist-900, …)
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 — stacks and @font-face live in theme-tokens.css so the
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 — runbook scale (small radii are signature). lg/xl/2xl/3xl
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 — controls */
129
- --radius-md: 0.25rem; /* 4px — panels / cards */
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 — mapped to the flip tokens in theme-tokens.css so every
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 — Tailwind utility aliases.
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 — fixed dark in both themes */
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 — a drawer sliding, a toggle knob, an accordion's
228
- * height, a progress bar's width — set their own timing inline. */
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 — the runbook voice. Components should not fight this. */
255
- h1,
256
- h2,
257
- h3 {
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 — prefer .rw-bg-glass which adapts to light/dark */
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)` — assert on the colon
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 — the seconds component is what sets it apart from formatTimeHM.
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) — no seconds", () => {
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
  });