@runwisp/ui 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/lib/assets/runwisp-logo.svg +2 -2
- package/src/lib/components/LogConsole.svelte +561 -66
- package/src/lib/components/Modal.svelte +3 -1
- package/src/lib/components/Popover.svelte +64 -31
- package/src/lib/components/dashboard/RunDetailPanel.svelte +774 -169
- package/src/lib/components/dashboard/RunFilterPopover.svelte +299 -0
- package/src/lib/components/dashboard/RunsList.svelte +418 -219
- package/src/lib/components/dashboard/run-filters.test.ts +299 -0
- package/src/lib/components/dashboard/run-filters.ts +384 -0
- package/src/lib/components/dashboard/run-helpers.test.ts +50 -1
- package/src/lib/components/dashboard/run-helpers.ts +38 -0
- package/src/lib/index.ts +27 -5
- package/src/lib/log-console/LogCache.svelte.ts +56 -1
- package/src/lib/log-console/ansi.test.ts +18 -1
- package/src/lib/log-console/ansi.ts +17 -0
- package/src/lib/log-console/types.ts +17 -0
- package/src/lib/theme-tokens.css +74 -62
- package/src/lib/theme.css +1 -0
- package/src/lib/utils/format.test.ts +38 -0
- package/src/lib/utils/format.ts +36 -0
- package/src/stories/LogConsole.stories.svelte +13 -0
- package/src/stories/assets/runwisp_c.svg +2 -2
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
<script lang="ts">
|
|
5
5
|
import { untrack } from "svelte";
|
|
6
|
-
import { ansiLineToHtml } from "../log-console/ansi.js";
|
|
6
|
+
import { ansiLineToHtml, visibleColumns } from "../log-console/ansi.js";
|
|
7
7
|
import { LogCache } from "../log-console/LogCache.svelte.js";
|
|
8
8
|
import { LogFetcher } from "../log-console/LogFetcher.svelte.js";
|
|
9
9
|
import type { FetchLogsFn, LogEvent } from "../log-console/types.js";
|
|
@@ -19,6 +19,24 @@
|
|
|
19
19
|
// The pulse is one-shot: changing the prop to a new line restarts the
|
|
20
20
|
// animation; setting it to null clears immediately.
|
|
21
21
|
highlightLine?: number | null;
|
|
22
|
+
// fetchLineHistory resolves the prior whole-region frames a settled
|
|
23
|
+
// progress bar / multi-line redraw passed through, for the given absolute
|
|
24
|
+
// line number. When omitted, anchor lines render without the rewind
|
|
25
|
+
// affordance.
|
|
26
|
+
fetchLineHistory?: ((lineNum: number) => Promise<string[][]>) | undefined;
|
|
27
|
+
// Text of the terminus marker drawn once a finished log's last line is
|
|
28
|
+
// reached (default "end of output"). A stopped/timed-out run passes its
|
|
29
|
+
// own wording so a short capture ends on an explicit reason, not a void.
|
|
30
|
+
endLabel?: string;
|
|
31
|
+
// "muted" (default) renders the terminus in the gutter grey; "warn"
|
|
32
|
+
// tints it amber for runs that ended by intervention rather than naturally.
|
|
33
|
+
endTone?: "muted" | "warn";
|
|
34
|
+
// Wrap long lines instead of horizontally scrolling them. When on, each
|
|
35
|
+
// rendered row's height becomes a multiple of `lineHeight` (one per
|
|
36
|
+
// wrapped visual row), so the virtualizer switches from the fixed-height
|
|
37
|
+
// linear layout to a prefix-sum-of-row-counts layout. Off by default to
|
|
38
|
+
// preserve the original horizontal-scroll behaviour for wide output.
|
|
39
|
+
wrap?: boolean;
|
|
22
40
|
}
|
|
23
41
|
|
|
24
42
|
let {
|
|
@@ -27,66 +45,310 @@
|
|
|
27
45
|
lineHeight = 20,
|
|
28
46
|
class: className = "",
|
|
29
47
|
highlightLine = null,
|
|
48
|
+
fetchLineHistory,
|
|
49
|
+
endLabel = "end of output",
|
|
50
|
+
endTone = "muted",
|
|
51
|
+
wrap = $bindable(false),
|
|
30
52
|
}: Props = $props();
|
|
31
53
|
|
|
54
|
+
// --- Frame-history inline expansion (single expansion at a time) ---
|
|
55
|
+
// expandedLine is the absolute line number whose history block is open;
|
|
56
|
+
// expandedFrames holds the fetched whole-region frames (null while loading).
|
|
57
|
+
let expandedLine = $state<number | null>(null);
|
|
58
|
+
let expandedFrames = $state<string[][] | null>(null);
|
|
59
|
+
let expandedError = $state(false);
|
|
60
|
+
|
|
61
|
+
const FRAME_BLOCK_PAD = 8; // px padding above+below the block content
|
|
62
|
+
const FRAME_GAP = 6; // px between consecutive frames
|
|
63
|
+
const FRAME_BLOCK_MAX = 400; // px cap; the block scrolls internally beyond this
|
|
64
|
+
|
|
65
|
+
// Height the open history block occupies in the virtual surface. Subsequent
|
|
66
|
+
// lines are shifted down by exactly this, so the math stays a single offset.
|
|
67
|
+
let frameBlockHeight = $derived.by(() => {
|
|
68
|
+
if (expandedLine === null) return 0;
|
|
69
|
+
if (!expandedFrames) return lineHeight * 2; // loading / error placeholder
|
|
70
|
+
let h = FRAME_BLOCK_PAD * 2;
|
|
71
|
+
for (const frame of expandedFrames) {
|
|
72
|
+
h += lineHeight; // per-frame label
|
|
73
|
+
h += frame.length * lineHeight; // the frame's rows
|
|
74
|
+
}
|
|
75
|
+
if (expandedFrames.length > 1) h += FRAME_GAP * (expandedFrames.length - 1);
|
|
76
|
+
return Math.min(h, FRAME_BLOCK_MAX);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
function collapseHistory() {
|
|
80
|
+
expandedLine = null;
|
|
81
|
+
expandedFrames = null;
|
|
82
|
+
expandedError = false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function toggleHistory(lineNum: number) {
|
|
86
|
+
if (expandedLine === lineNum) {
|
|
87
|
+
collapseHistory();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
expandedLine = lineNum;
|
|
91
|
+
expandedFrames = null;
|
|
92
|
+
expandedError = false;
|
|
93
|
+
const fn = fetchLineHistory;
|
|
94
|
+
if (!fn) return;
|
|
95
|
+
try {
|
|
96
|
+
const frames = await fn(lineNum);
|
|
97
|
+
if (expandedLine === lineNum) expandedFrames = frames;
|
|
98
|
+
} catch {
|
|
99
|
+
if (expandedLine === lineNum) expandedError = true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
32
103
|
let flashLine = $state<number | null>(null);
|
|
33
104
|
let flashTimer: ReturnType<typeof setTimeout> | null = null;
|
|
34
105
|
|
|
35
106
|
const cache = new LogCache();
|
|
36
107
|
let fetcher: LogFetcher | null = $state(null);
|
|
37
108
|
let containerEl: HTMLDivElement | null = $state(null);
|
|
109
|
+
let rulerEl: HTMLSpanElement | null = $state(null);
|
|
38
110
|
let scrollTop = $state(0);
|
|
39
111
|
let containerHeight = $state(0);
|
|
112
|
+
let containerWidth = $state(0);
|
|
113
|
+
// Monospace character width in px, measured from a hidden ruler so the
|
|
114
|
+
// horizontal scroll surface is sized without per-line DOM reflow.
|
|
115
|
+
let charWidth = $state(8);
|
|
40
116
|
let isAutoScroll = $state(true);
|
|
41
117
|
let userScrolledUp = $state(false);
|
|
42
118
|
let isStreaming = $derived(!cache.finished && cache.totalLines > 0);
|
|
43
119
|
|
|
44
120
|
const OVERSCAN = 50;
|
|
45
121
|
const BLANK_LINES_AT_END = 3;
|
|
122
|
+
const RULER_SAMPLE = "0".repeat(50);
|
|
123
|
+
// Trailing slack past the last column so the widest line never sits flush
|
|
124
|
+
// against the scroll edge.
|
|
125
|
+
const SURFACE_PADDING = 16;
|
|
46
126
|
|
|
47
127
|
let truncationBannerHeight = $derived(cache.firstAvailableLine > 0 ? lineHeight : 0);
|
|
48
128
|
|
|
49
|
-
|
|
129
|
+
let gutterWidth = $derived(Math.max(4, String(cache.totalLines || 1).length) * 10 + 16);
|
|
130
|
+
|
|
131
|
+
// ---- wrap-mode row accounting ---------------------------------------
|
|
132
|
+
// When wrapping, each line occupies as many `lineHeight` rows as its text
|
|
133
|
+
// wraps into at the current column width. rowCounts remembers the wrapped
|
|
134
|
+
// row count for every line whose text has been observed; lines never loaded
|
|
135
|
+
// (pruned or out-of-window) default to 1 row. prefixSums[i] is the total
|
|
136
|
+
// rows consumed by lines [firstAvailableLine, firstAvailableLine + i), so
|
|
137
|
+
// lineTop / totalHeight / the scroll-position→line lookup stay O(1) or
|
|
138
|
+
// O(log n) even though rows are no longer uniform. A plain Map is
|
|
139
|
+
// deliberate: reactivity is driven by the `rowCountVersion` counter (bumped
|
|
140
|
+
// on any change), not by per-key subscriptions — iterating a SvelteMap of
|
|
141
|
+
// tens of thousands of lines on every streamed line would be wasteful.
|
|
142
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
143
|
+
const rowCounts = new Map<number, number>();
|
|
144
|
+
let rowCountVersion = $state(0);
|
|
145
|
+
let prefixSums = $state<number[]>([]);
|
|
146
|
+
|
|
147
|
+
// Right padding on the text cell (pr-4); reserved when computing how many
|
|
148
|
+
// columns a wrapped line can use.
|
|
149
|
+
const TEXT_PADDING = 16;
|
|
150
|
+
|
|
151
|
+
let availableColumns = $derived(
|
|
152
|
+
wrap && charWidth > 0
|
|
153
|
+
? Math.max(1, Math.floor((containerWidth - gutterWidth - TEXT_PADDING) / charWidth))
|
|
154
|
+
: 1,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
function wrappedRowsFor(text: string | undefined): number {
|
|
158
|
+
if (!text || !wrap) return 1;
|
|
159
|
+
const cols = availableColumns;
|
|
160
|
+
if (cols <= 1) return 1;
|
|
161
|
+
return Math.max(1, Math.ceil(visibleColumns(text) / cols));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Line-text white-space model: wrapped lines break anywhere (so a long
|
|
165
|
+
// token wraps mid-word the way a terminal would), unwrapped lines stay on
|
|
166
|
+
// one horizontal-scrolling row.
|
|
167
|
+
let lineTextClass = $derived(wrap ? "break-anywhere whitespace-pre-wrap" : "whitespace-pre");
|
|
168
|
+
|
|
169
|
+
// Recompute row counts for every loaded line whenever wrapping is on and
|
|
170
|
+
// either the cache or the available column width moves. Pruned lines keep
|
|
171
|
+
// their last-computed count so scroll geometry stays stable.
|
|
172
|
+
$effect(() => {
|
|
173
|
+
if (!wrap) return;
|
|
174
|
+
const lines = cache.lines;
|
|
175
|
+
void availableColumns;
|
|
176
|
+
let changed = false;
|
|
177
|
+
for (const [num, text] of lines) {
|
|
178
|
+
const r = wrappedRowsFor(text);
|
|
179
|
+
const prev = rowCounts.get(num);
|
|
180
|
+
if (prev !== r) {
|
|
181
|
+
rowCounts.set(num, r);
|
|
182
|
+
changed = true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (changed) rowCountVersion++;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Rebuild the prefix-sum table when wrap is on and any geometry input
|
|
189
|
+
// moves. Off ⇒ empty (the fixed-height path ignores it).
|
|
190
|
+
$effect(() => {
|
|
191
|
+
const first = cache.firstAvailableLine;
|
|
192
|
+
const total = cache.totalLines;
|
|
193
|
+
void rowCountVersion;
|
|
194
|
+
void availableColumns;
|
|
195
|
+
if (!wrap) {
|
|
196
|
+
prefixSums = [];
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const count = Math.max(0, total - first);
|
|
200
|
+
const arr = new Array(count + 1);
|
|
201
|
+
arr[0] = 0;
|
|
202
|
+
let acc = 0;
|
|
203
|
+
for (let i = 0; i < count; i++) {
|
|
204
|
+
acc += rowCounts.get(first + i) ?? 1;
|
|
205
|
+
arr[i + 1] = acc;
|
|
206
|
+
}
|
|
207
|
+
prefixSums = arr;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Drop wrap geometry when wrapping turns off so memory doesn't linger.
|
|
211
|
+
$effect(() => {
|
|
212
|
+
if (!wrap) {
|
|
213
|
+
rowCounts.clear();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
function lineRowCount(lineNum: number): number {
|
|
218
|
+
return wrap ? (rowCounts.get(lineNum) ?? 1) : 1;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Convert a global line number to a pixel Y position. Lines below an open
|
|
222
|
+
// history block are pushed down by the block's height (single expansion, so
|
|
223
|
+
// it's one conditional offset on top of the wrap/non-wrap row math).
|
|
50
224
|
function lineTop(lineNum: number): number {
|
|
51
|
-
|
|
225
|
+
const base =
|
|
226
|
+
truncationBannerHeight +
|
|
227
|
+
(expandedLine !== null && lineNum > expandedLine ? frameBlockHeight : 0);
|
|
228
|
+
if (!wrap) {
|
|
229
|
+
return (lineNum - cache.firstAvailableLine) * lineHeight + base;
|
|
230
|
+
}
|
|
231
|
+
const i = lineNum - cache.firstAvailableLine;
|
|
232
|
+
if (i <= 0) return base;
|
|
233
|
+
if (i >= prefixSums.length) {
|
|
234
|
+
const last = prefixSums.length > 0 ? (prefixSums[prefixSums.length - 1] ?? 0) : 0;
|
|
235
|
+
return last * lineHeight + base;
|
|
236
|
+
}
|
|
237
|
+
return (prefixSums[i] ?? 0) * lineHeight + base;
|
|
52
238
|
}
|
|
53
239
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
240
|
+
function lineHeightPx(lineNum: number): number {
|
|
241
|
+
return lineRowCount(lineNum) * lineHeight;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Largest i with prefixSums[i] <= yRows (i.e. the line index whose row span
|
|
245
|
+
// covers the given scroll offset, in rows). Used to map a scroll position
|
|
246
|
+
// back to a line number when wrapping makes the layout non-linear.
|
|
247
|
+
function lineIndexAtY(yRows: number): number {
|
|
248
|
+
const arr = prefixSums;
|
|
249
|
+
if (arr.length === 0) return 0;
|
|
250
|
+
if (yRows <= (arr[0] ?? 0)) return 0;
|
|
251
|
+
const last = arr.length - 1;
|
|
252
|
+
if (yRows >= (arr[last] ?? Number.POSITIVE_INFINITY)) return last;
|
|
253
|
+
let lo = 0;
|
|
254
|
+
let hi = last;
|
|
255
|
+
while (lo < hi) {
|
|
256
|
+
const mid = (lo + hi + 1) >> 1;
|
|
257
|
+
if ((arr[mid] ?? Number.POSITIVE_INFINITY) <= yRows) lo = mid;
|
|
258
|
+
else hi = mid - 1;
|
|
259
|
+
}
|
|
260
|
+
return lo;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let visibleStart = $derived.by(() => {
|
|
264
|
+
const first = cache.firstAvailableLine;
|
|
265
|
+
if (!wrap) {
|
|
266
|
+
return Math.max(
|
|
267
|
+
first,
|
|
268
|
+
first +
|
|
269
|
+
Math.floor(Math.max(0, scrollTop - truncationBannerHeight) / lineHeight) -
|
|
72
270
|
OVERSCAN,
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const yRows = Math.max(0, scrollTop - truncationBannerHeight) / lineHeight;
|
|
274
|
+
const idx = lineIndexAtY(yRows) - OVERSCAN;
|
|
275
|
+
return Math.max(first, first + Math.max(0, idx));
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
let visibleEnd = $derived.by(() => {
|
|
279
|
+
const first = cache.firstAvailableLine;
|
|
280
|
+
const maxLine = Math.max(0, cache.totalLines - 1);
|
|
281
|
+
if (!wrap) {
|
|
282
|
+
return Math.max(
|
|
283
|
+
first,
|
|
284
|
+
Math.min(
|
|
285
|
+
maxLine,
|
|
286
|
+
first +
|
|
287
|
+
Math.ceil(
|
|
288
|
+
Math.max(0, scrollTop - truncationBannerHeight + containerHeight) /
|
|
289
|
+
lineHeight,
|
|
290
|
+
) +
|
|
291
|
+
OVERSCAN,
|
|
292
|
+
),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
const yRows = Math.max(0, scrollTop - truncationBannerHeight) / lineHeight;
|
|
296
|
+
const viewportRows = containerHeight / lineHeight;
|
|
297
|
+
const limit = yRows + viewportRows + OVERSCAN;
|
|
298
|
+
const count = prefixSums.length - 1;
|
|
299
|
+
let end = lineIndexAtY(yRows);
|
|
300
|
+
while (end < count && (prefixSums[end] ?? Number.POSITIVE_INFINITY) < limit) end++;
|
|
301
|
+
return Math.min(maxLine, first + Math.max(0, end));
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Live-region overlay rows, rendered in place below the committed lines.
|
|
305
|
+
let overlayRows = $derived(cache.overlayRows);
|
|
76
306
|
|
|
77
307
|
let totalHeight = $derived.by(() => {
|
|
78
|
-
const availableLines = cache.totalLines - cache.firstAvailableLine;
|
|
79
308
|
const truncationBannerHeight = cache.firstAvailableLine > 0 ? lineHeight : 0;
|
|
80
|
-
const linesHeight =
|
|
81
|
-
|
|
309
|
+
const linesHeight = wrap
|
|
310
|
+
? (prefixSums.length > 0
|
|
311
|
+
? (prefixSums[prefixSums.length - 1] ?? 0)
|
|
312
|
+
: Math.max(0, cache.totalLines - cache.firstAvailableLine)) *
|
|
313
|
+
lineHeight +
|
|
314
|
+
truncationBannerHeight
|
|
315
|
+
: (cache.totalLines - cache.firstAvailableLine) * lineHeight + truncationBannerHeight;
|
|
316
|
+
const overlayHeight = overlayRows.length * lineHeight;
|
|
317
|
+
// Reserve a row for the streaming cursor only when it stands on its own
|
|
318
|
+
// fresh line. With a live tail it rides the last overlay row (counted in
|
|
319
|
+
// overlayHeight), so no extra row is needed.
|
|
320
|
+
const streamingHeight = isStreaming && overlayRows.length === 0 ? lineHeight : 0;
|
|
321
|
+
// A finished log gets an "end of output" sentinel where the streaming
|
|
322
|
+
// indicator sits while live (mutually exclusive). It's drawn two rows
|
|
323
|
+
// tall so the dashed rule has breathing room above the centred label.
|
|
324
|
+
const sentinelHeight = cache.finished && cache.totalLines > 0 ? lineHeight * 2 : 0;
|
|
82
325
|
const blankHeight = cache.totalLines > 0 ? BLANK_LINES_AT_END * lineHeight : 0;
|
|
83
|
-
return Math.max(
|
|
326
|
+
return Math.max(
|
|
327
|
+
linesHeight +
|
|
328
|
+
overlayHeight +
|
|
329
|
+
streamingHeight +
|
|
330
|
+
sentinelHeight +
|
|
331
|
+
blankHeight +
|
|
332
|
+
frameBlockHeight,
|
|
333
|
+
containerHeight,
|
|
334
|
+
);
|
|
84
335
|
});
|
|
85
336
|
|
|
86
|
-
|
|
337
|
+
// Width of the virtual scroll surface: wide enough for the longest line
|
|
338
|
+
// seen so far, never narrower than the viewport (so short logs show no
|
|
339
|
+
// horizontal scrollbar). Wrapping pins the surface to the viewport — there
|
|
340
|
+
// is no horizontal axis to scroll.
|
|
341
|
+
let surfaceWidth = $derived(
|
|
342
|
+
wrap
|
|
343
|
+
? containerWidth
|
|
344
|
+
: Math.max(
|
|
345
|
+
containerWidth,
|
|
346
|
+
gutterWidth + cache.maxLineColumns * charWidth + SURFACE_PADDING,
|
|
347
|
+
),
|
|
348
|
+
);
|
|
87
349
|
|
|
88
350
|
let renderedLines = $derived.by(() => {
|
|
89
|
-
const lines: Array<{ num: number; text: string | undefined }> = [];
|
|
351
|
+
const lines: Array<{ num: number; text: string | undefined; frameCount: number }> = [];
|
|
90
352
|
const start = Math.max(cache.firstAvailableLine, visibleStart);
|
|
91
353
|
const end = Math.min(cache.totalLines - 1, visibleEnd);
|
|
92
354
|
|
|
@@ -94,11 +356,15 @@
|
|
|
94
356
|
lines.push({
|
|
95
357
|
num: i,
|
|
96
358
|
text: cache.lines.get(i),
|
|
359
|
+
frameCount: cache.frameCounts.get(i) ?? 0,
|
|
97
360
|
});
|
|
98
361
|
}
|
|
99
362
|
return lines;
|
|
100
363
|
});
|
|
101
364
|
|
|
365
|
+
// Anchors are clickable only when the parent supplied a history fetcher.
|
|
366
|
+
let canExpand = $derived(fetchLineHistory !== undefined);
|
|
367
|
+
|
|
102
368
|
// The fetcher serves on-demand scroll-up loads only; the parent seeds
|
|
103
369
|
// the initial cache via onStream (typically a tail fetch) so the
|
|
104
370
|
// viewport lands at the end of the log without racing the auto-scroll.
|
|
@@ -134,14 +400,16 @@
|
|
|
134
400
|
untrack(() => cache.prune(vs, ve));
|
|
135
401
|
});
|
|
136
402
|
|
|
137
|
-
let
|
|
403
|
+
let prevTailRows = 0; // plain variable — intentionally non-reactive
|
|
138
404
|
|
|
139
405
|
$effect(() => {
|
|
140
|
-
|
|
141
|
-
|
|
406
|
+
// Track committed lines AND live overlay rows so an animating region at
|
|
407
|
+
// the tail keeps a bottom-anchored viewport pinned to the bottom.
|
|
408
|
+
const currentTail = cache.totalLines + overlayRows.length;
|
|
409
|
+
if (currentTail > prevTailRows && isAutoScroll && !userScrolledUp) {
|
|
142
410
|
requestAnimationFrame(() => scrollToBottom());
|
|
143
411
|
}
|
|
144
|
-
|
|
412
|
+
prevTailRows = currentTail;
|
|
145
413
|
});
|
|
146
414
|
|
|
147
415
|
function onScroll(e: Event & { currentTarget: EventTarget & HTMLDivElement }) {
|
|
@@ -186,6 +454,24 @@
|
|
|
186
454
|
function onResize() {
|
|
187
455
|
if (containerEl) {
|
|
188
456
|
containerHeight = containerEl.clientHeight;
|
|
457
|
+
containerWidth = containerEl.clientWidth;
|
|
458
|
+
// Resync the reactive scroll position with the DOM. The browser
|
|
459
|
+
// clamps scrollTop when the viewport grows (e.g. maximizing the
|
|
460
|
+
// console), but no scroll event fires for that clamp — so without
|
|
461
|
+
// this the virtualizer keeps a stale, too-large scrollTop and
|
|
462
|
+
// renders an empty window until the first manual scroll.
|
|
463
|
+
scrollTop = containerEl.scrollTop;
|
|
464
|
+
if (isAutoScroll && !userScrolledUp) scrollToBottom();
|
|
465
|
+
}
|
|
466
|
+
measureCharWidth();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Measure one monospace column from the hidden ruler. Font, zoom, and
|
|
470
|
+
// DPI changes all surface through the ResizeObserver, so this stays
|
|
471
|
+
// accurate without polling.
|
|
472
|
+
function measureCharWidth() {
|
|
473
|
+
if (rulerEl && rulerEl.offsetWidth > 0) {
|
|
474
|
+
charWidth = rulerEl.offsetWidth / RULER_SAMPLE.length;
|
|
189
475
|
}
|
|
190
476
|
}
|
|
191
477
|
|
|
@@ -204,6 +490,7 @@
|
|
|
204
490
|
|
|
205
491
|
export function reset() {
|
|
206
492
|
cache.reset();
|
|
493
|
+
collapseHistory();
|
|
207
494
|
isAutoScroll = true;
|
|
208
495
|
userScrolledUp = false;
|
|
209
496
|
scrollTop = 0;
|
|
@@ -236,6 +523,8 @@
|
|
|
236
523
|
$effect(() => {
|
|
237
524
|
if (!containerEl) return;
|
|
238
525
|
containerHeight = containerEl.clientHeight;
|
|
526
|
+
containerWidth = containerEl.clientWidth;
|
|
527
|
+
measureCharWidth();
|
|
239
528
|
|
|
240
529
|
const resizeObserver = new ResizeObserver(() => {
|
|
241
530
|
onResize();
|
|
@@ -248,21 +537,41 @@
|
|
|
248
537
|
});
|
|
249
538
|
</script>
|
|
250
539
|
|
|
540
|
+
<!-- One place that renders ANSI-converted markup, so the necessary @html escape
|
|
541
|
+
hatch (the input is sanitised by ansiLineToHtml) lives behind a single
|
|
542
|
+
audited disable rather than being repeated per call site. -->
|
|
543
|
+
{#snippet ansiLine(text: string)}
|
|
544
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
545
|
+
<span class="whitespace-pre">{@html ansiLineToHtml(text)}</span>
|
|
546
|
+
{/snippet}
|
|
547
|
+
|
|
251
548
|
<div
|
|
252
|
-
class="log-console relative flex h-full w-full flex-col overflow-hidden bg-
|
|
549
|
+
class="log-console relative flex h-full w-full flex-col overflow-hidden bg-[var(--rw-con-bg)] font-mono text-[12.5px] {className}"
|
|
253
550
|
>
|
|
551
|
+
<!-- Hidden ruler: one monospace column is measured from this off-screen
|
|
552
|
+
sample to size the horizontal scroll surface without per-line reflow. -->
|
|
553
|
+
<span
|
|
554
|
+
bind:this={rulerEl}
|
|
555
|
+
aria-hidden="true"
|
|
556
|
+
class="pointer-events-none absolute -top-[9999px] left-0 whitespace-pre select-none"
|
|
557
|
+
>{RULER_SAMPLE}</span
|
|
558
|
+
>
|
|
559
|
+
|
|
254
560
|
<div bind:this={containerEl} class="flex-1 overflow-auto" onscroll={onScroll}>
|
|
255
|
-
<div
|
|
561
|
+
<div
|
|
562
|
+
class="relative"
|
|
563
|
+
style="height: {totalHeight}px; min-height: 100%; width: {surfaceWidth}px; min-width: 100%;"
|
|
564
|
+
>
|
|
256
565
|
{#if cache.firstAvailableLine > 0}
|
|
257
566
|
<div
|
|
258
567
|
class="absolute right-0 left-0 flex items-center bg-warning-700/20 text-warning-400"
|
|
259
568
|
style="top: 0px; height: {lineHeight}px;"
|
|
260
569
|
>
|
|
261
570
|
<div
|
|
262
|
-
class="flex-shrink-0 pr-3 text-right select-none"
|
|
571
|
+
class="sticky left-0 z-10 flex-shrink-0 pr-3 text-right select-none"
|
|
263
572
|
style="width: {gutterWidth}px;"
|
|
264
573
|
></div>
|
|
265
|
-
<div class="flex-
|
|
574
|
+
<div class="sticky flex-shrink-0 pr-4 text-xs" style="left: {gutterWidth}px;">
|
|
266
575
|
Log truncated: {cache.firstAvailableLine.toLocaleString()} earlier line{cache.firstAvailableLine ===
|
|
267
576
|
1
|
|
268
577
|
? ""
|
|
@@ -272,57 +581,172 @@
|
|
|
272
581
|
{/if}
|
|
273
582
|
|
|
274
583
|
{#each renderedLines as line (line.num)}
|
|
584
|
+
{@const isAnchor = canExpand && line.frameCount > 0}
|
|
275
585
|
<div
|
|
276
|
-
class="log-line absolute right-0 left-0 flex
|
|
586
|
+
class="log-line absolute right-0 left-0 flex {wrap
|
|
587
|
+
? 'items-start'
|
|
588
|
+
: 'items-center'} hover:bg-[rgb(255_255_255_/_0.025)] {flashLine ===
|
|
277
589
|
line.num
|
|
278
590
|
? 'log-line--flash'
|
|
279
591
|
: ''}"
|
|
280
|
-
style="top: {lineTop(line.num)}px; height: {
|
|
592
|
+
style="top: {lineTop(line.num)}px; height: {lineHeightPx(line.num)}px;"
|
|
281
593
|
>
|
|
282
594
|
<div
|
|
283
|
-
class="flex-shrink-0 pr-3 text-right text-
|
|
595
|
+
class="sticky left-0 z-10 flex flex-shrink-0 items-center justify-end gap-1 bg-[var(--rw-con-bg)] pr-3 text-right text-[var(--rw-con-gutter)] select-none"
|
|
284
596
|
style="width: {gutterWidth}px;"
|
|
285
597
|
>
|
|
598
|
+
{#if isAnchor}
|
|
599
|
+
<button
|
|
600
|
+
type="button"
|
|
601
|
+
class="frame-toggle {expandedLine === line.num
|
|
602
|
+
? 'text-aurora-400'
|
|
603
|
+
: 'text-[var(--rw-con-dim)] hover:text-aurora-400'}"
|
|
604
|
+
title="{line.frameCount} earlier frame{line.frameCount === 1
|
|
605
|
+
? ''
|
|
606
|
+
: 's'} — click to {expandedLine === line.num
|
|
607
|
+
? 'hide'
|
|
608
|
+
: 'rewind'}"
|
|
609
|
+
aria-label="Toggle frame history for line {line.num + 1}"
|
|
610
|
+
aria-expanded={expandedLine === line.num}
|
|
611
|
+
onclick={() => toggleHistory(line.num)}
|
|
612
|
+
>
|
|
613
|
+
↻
|
|
614
|
+
</button>
|
|
615
|
+
{/if}
|
|
286
616
|
{line.num + 1}
|
|
287
617
|
</div>
|
|
288
|
-
<div class="flex-1
|
|
618
|
+
<div class="min-w-0 flex-1 pr-4 text-[var(--rw-con-text)]">
|
|
289
619
|
{#if line.text !== undefined}
|
|
290
620
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
291
|
-
<span class=
|
|
621
|
+
<span class={lineTextClass}>{@html ansiLineToHtml(line.text)}</span>
|
|
292
622
|
{:else}
|
|
293
|
-
<span class="text-
|
|
623
|
+
<span class="text-[var(--rw-con-dim)] italic">Loading...</span>
|
|
294
624
|
{/if}
|
|
295
625
|
</div>
|
|
296
626
|
</div>
|
|
297
627
|
{/each}
|
|
298
628
|
|
|
299
|
-
{#if
|
|
629
|
+
{#if expandedLine !== null}
|
|
630
|
+
<div
|
|
631
|
+
class="frame-history absolute right-0 left-0 overflow-y-auto border-y border-[var(--rw-con-gutter)] bg-[var(--rw-con-panel)]"
|
|
632
|
+
style="top: {lineTop(expandedLine) +
|
|
633
|
+
lineHeightPx(expandedLine)}px; height: {frameBlockHeight}px;"
|
|
634
|
+
>
|
|
635
|
+
<div style="padding: {FRAME_BLOCK_PAD}px 0;">
|
|
636
|
+
{#if expandedFrames}
|
|
637
|
+
{#each expandedFrames as frame, fi (fi)}
|
|
638
|
+
<div style="margin-top: {fi > 0 ? FRAME_GAP : 0}px;">
|
|
639
|
+
<div
|
|
640
|
+
class="px-3 text-xs text-[var(--rw-con-dim)] select-none"
|
|
641
|
+
style="height: {lineHeight}px; line-height: {lineHeight}px;"
|
|
642
|
+
>
|
|
643
|
+
Frame {fi + 1} of {expandedFrames.length}
|
|
644
|
+
</div>
|
|
645
|
+
{#each frame as row, ri (ri)}
|
|
646
|
+
<div
|
|
647
|
+
class="flex items-center"
|
|
648
|
+
style="height: {lineHeight}px;"
|
|
649
|
+
>
|
|
650
|
+
<div
|
|
651
|
+
class="flex-shrink-0 pr-3 text-right text-[var(--rw-con-gutter)] select-none"
|
|
652
|
+
style="width: {gutterWidth}px;"
|
|
653
|
+
></div>
|
|
654
|
+
<div class="flex-1 pr-4 text-[var(--rw-con-text)]">
|
|
655
|
+
{@render ansiLine(row)}
|
|
656
|
+
</div>
|
|
657
|
+
</div>
|
|
658
|
+
{/each}
|
|
659
|
+
</div>
|
|
660
|
+
{/each}
|
|
661
|
+
{:else}
|
|
662
|
+
<div
|
|
663
|
+
class="px-3 text-xs text-[var(--rw-con-dim)] italic"
|
|
664
|
+
style="line-height: {lineHeight}px; padding-left: {gutterWidth}px;"
|
|
665
|
+
>
|
|
666
|
+
{expandedError ? "Failed to load frame history" : "Loading frames…"}
|
|
667
|
+
</div>
|
|
668
|
+
{/if}
|
|
669
|
+
</div>
|
|
670
|
+
</div>
|
|
671
|
+
{/if}
|
|
672
|
+
|
|
673
|
+
{#each overlayRows as row, i (i)}
|
|
674
|
+
<div
|
|
675
|
+
class="log-line log-line--live absolute right-0 left-0 flex items-center"
|
|
676
|
+
style="top: {lineTop(cache.totalLines + i)}px; height: {lineHeight}px;"
|
|
677
|
+
>
|
|
678
|
+
<div
|
|
679
|
+
class="sticky left-0 z-10 flex-shrink-0 bg-[var(--rw-con-bg)] pr-3 text-right text-[var(--rw-con-gutter)] select-none"
|
|
680
|
+
style="width: {gutterWidth}px;"
|
|
681
|
+
>
|
|
682
|
+
⋯
|
|
683
|
+
</div>
|
|
684
|
+
<!-- The cursor rides the end of the last live overlay row so a
|
|
685
|
+
still-being-appended line shows the caret inline, the way a
|
|
686
|
+
terminal parks it before the next byte arrives. -->
|
|
687
|
+
<div class="flex-1 pr-4 text-[var(--rw-con-text)]">
|
|
688
|
+
{@render ansiLine(
|
|
689
|
+
row,
|
|
690
|
+
)}{#if !cache.finished && i === overlayRows.length - 1}<span
|
|
691
|
+
class="stream-cursor"
|
|
692
|
+
aria-hidden="true"
|
|
693
|
+
></span>{/if}
|
|
694
|
+
</div>
|
|
695
|
+
</div>
|
|
696
|
+
{/each}
|
|
697
|
+
|
|
698
|
+
<!-- Streaming cursor on a fresh line: shown only when the last
|
|
699
|
+
committed line ended with a newline (no live tail). When output
|
|
700
|
+
is still being appended mid-line, the cursor rides the end of the
|
|
701
|
+
last overlay row instead (see the overlay loop above). -->
|
|
702
|
+
{#if isStreaming && overlayRows.length === 0}
|
|
300
703
|
<div
|
|
301
704
|
class="streaming-indicator absolute right-0 left-0 flex items-center"
|
|
302
705
|
style="top: {lineTop(cache.totalLines)}px; height: {lineHeight}px;"
|
|
303
706
|
>
|
|
304
707
|
<div
|
|
305
|
-
class="flex-shrink-0 pr-3 text-right select-none"
|
|
708
|
+
class="sticky left-0 z-10 flex-shrink-0 bg-[var(--rw-con-bg)] pr-3 text-right text-[var(--rw-con-gutter)] select-none"
|
|
306
709
|
style="width: {gutterWidth}px;"
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
<span class="
|
|
710
|
+
>
|
|
711
|
+
{cache.totalLines + 1}
|
|
712
|
+
</div>
|
|
713
|
+
<div class="flex-1 pr-4">
|
|
714
|
+
<span class="stream-cursor" aria-hidden="true"></span>
|
|
312
715
|
</div>
|
|
313
716
|
</div>
|
|
314
717
|
{/if}
|
|
315
718
|
|
|
719
|
+
{#if cache.finished && cache.totalLines > 0}
|
|
720
|
+
<div
|
|
721
|
+
class="sentinel absolute right-4 left-4 flex justify-center select-none"
|
|
722
|
+
style="top: {lineTop(cache.totalLines)}px; height: {lineHeight * 2}px;"
|
|
723
|
+
>
|
|
724
|
+
<span
|
|
725
|
+
class="mt-[14px] border-t border-dashed pt-3 text-center text-[11px] tracking-wide {endTone ===
|
|
726
|
+
'warn'
|
|
727
|
+
? 'sentinel--warn'
|
|
728
|
+
: 'sentinel--muted'}"
|
|
729
|
+
>
|
|
730
|
+
── {endLabel} ──
|
|
731
|
+
</span>
|
|
732
|
+
</div>
|
|
733
|
+
{/if}
|
|
734
|
+
|
|
316
735
|
{#if cache.totalLines > 0}
|
|
317
736
|
{#each Array.from({ length: BLANK_LINES_AT_END }, (_, i) => i) as i (i)}
|
|
318
737
|
<div
|
|
319
738
|
class="absolute right-0 left-0 flex items-center"
|
|
320
739
|
style="top: {lineTop(
|
|
321
|
-
cache.
|
|
740
|
+
cache.finished
|
|
741
|
+
? cache.totalLines + 2 + i
|
|
742
|
+
: cache.totalLines +
|
|
743
|
+
overlayRows.length +
|
|
744
|
+
(isStreaming && overlayRows.length === 0 ? 1 : 0) +
|
|
745
|
+
i,
|
|
322
746
|
)}px; height: {lineHeight}px;"
|
|
323
747
|
>
|
|
324
748
|
<div
|
|
325
|
-
class="flex-shrink-0 pr-3 text-right
|
|
749
|
+
class="sticky left-0 z-10 flex-shrink-0 bg-[var(--rw-con-bg)] pr-3 text-right opacity-0 select-none"
|
|
326
750
|
style="width: {gutterWidth}px;"
|
|
327
751
|
>
|
|
328
752
|
~
|
|
@@ -331,17 +755,21 @@
|
|
|
331
755
|
{/each}
|
|
332
756
|
{/if}
|
|
333
757
|
|
|
334
|
-
{#if cache.totalLines === 0 && !fetcher?.isFetching}
|
|
335
|
-
<div
|
|
758
|
+
{#if cache.totalLines === 0 && overlayRows.length === 0 && !fetcher?.isFetching}
|
|
759
|
+
<div
|
|
760
|
+
class="absolute inset-0 flex items-center justify-center text-[var(--rw-con-dim)]"
|
|
761
|
+
>
|
|
336
762
|
<div class="text-center">
|
|
337
763
|
<div class="mb-1 text-lg">No output yet</div>
|
|
338
|
-
<div class="text-sm text-
|
|
764
|
+
<div class="text-sm text-[var(--rw-con-gutter)]">Waiting for logs...</div>
|
|
339
765
|
</div>
|
|
340
766
|
</div>
|
|
341
767
|
{/if}
|
|
342
768
|
|
|
343
769
|
{#if cache.totalLines === 0 && fetcher?.isFetching}
|
|
344
|
-
<div
|
|
770
|
+
<div
|
|
771
|
+
class="absolute inset-0 flex items-center justify-center text-[var(--rw-con-dim)]"
|
|
772
|
+
>
|
|
345
773
|
<div class="flex items-center gap-2">
|
|
346
774
|
<span class="dot" style="animation-delay: 0ms;"></span>
|
|
347
775
|
<span class="dot" style="animation-delay: 150ms;"></span>
|
|
@@ -356,11 +784,11 @@
|
|
|
356
784
|
{#if userScrolledUp && cache.totalLines > 0}
|
|
357
785
|
<button
|
|
358
786
|
class="absolute right-4 bottom-12 z-10 flex items-center gap-2 rounded-full border
|
|
359
|
-
border-
|
|
787
|
+
border-[rgb(255_255_255_/_0.1)] bg-[var(--rw-con-panel)]
|
|
360
788
|
px-3 py-1.5 text-xs
|
|
361
|
-
font-medium text-
|
|
789
|
+
font-medium text-[var(--rw-con-text)] shadow-lg
|
|
362
790
|
transition-all duration-200 hover:scale-105
|
|
363
|
-
hover:bg-
|
|
791
|
+
hover:bg-[rgb(255_255_255_/_0.06)] active:scale-95"
|
|
364
792
|
onclick={enableAutoScroll}
|
|
365
793
|
>
|
|
366
794
|
<svg
|
|
@@ -377,20 +805,20 @@
|
|
|
377
805
|
{/if}
|
|
378
806
|
|
|
379
807
|
<div
|
|
380
|
-
class="flex flex-shrink-0 items-center justify-between border-t border-
|
|
381
|
-
bg-
|
|
808
|
+
class="flex flex-shrink-0 items-center justify-between border-t border-[rgb(255_255_255_/_0.06)]
|
|
809
|
+
bg-[var(--rw-con-panel)] px-3.5 py-2 text-[11px] text-[var(--rw-con-dim)]"
|
|
382
810
|
>
|
|
383
811
|
<div class="flex items-center gap-3">
|
|
384
812
|
{#if isStreaming}
|
|
385
|
-
<div class="flex items-center gap-1.5 text-
|
|
813
|
+
<div class="flex items-center gap-1.5 text-aurora-400">
|
|
386
814
|
<div class="h-1.5 w-1.5 animate-pulse rounded-full bg-aurora-400"></div>
|
|
387
815
|
Streaming
|
|
388
816
|
</div>
|
|
389
817
|
{:else if cache.finished}
|
|
390
|
-
<span
|
|
818
|
+
<span>Stream ended</span>
|
|
391
819
|
{/if}
|
|
392
820
|
{#if fetcher?.isFetching}
|
|
393
|
-
<span
|
|
821
|
+
<span>Fetching...</span>
|
|
394
822
|
{/if}
|
|
395
823
|
</div>
|
|
396
824
|
<div class="flex items-center gap-4">
|
|
@@ -399,7 +827,21 @@
|
|
|
399
827
|
<span>{formatBytes(cache.totalBytes)}</span>
|
|
400
828
|
{/if}
|
|
401
829
|
{#if isAutoScroll}
|
|
402
|
-
<span class="text-
|
|
830
|
+
<span class="inline-flex items-center gap-1.5 text-[#7fe0b0]">
|
|
831
|
+
<svg
|
|
832
|
+
class="h-3 w-3"
|
|
833
|
+
viewBox="0 0 24 24"
|
|
834
|
+
fill="none"
|
|
835
|
+
stroke="currentColor"
|
|
836
|
+
stroke-width="2.2"
|
|
837
|
+
stroke-linecap="round"
|
|
838
|
+
stroke-linejoin="round"
|
|
839
|
+
>
|
|
840
|
+
<line x1="12" y1="5" x2="12" y2="19" />
|
|
841
|
+
<polyline points="6 13 12 19 18 13" />
|
|
842
|
+
</svg>
|
|
843
|
+
Auto-scroll
|
|
844
|
+
</span>
|
|
403
845
|
{/if}
|
|
404
846
|
</div>
|
|
405
847
|
</div>
|
|
@@ -408,12 +850,34 @@
|
|
|
408
850
|
<style>
|
|
409
851
|
.log-console {
|
|
410
852
|
--log-line-height: 20px;
|
|
853
|
+
tab-size: 8;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/* End-of-output marker: a centred label sitting under a dashed rule, the
|
|
857
|
+
way the approved design closes a finished capture. */
|
|
858
|
+
.sentinel--muted {
|
|
859
|
+
color: var(--rw-con-gutter);
|
|
860
|
+
border-top-color: rgb(255 255 255 / 0.08);
|
|
861
|
+
}
|
|
862
|
+
.sentinel--warn {
|
|
863
|
+
color: #e5a552;
|
|
864
|
+
border-top-color: rgb(229 165 82 / 0.3);
|
|
411
865
|
}
|
|
412
866
|
|
|
413
867
|
.log-line--flash {
|
|
414
868
|
animation: log-line-flash 1.5s ease-out;
|
|
415
869
|
}
|
|
416
870
|
|
|
871
|
+
.frame-toggle {
|
|
872
|
+
cursor: pointer;
|
|
873
|
+
font-size: 0.85em;
|
|
874
|
+
line-height: 1;
|
|
875
|
+
background: transparent;
|
|
876
|
+
border: none;
|
|
877
|
+
padding: 0;
|
|
878
|
+
transition: color 120ms ease;
|
|
879
|
+
}
|
|
880
|
+
|
|
417
881
|
@keyframes log-line-flash {
|
|
418
882
|
0% {
|
|
419
883
|
background-color: oklch(0.8 0.18 70 / 0.45);
|
|
@@ -423,6 +887,37 @@
|
|
|
423
887
|
}
|
|
424
888
|
}
|
|
425
889
|
|
|
890
|
+
/* Blinking teal block caret shown while a run is actively streaming. One
|
|
891
|
+
monospace cell wide so it reads as a terminal cursor parked at the write
|
|
892
|
+
position — inline after a still-appending line, or alone on the next. */
|
|
893
|
+
.stream-cursor {
|
|
894
|
+
display: inline-block;
|
|
895
|
+
width: 1ch;
|
|
896
|
+
height: 1.1em;
|
|
897
|
+
vertical-align: text-bottom;
|
|
898
|
+
background-color: var(--color-aurora-400);
|
|
899
|
+
animation: stream-cursor-blink 1.05s steps(2, start) infinite;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
@keyframes stream-cursor-blink {
|
|
903
|
+
0%,
|
|
904
|
+
50% {
|
|
905
|
+
opacity: 1;
|
|
906
|
+
}
|
|
907
|
+
50.01%,
|
|
908
|
+
100% {
|
|
909
|
+
opacity: 0;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/* Reduced motion: keep the caret solid (still a clear "live" marker) rather
|
|
914
|
+
than blinking. */
|
|
915
|
+
@media (prefers-reduced-motion: reduce) {
|
|
916
|
+
.stream-cursor {
|
|
917
|
+
animation: none;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
426
921
|
.dot {
|
|
427
922
|
display: inline-block;
|
|
428
923
|
width: 4px;
|