@appscode/design-system 2.17.87 → 2.17.91-alpha.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.
@@ -0,0 +1,953 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from "vue";
3
+ import { useResizeObserver } from "@vueuse/core";
4
+ import HeroiconsArrowPath from "~icons/heroicons/arrow-path";
5
+
6
+ type TimeValue = number | string | Date;
7
+ type StatusType = "success" | "warning" | "danger";
8
+ type LegendKey = "recoverable" | "snapshot" | "failed" | "scheduled" | "selected";
9
+
10
+ interface TimeRange {
11
+ start: TimeValue;
12
+ end: TimeValue;
13
+ }
14
+
15
+ interface Tick {
16
+ time: TimeValue;
17
+ label?: string;
18
+ highlight?: boolean;
19
+ }
20
+
21
+ interface LegendLabels {
22
+ recoverable?: string;
23
+ snapshot?: string;
24
+ failed?: string;
25
+ scheduled?: string;
26
+ selected?: string;
27
+ }
28
+
29
+ interface TimelineRow {
30
+ // unique key; defaults to the row index
31
+ key?: string;
32
+ label: string;
33
+ // colors the dot in front of the label
34
+ status?: StatusType;
35
+ // "continuous" fills the recoverable ranges (e.g. PITR), "discrete" draws every snapshot as a block;
36
+ // defaults to "continuous" when the row has recoverable ranges
37
+ variant?: "continuous" | "discrete";
38
+ recoverableRanges?: TimeRange[];
39
+ snapshots?: TimeValue[];
40
+ failedCheckpoints?: TimeValue[];
41
+ // upcoming runs, drawn as outlined blocks
42
+ scheduled?: TimeValue[];
43
+ }
44
+
45
+ interface RangeOption {
46
+ label: string;
47
+ value: string;
48
+ }
49
+
50
+ interface LegendNote {
51
+ label: string;
52
+ value: string;
53
+ }
54
+
55
+ interface Props {
56
+ title?: string;
57
+ subtitle?: string;
58
+ statusLabel?: string;
59
+ statusText?: string;
60
+ statusType?: StatusType;
61
+ // visible time range of the timeline
62
+ rangeStart: TimeValue;
63
+ rangeEnd: TimeValue;
64
+ // current time marker; defaults to rangeEnd
65
+ now?: TimeValue;
66
+ nowLabel?: string;
67
+ // data of the single timeline; ignored when `rows` is set
68
+ recoverableRanges?: TimeRange[];
69
+ snapshots?: TimeValue[];
70
+ failedCheckpoints?: TimeValue[];
71
+ // labeled timelines sharing one time axis, e.g. a database with its config and cache snapshots
72
+ rows?: TimelineRow[];
73
+ // explicit tick marks; when empty, `tickCount` evenly spaced ticks are generated
74
+ ticks?: Tick[];
75
+ tickCount?: number;
76
+ // formats a timestamp (ms) for tick labels and tooltips
77
+ formatTime?: (time: number) => string;
78
+ // range presets shown as a toggle in the header, e.g. 24h / 7d / 30d; the active one is `v-model:range`
79
+ rangeOptions?: RangeOption[];
80
+ // show a refresh button in the header that emits `refresh`
81
+ showRefresh?: boolean;
82
+ legendLabels?: LegendLabels;
83
+ // legend entries in display order; defaults depend on the layout
84
+ legendItems?: LegendKey[];
85
+ // shown at the end of the legend as "label: value", e.g. "etcd interval: 2h"
86
+ legendNote?: LegendNote;
87
+ showLegend?: boolean;
88
+ // allow selecting a timestamp by clicking inside a recoverable range or on a snapshot block
89
+ selectable?: boolean;
90
+ modifierClasses?: string;
91
+ }
92
+
93
+ const props = withDefaults(defineProps<Props>(), {
94
+ title: "Recoverable Window",
95
+ subtitle: "",
96
+ statusLabel: "Continuous Log Streaming",
97
+ statusText: "",
98
+ statusType: "success",
99
+ now: undefined,
100
+ nowLabel: "now",
101
+ recoverableRanges: () => [],
102
+ snapshots: () => [],
103
+ failedCheckpoints: () => [],
104
+ rows: () => [],
105
+ ticks: () => [],
106
+ tickCount: 6,
107
+ formatTime: (time: number) => {
108
+ const d = new Date(time);
109
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
110
+ },
111
+ rangeOptions: () => [],
112
+ showRefresh: false,
113
+ legendLabels: () => ({}),
114
+ legendItems: undefined,
115
+ legendNote: undefined,
116
+ showLegend: true,
117
+ selectable: true,
118
+ modifierClasses: "",
119
+ });
120
+
121
+ const emit = defineEmits<{
122
+ // `row` is the entry of `rows` the timestamp was picked from
123
+ select: [time: number, row?: TimelineRow];
124
+ refresh: [];
125
+ }>();
126
+
127
+ // selected timestamp in ms
128
+ const selected = defineModel<number | null>({ default: null });
129
+ // value of the active range option
130
+ const range = defineModel<string>("range", { default: "" });
131
+
132
+ const activeRange = computed(() => range.value || props.rangeOptions[0]?.value);
133
+
134
+ const toMs = (value: TimeValue) => new Date(value).getTime();
135
+
136
+ const startMs = computed(() => toMs(props.rangeStart));
137
+ const endMs = computed(() => toMs(props.rangeEnd));
138
+ const nowMs = computed(() => (props.now !== undefined ? toMs(props.now) : endMs.value));
139
+ const span = computed(() => Math.max(endMs.value - startMs.value, 1));
140
+
141
+ const toPercent = (time: number) => Math.min(Math.max(((time - startMs.value) / span.value) * 100, 0), 100);
142
+
143
+ interface Marker {
144
+ time: number;
145
+ left: number;
146
+ }
147
+
148
+ interface Lane {
149
+ key: string;
150
+ row: TimelineRow;
151
+ variant: "continuous" | "discrete";
152
+ ranges: { start: number; end: number; left: number; width: number }[];
153
+ snapshots: Marker[];
154
+ failed: Marker[];
155
+ scheduled: Marker[];
156
+ blockWidth?: string;
157
+ }
158
+
159
+ const toMarkers = (times: TimeValue[] = []): Marker[] =>
160
+ times.map((t) => ({ time: toMs(t), left: toPercent(toMs(t)) }));
161
+
162
+ // narrow the blocks when snapshots sit closer together than a block is wide (long ranges)
163
+ const blockWidth = (markers: Marker[]) => {
164
+ const lefts = markers.map((m) => m.left).sort((a, b) => a - b);
165
+ const gaps = lefts
166
+ .slice(1)
167
+ .map((left, i) => left - lefts[i])
168
+ .filter((gap) => gap > 0);
169
+ return gaps.length ? `min(10px, ${Math.min(...gaps) * 0.6}%)` : undefined;
170
+ };
171
+
172
+ const isMulti = computed(() => props.rows.length > 0);
173
+
174
+ // without `rows` the component renders one unlabeled continuous lane from the top level props
175
+ const lanes = computed(() => {
176
+ const rows: TimelineRow[] = isMulti.value
177
+ ? props.rows
178
+ : [
179
+ {
180
+ label: props.title,
181
+ recoverableRanges: props.recoverableRanges,
182
+ snapshots: props.snapshots,
183
+ failedCheckpoints: props.failedCheckpoints,
184
+ },
185
+ ];
186
+ return rows.map((row, idx): Lane => {
187
+ const snapshots = toMarkers(row.snapshots);
188
+ const failed = toMarkers(row.failedCheckpoints);
189
+ const scheduled = toMarkers(row.scheduled);
190
+ return {
191
+ key: row.key ?? String(idx),
192
+ row,
193
+ variant: row.variant ?? (!isMulti.value || row.recoverableRanges?.length ? "continuous" : "discrete"),
194
+ ranges: (row.recoverableRanges ?? []).map((r) => {
195
+ const start = toMs(r.start);
196
+ const end = toMs(r.end);
197
+ const left = toPercent(start);
198
+ return { start, end, left, width: toPercent(end) - left };
199
+ }),
200
+ snapshots,
201
+ failed,
202
+ scheduled,
203
+ blockWidth: blockWidth([...snapshots, ...failed, ...scheduled]),
204
+ };
205
+ });
206
+ });
207
+
208
+ const tickMarks = computed(() => {
209
+ const source: Tick[] = props.ticks.length
210
+ ? props.ticks
211
+ : Array.from({ length: props.tickCount }, (_, i) => ({
212
+ time: startMs.value + (span.value / props.tickCount) * i,
213
+ }));
214
+ return source.map((tick) => {
215
+ const time = toMs(tick.time);
216
+ return { time, left: toPercent(time), label: tick.label ?? props.formatTime(time), highlight: !!tick.highlight };
217
+ });
218
+ });
219
+
220
+ // grid lines inside the continuous rows
221
+ const gridLines = computed(() => tickMarks.value.filter((tick) => tick.left > 0 && tick.left < 100));
222
+
223
+ const isRecoverable = (lane: Lane, time: number) => lane.ranges.some((r) => time >= r.start && time <= r.end);
224
+
225
+ const legend = computed(() => ({
226
+ recoverable: "Any point recoverable (WAL Replay)",
227
+ snapshot: "Consistent Snapshot",
228
+ failed: "Failed checkpoint",
229
+ scheduled: "Next scheduled",
230
+ selected: "Selected Timestamp",
231
+ ...props.legendLabels,
232
+ }));
233
+
234
+ const legendKeys = computed<LegendKey[]>(
235
+ () =>
236
+ props.legendItems ??
237
+ (isMulti.value
238
+ ? ["recoverable", "snapshot", "failed", "scheduled"]
239
+ : ["recoverable", "snapshot", "failed", "selected"]),
240
+ );
241
+
242
+ // rows only mark the selection where it is recoverable
243
+ const selectedTime = computed(() => selected.value ?? null);
244
+
245
+ const select = (time: number, lane: Lane) => {
246
+ if (!props.selectable) return;
247
+ selected.value = time;
248
+ emit("select", time, isMulti.value ? lane.row : undefined);
249
+ };
250
+
251
+ // hover indicator
252
+ const hover = ref<{ lane: string; time: number } | null>(null);
253
+
254
+ const timeFromEvent = (event: MouseEvent) => {
255
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
256
+ const ratio = Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1);
257
+ return Math.round(startMs.value + ratio * span.value);
258
+ };
259
+
260
+ const onMouseMove = (event: MouseEvent, lane: Lane) => {
261
+ if (!props.selectable || lane.variant !== "continuous") return;
262
+ const time = timeFromEvent(event);
263
+ hover.value = isRecoverable(lane, time) ? { lane: lane.key, time } : null;
264
+ };
265
+
266
+ const onClick = (event: MouseEvent, lane: Lane) => {
267
+ if (!props.selectable || lane.variant !== "continuous") return;
268
+ const time = timeFromEvent(event);
269
+ if (isRecoverable(lane, time)) select(time, lane);
270
+ };
271
+
272
+ // hide the tick labels the now label would overlap
273
+ const ticksRef = ref<HTMLElement | null>(null);
274
+ const nowLabelRef = ref<HTMLElement | null>(null);
275
+ const coveredTicks = ref<number[]>([]);
276
+
277
+ const updateCoveredTicks = () => {
278
+ if (!ticksRef.value || !nowLabelRef.value?.offsetWidth) {
279
+ coveredTicks.value = [];
280
+ return;
281
+ }
282
+ const nowRect = nowLabelRef.value.getBoundingClientRect();
283
+ const labels = Array.from(ticksRef.value.querySelectorAll<HTMLElement>(".rw-tick:not(.is-now)"));
284
+ coveredTicks.value = labels.flatMap((label, idx) => {
285
+ const rect = label.getBoundingClientRect();
286
+ return rect.right + 8 > nowRect.left && rect.left - 8 < nowRect.right ? [idx] : [];
287
+ });
288
+ };
289
+
290
+ useResizeObserver(ticksRef, updateCoveredTicks);
291
+ useResizeObserver(nowLabelRef, updateCoveredTicks);
292
+ watch([tickMarks, nowMs, isMulti], updateCoveredTicks, { flush: "post" });
293
+ </script>
294
+
295
+ <template>
296
+ <div class="recoverable-window" :class="[modifierClasses, { 'is-multi': isMulti }]">
297
+ <div class="rw-header is-flex is-justify-content-space-between is-align-items-center is-flex-wrap-wrap gap-8">
298
+ <div>
299
+ <h6 class="rw-title mb-0">{{ title }}</h6>
300
+ <p v-if="subtitle" class="rw-subtitle mb-0">{{ subtitle }}</p>
301
+ </div>
302
+ <div
303
+ v-if="statusText || rangeOptions.length || showRefresh || $slots.actions"
304
+ class="is-flex is-align-items-center is-flex-wrap-wrap gap-8"
305
+ >
306
+ <p v-if="statusText" class="rw-status mb-0">
307
+ {{ statusLabel }}: <strong :class="`is-${statusType}`">{{ statusText }}</strong>
308
+ </p>
309
+ <slot name="actions">
310
+ <div v-if="rangeOptions.length" class="rw-range-toggle" role="group" aria-label="Time range">
311
+ <button
312
+ v-for="option in rangeOptions"
313
+ :key="option.value"
314
+ type="button"
315
+ class="rw-range-option"
316
+ :class="{ 'is-active': option.value === activeRange }"
317
+ :aria-pressed="option.value === activeRange"
318
+ @click="range = option.value"
319
+ >
320
+ {{ option.label }}
321
+ </button>
322
+ </div>
323
+ <button
324
+ v-if="showRefresh"
325
+ type="button"
326
+ class="rw-refresh"
327
+ title="Refresh"
328
+ aria-label="Refresh"
329
+ @click="emit('refresh')"
330
+ >
331
+ <HeroiconsArrowPath />
332
+ </button>
333
+ </slot>
334
+ </div>
335
+ </div>
336
+
337
+ <div class="rw-lanes">
338
+ <template v-for="lane in lanes" :key="lane.key">
339
+ <div v-if="isMulti" class="rw-lane-label" :title="lane.row.label">
340
+ <span v-if="lane.row.status" class="rw-dot" :class="`is-${lane.row.status}`" />
341
+ <span class="rw-lane-name">{{ lane.row.label }}</span>
342
+ </div>
343
+ <div
344
+ class="rw-track"
345
+ :class="[`is-${lane.variant}`, { 'is-selectable': selectable && lane.variant === 'continuous' }]"
346
+ :style="{ '--rw-block-width': lane.blockWidth }"
347
+ @mousemove="onMouseMove($event, lane)"
348
+ @mouseleave="hover = null"
349
+ @click="onClick($event, lane)"
350
+ >
351
+ <template v-if="lane.variant === 'continuous'">
352
+ <span
353
+ v-for="(r, idx) in lane.ranges"
354
+ :key="`range-${idx}`"
355
+ class="rw-range"
356
+ :style="{ left: `${r.left}%`, width: `${r.width}%` }"
357
+ />
358
+ <template v-if="isMulti">
359
+ <span
360
+ v-for="(tick, idx) in gridLines"
361
+ :key="`grid-${idx}`"
362
+ class="rw-grid"
363
+ :style="{ left: `${tick.left}%` }"
364
+ />
365
+ </template>
366
+ <span
367
+ v-for="(marker, idx) in lane.snapshots"
368
+ :key="`snap-${idx}`"
369
+ class="rw-marker is-snapshot"
370
+ :style="{ left: `${marker.left}%` }"
371
+ :title="`${legend.snapshot}: ${formatTime(marker.time)}`"
372
+ />
373
+ <span
374
+ v-for="(marker, idx) in lane.failed"
375
+ :key="`fail-${idx}`"
376
+ class="rw-marker is-failed"
377
+ :style="{ left: `${marker.left}%` }"
378
+ :title="`${legend.failed}: ${formatTime(marker.time)}`"
379
+ />
380
+ <span v-if="hover?.lane === lane.key" class="rw-hover" :style="{ left: `${toPercent(hover.time)}%` }">
381
+ <span class="rw-hover-label">{{ formatTime(hover.time) }}</span>
382
+ </span>
383
+ <span
384
+ v-if="selectedTime !== null && (!isMulti || isRecoverable(lane, selectedTime))"
385
+ class="rw-marker is-selected"
386
+ :style="{ left: `${toPercent(selectedTime)}%` }"
387
+ :title="`${legend.selected}: ${formatTime(selectedTime)}`"
388
+ />
389
+ <span v-if="!isMulti" class="rw-marker is-now" :style="{ left: `${toPercent(nowMs)}%` }" />
390
+ </template>
391
+ <template v-else>
392
+ <span
393
+ v-for="(marker, idx) in lane.snapshots"
394
+ :key="`snap-${idx}`"
395
+ class="rw-block is-snapshot"
396
+ :class="{ 'is-clickable': selectable, 'is-selected': marker.time === selectedTime }"
397
+ :style="{ left: `${marker.left}%` }"
398
+ :title="`${legend.snapshot}: ${formatTime(marker.time)}`"
399
+ @click="select(marker.time, lane)"
400
+ />
401
+ <span
402
+ v-for="(marker, idx) in lane.failed"
403
+ :key="`fail-${idx}`"
404
+ class="rw-block is-failed"
405
+ :style="{ left: `${marker.left}%` }"
406
+ :title="`${legend.failed}: ${formatTime(marker.time)}`"
407
+ />
408
+ <span
409
+ v-for="(marker, idx) in lane.scheduled"
410
+ :key="`next-${idx}`"
411
+ class="rw-block is-scheduled"
412
+ :style="{ left: `${marker.left}%` }"
413
+ :title="`${legend.scheduled}: ${formatTime(marker.time)}`"
414
+ />
415
+ </template>
416
+ </div>
417
+ </template>
418
+ </div>
419
+
420
+ <div class="rw-axis">
421
+ <div ref="ticksRef" class="rw-ticks">
422
+ <span
423
+ v-for="(tick, idx) in tickMarks"
424
+ :key="`tick-${idx}`"
425
+ class="rw-tick"
426
+ :class="{
427
+ 'is-highlight': tick.highlight,
428
+ 'is-first': tick.left === 0,
429
+ 'is-covered': coveredTicks.includes(idx),
430
+ }"
431
+ :style="{ left: `${tick.left}%` }"
432
+ >
433
+ {{ tick.label }}
434
+ </span>
435
+ <span ref="nowLabelRef" class="rw-tick is-now" :style="isMulti ? undefined : { left: `${toPercent(nowMs)}%` }">
436
+ {{ nowLabel }}
437
+ </span>
438
+ </div>
439
+ </div>
440
+
441
+ <div v-if="showLegend" class="rw-legend is-flex is-flex-wrap-wrap is-align-items-center">
442
+ <span v-for="key in legendKeys" :key="key" class="rw-legend-item">
443
+ <span class="rw-swatch" :class="`is-${key}`" />{{ legend[key] }}
444
+ </span>
445
+ <span v-if="legendNote" class="rw-legend-note">
446
+ {{ legendNote.label }}: <strong>{{ legendNote.value }}</strong>
447
+ </span>
448
+ </div>
449
+ </div>
450
+ </template>
451
+
452
+ <style scoped lang="scss">
453
+ .recoverable-window {
454
+ --rw-recoverable: hsl(158, 59%, 49%);
455
+ --rw-recoverable-start: hsl(161, 94%, 31%);
456
+ --rw-recoverable-end: hsl(158, 63%, 51%);
457
+ --rw-snapshot: hsl(145, 60%, 24%);
458
+ --rw-block: hsl(149, 100%, 30%);
459
+ --rw-failed: hsl(0, 84%, 60%);
460
+ --rw-scheduled: #{$slate-60};
461
+ --rw-selected: #{$color-heading};
462
+ --rw-track-bg: #{$slate-95};
463
+ --rw-border: #{$color-border-light};
464
+ --rw-divider: #{$color-border-light};
465
+ --rw-tick-color: #{$color-label};
466
+ --rw-label-width: 152px;
467
+ --rw-mono: "Inconsolata", monospace;
468
+
469
+ padding: 24px 28px;
470
+ border: 1px solid var(--rw-border);
471
+ border-radius: 12px;
472
+ background-color: $white-100;
473
+
474
+ // labeled rows layout
475
+ &.is-multi {
476
+ --rw-recoverable: hsl(160, 84%, 39%);
477
+ --rw-failed: hsl(0, 72%, 51%);
478
+ --rw-track-bg: #{$slate-90};
479
+ --rw-border: #{$color-border};
480
+ --rw-tick-color: #{$slate-50};
481
+
482
+ padding: 24px;
483
+
484
+ @media (max-width: 576px) {
485
+ --rw-label-width: 108px;
486
+
487
+ padding: 16px;
488
+ }
489
+ }
490
+ }
491
+
492
+ .rw-header {
493
+ margin-bottom: 8px;
494
+ padding-bottom: 4px;
495
+
496
+ .is-multi & {
497
+ margin-bottom: 24px;
498
+ padding-bottom: 16px;
499
+ border-bottom: 1px solid var(--rw-divider);
500
+ }
501
+ }
502
+
503
+ .rw-title {
504
+ font-size: 14px;
505
+ font-weight: 600;
506
+ color: $color-heading;
507
+
508
+ .is-multi & {
509
+ font-size: 16px;
510
+ }
511
+ }
512
+
513
+ .rw-subtitle {
514
+ margin-top: 2px;
515
+ font-size: 13px;
516
+ color: $slate-50;
517
+ }
518
+
519
+ .rw-status {
520
+ font-family: var(--rw-mono);
521
+ font-size: 13px;
522
+ color: $color-label;
523
+
524
+ strong {
525
+ font-weight: 700;
526
+
527
+ &.is-success {
528
+ color: hsl(145, 60%, 32%);
529
+ }
530
+ &.is-warning {
531
+ color: hsl(36, 90%, 40%);
532
+ }
533
+ &.is-danger {
534
+ color: var(--rw-failed);
535
+ }
536
+ }
537
+ }
538
+
539
+ .rw-range-toggle {
540
+ display: inline-flex;
541
+ gap: 2px;
542
+ padding: 2px;
543
+ border: 1px solid $color-border;
544
+ border-radius: 8px;
545
+ background-color: $white-100;
546
+ }
547
+
548
+ .rw-range-option,
549
+ .rw-refresh {
550
+ border: 0;
551
+ font-size: 13px;
552
+ line-height: 18px;
553
+ color: $slate-40;
554
+ background-color: transparent;
555
+ cursor: pointer;
556
+
557
+ &:hover {
558
+ color: $color-heading;
559
+ }
560
+
561
+ &:focus-visible {
562
+ outline: 2px solid $color-border-dark;
563
+ outline-offset: 1px;
564
+ }
565
+ }
566
+
567
+ .rw-range-option {
568
+ padding: 3px 10px;
569
+ border-radius: 6px;
570
+ font-weight: 500;
571
+
572
+ &.is-active {
573
+ font-weight: 700;
574
+ color: $color-heading;
575
+ background-color: $slate-95;
576
+ }
577
+ }
578
+
579
+ .rw-refresh {
580
+ display: inline-flex;
581
+ align-items: center;
582
+ justify-content: center;
583
+ width: 30px;
584
+ height: 30px;
585
+ padding: 0;
586
+ border: 1px solid $color-border;
587
+ border-radius: 8px;
588
+ color: $slate-50;
589
+ background-color: $white-100;
590
+
591
+ svg {
592
+ width: 14px;
593
+ height: 14px;
594
+ }
595
+ }
596
+
597
+ .rw-lanes {
598
+ .is-multi & {
599
+ display: grid;
600
+ grid-template-columns: var(--rw-label-width) minmax(0, 1fr);
601
+ align-items: center;
602
+ row-gap: 16px;
603
+ }
604
+ }
605
+
606
+ .rw-lane-label {
607
+ display: flex;
608
+ align-items: center;
609
+ gap: 8px;
610
+ min-width: 0;
611
+ padding-right: 12px;
612
+ font-size: 13px;
613
+ font-weight: 600;
614
+ color: $color-heading;
615
+ }
616
+
617
+ .rw-lane-name {
618
+ overflow: hidden;
619
+ text-overflow: ellipsis;
620
+ white-space: nowrap;
621
+ }
622
+
623
+ .rw-dot {
624
+ flex-shrink: 0;
625
+ width: 8px;
626
+ height: 8px;
627
+ border-radius: 50%;
628
+
629
+ &.is-success {
630
+ background-color: hsl(160, 84%, 39%);
631
+ }
632
+ &.is-warning {
633
+ background-color: hsl(25, 95%, 53%);
634
+ }
635
+ &.is-danger {
636
+ background-color: var(--rw-failed);
637
+ }
638
+ }
639
+
640
+ .rw-track {
641
+ position: relative;
642
+ height: 34px;
643
+ border: 1px solid $color-border;
644
+ background-color: var(--rw-track-bg);
645
+
646
+ &.is-selectable {
647
+ cursor: crosshair;
648
+ }
649
+
650
+ .is-multi & {
651
+ height: 32px;
652
+ border-radius: 6px;
653
+ }
654
+
655
+ .is-multi &.is-discrete {
656
+ height: 28px;
657
+ background-color: $white-100;
658
+ }
659
+ }
660
+
661
+ .rw-range {
662
+ position: absolute;
663
+ top: 0;
664
+ bottom: 0;
665
+ background-color: var(--rw-recoverable);
666
+
667
+ .is-multi & {
668
+ top: 2px;
669
+ bottom: 2px;
670
+ border-radius: 4px;
671
+ background-image: linear-gradient(90deg, var(--rw-recoverable-start), var(--rw-recoverable-end));
672
+ }
673
+ }
674
+
675
+ .rw-grid {
676
+ position: absolute;
677
+ top: 50%;
678
+ width: 2px;
679
+ height: 12px;
680
+ background-color: rgba(255, 255, 255, 0.25);
681
+ transform: translate(-50%, -50%);
682
+ pointer-events: none;
683
+ }
684
+
685
+ .rw-marker {
686
+ position: absolute;
687
+ top: 0;
688
+ bottom: 0;
689
+ width: 3px;
690
+ transform: translateX(-50%);
691
+
692
+ &.is-snapshot {
693
+ background-color: var(--rw-snapshot);
694
+ }
695
+
696
+ &.is-failed {
697
+ background-color: var(--rw-failed);
698
+ }
699
+
700
+ .is-multi &.is-snapshot,
701
+ .is-multi &.is-failed {
702
+ top: 2px;
703
+ bottom: 2px;
704
+ width: 2px;
705
+ }
706
+
707
+ &.is-selected {
708
+ width: 2px;
709
+ background-color: var(--rw-selected);
710
+ z-index: 2;
711
+
712
+ &::before {
713
+ content: "";
714
+ position: absolute;
715
+ top: -4px;
716
+ left: 50%;
717
+ width: 9px;
718
+ height: 9px;
719
+ border-radius: 50%;
720
+ background-color: var(--rw-selected);
721
+ transform: translateX(-50%);
722
+ }
723
+ }
724
+
725
+ &.is-now {
726
+ width: 2px;
727
+ background-color: var(--rw-failed);
728
+ z-index: 3;
729
+ pointer-events: none;
730
+
731
+ &::after {
732
+ content: "";
733
+ position: absolute;
734
+ bottom: -5px;
735
+ left: 50%;
736
+ width: 10px;
737
+ height: 10px;
738
+ border-radius: 50%;
739
+ background-color: var(--rw-failed);
740
+ transform: translateX(-50%);
741
+ }
742
+ }
743
+ }
744
+
745
+ .rw-block {
746
+ position: absolute;
747
+ top: 50%;
748
+ width: var(--rw-block-width, 10px);
749
+ height: 14px;
750
+ border-radius: 2px;
751
+ transform: translate(-50%, -50%);
752
+
753
+ &.is-snapshot {
754
+ background-color: var(--rw-block);
755
+ }
756
+
757
+ &.is-failed {
758
+ background-color: var(--rw-failed);
759
+ }
760
+
761
+ &.is-scheduled {
762
+ border: 1px dashed var(--rw-scheduled);
763
+ }
764
+
765
+ &.is-clickable {
766
+ cursor: pointer;
767
+ }
768
+
769
+ &.is-selected {
770
+ box-shadow:
771
+ 0 0 0 2px $white-100,
772
+ 0 0 0 3px var(--rw-selected);
773
+ }
774
+ }
775
+
776
+ .rw-hover {
777
+ position: absolute;
778
+ top: 0;
779
+ bottom: 0;
780
+ width: 1px;
781
+ background-color: rgba(0, 0, 0, 0.4);
782
+ pointer-events: none;
783
+ z-index: 1;
784
+
785
+ .rw-hover-label {
786
+ position: absolute;
787
+ bottom: calc(100% + 4px);
788
+ left: 50%;
789
+ padding: 2px 6px;
790
+ border-radius: 4px;
791
+ font-family: var(--rw-mono);
792
+ font-size: 11px;
793
+ white-space: nowrap;
794
+ color: $white-100;
795
+ background-color: $color-heading;
796
+ transform: translateX(-50%);
797
+ }
798
+ }
799
+
800
+ .rw-axis {
801
+ .is-multi & {
802
+ display: grid;
803
+ grid-template-columns: var(--rw-label-width) minmax(0, 1fr);
804
+ margin-top: 16px;
805
+ padding: 8px 0 20px;
806
+ border-top: 1px solid var(--rw-divider);
807
+ border-bottom: 1px solid var(--rw-divider);
808
+ }
809
+ }
810
+
811
+ .rw-ticks {
812
+ position: relative;
813
+ height: 18px;
814
+ margin: 10px 0 12px;
815
+ padding-bottom: 14px;
816
+ box-sizing: content-box;
817
+ border-bottom: 1px dashed var(--rw-border);
818
+
819
+ .is-multi & {
820
+ grid-column: 2;
821
+ margin: 0;
822
+ padding: 0;
823
+ border: 0;
824
+ }
825
+ }
826
+
827
+ .rw-tick {
828
+ position: absolute;
829
+ top: 0;
830
+ font-family: var(--rw-mono);
831
+ font-size: 12px;
832
+ color: var(--rw-tick-color);
833
+ white-space: nowrap;
834
+ transform: translateX(-50%);
835
+
836
+ &.is-first {
837
+ transform: translateX(4px);
838
+ }
839
+
840
+ &.is-covered {
841
+ visibility: hidden;
842
+ }
843
+
844
+ &.is-highlight {
845
+ font-weight: 700;
846
+ color: hsl(145, 60%, 32%);
847
+ }
848
+
849
+ &.is-now {
850
+ font-weight: 700;
851
+ color: var(--rw-failed);
852
+ }
853
+
854
+ // pinned to the end of the axis
855
+ .is-multi &.is-now {
856
+ right: 0;
857
+ display: inline-flex;
858
+ align-items: center;
859
+ gap: 6px;
860
+ transform: none;
861
+
862
+ &::before {
863
+ content: "";
864
+ width: 6px;
865
+ height: 6px;
866
+ border-radius: 50%;
867
+ background-color: currentColor;
868
+ }
869
+ }
870
+ }
871
+
872
+ .rw-legend {
873
+ gap: 4px 12px;
874
+ font-size: 13px;
875
+ color: $color-text;
876
+
877
+ .is-multi & {
878
+ gap: 8px 24px;
879
+ padding-top: 12px;
880
+ color: $slate-40;
881
+ }
882
+ }
883
+
884
+ .rw-legend-item {
885
+ display: inline-flex;
886
+ align-items: center;
887
+ gap: 6px;
888
+
889
+ .is-multi & {
890
+ gap: 8px;
891
+ }
892
+ }
893
+
894
+ .rw-legend-note {
895
+ margin-left: auto;
896
+ font-family: var(--rw-mono);
897
+ font-size: 12px;
898
+ color: $slate-60;
899
+
900
+ strong {
901
+ font-weight: 700;
902
+ color: $color-text;
903
+ }
904
+ }
905
+
906
+ .rw-swatch {
907
+ display: inline-block;
908
+ width: 14px;
909
+ height: 10px;
910
+
911
+ &.is-recoverable {
912
+ background-color: var(--rw-recoverable);
913
+ }
914
+ &.is-snapshot {
915
+ width: 8px;
916
+ background-color: var(--rw-snapshot);
917
+ }
918
+ &.is-failed {
919
+ width: 10px;
920
+ background-color: var(--rw-failed);
921
+ }
922
+ &.is-scheduled {
923
+ width: 10px;
924
+ border: 1px dashed var(--rw-scheduled);
925
+ }
926
+ &.is-selected {
927
+ width: 3px;
928
+ height: 16px;
929
+ background-color: var(--rw-selected);
930
+ }
931
+
932
+ // same shapes as the row blocks
933
+ .is-multi & {
934
+ width: 10px;
935
+ height: 12px;
936
+ border-radius: 2px;
937
+
938
+ &.is-recoverable {
939
+ width: 15px;
940
+ height: 15px;
941
+ border-radius: 4px;
942
+ }
943
+ &.is-snapshot {
944
+ background-color: var(--rw-block);
945
+ }
946
+ &.is-selected {
947
+ width: 3px;
948
+ height: 16px;
949
+ border-radius: 0;
950
+ }
951
+ }
952
+ }
953
+ </style>