@kestack/web-platform 0.0.0 → 0.0.2
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/README.md +6 -0
- package/dist/components/viewers/diff-viewer/diff-viewer.js +1 -1
- package/dist/components/viewers/source-explorer/source-explorer.js +1 -1
- package/dist/pwa/shared/config/config.js +1 -1
- package/dist/pwa/worker/requests/strategies/strategies.js +1 -1
- package/dist/recipes/devtools-dock/devtools-dock.d.ts +23 -0
- package/dist/recipes/devtools-dock/devtools-dock.d.ts.map +1 -0
- package/dist/recipes/devtools-dock/devtools-dock.js +343 -0
- package/dist/recipes/devtools-dock/devtools-dock.js.map +1 -0
- package/dist/recipes/devtools-dock/index.d.ts +2 -0
- package/dist/recipes/devtools-dock/index.js +2 -0
- package/dist/recipes/kabuki-devtools/kabuki-devtools.d.ts.map +1 -1
- package/dist/recipes/kabuki-devtools/kabuki-devtools.js +4 -1
- package/dist/recipes/kabuki-devtools/kabuki-devtools.js.map +1 -1
- package/dist/recipes/kabuki-devtools/map/canvas/canvas.d.ts.map +1 -1
- package/dist/recipes/kabuki-devtools/map/canvas/canvas.js +13 -0
- package/dist/recipes/kabuki-devtools/map/canvas/canvas.js.map +1 -1
- package/dist/recipes/kabuki-devtools/scene/tree.js +1 -1
- package/dist/recipes/kabuki-devtools/scene/tree.js.map +1 -1
- package/dist/recipes/kabuki-devtools/sheet/sheet.d.ts.map +1 -1
- package/dist/recipes/kabuki-devtools/sheet/sheet.js +84 -78
- package/dist/recipes/kabuki-devtools/sheet/sheet.js.map +1 -1
- package/dist/recipes/kabuki-devtools/timeline/timeline.d.ts +6 -1
- package/dist/recipes/kabuki-devtools/timeline/timeline.d.ts.map +1 -1
- package/dist/recipes/kabuki-devtools/timeline/timeline.js +202 -16
- package/dist/recipes/kabuki-devtools/timeline/timeline.js.map +1 -1
- package/package.json +17 -17
- package/src/recipes/devtools-dock/devtools-dock.tsx +505 -0
- package/src/recipes/devtools-dock/index.ts +2 -0
- package/src/recipes/kabuki-devtools/kabuki-devtools.tsx +4 -0
- package/src/recipes/kabuki-devtools/map/canvas/canvas.tsx +17 -1
- package/src/recipes/kabuki-devtools/scene/tree.ts +1 -1
- package/src/recipes/kabuki-devtools/sheet/sheet.tsx +95 -107
- package/src/recipes/kabuki-devtools/timeline/timeline.tsx +238 -19
|
@@ -7,15 +7,16 @@ import { ink } from '../ink/index.ts';
|
|
|
7
7
|
import type { Tone } from '../ink/index.ts';
|
|
8
8
|
|
|
9
9
|
/*
|
|
10
|
-
* The Sheet: the data of what is picked,
|
|
10
|
+
* The Sheet: the data of what is picked, over the Map.
|
|
11
11
|
*
|
|
12
|
-
* 1. Open it
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* 2. Fit on a wide Inspector it is a
|
|
16
|
-
*
|
|
17
|
-
* it
|
|
18
|
-
* everything stacked
|
|
12
|
+
* 1. Open it slides in quickly, and away when closed. Picking something
|
|
13
|
+
* else changes what it says in place: it never closes and opens
|
|
14
|
+
* again.
|
|
15
|
+
* 2. Fit on a wide Inspector it is a panel floating on the Map's right,
|
|
16
|
+
* as tall as it needs and no taller than the Map; on a narrow one
|
|
17
|
+
* it is a drawer from the bottom, the full width. Either way
|
|
18
|
+
* everything is stacked, since every value is a tree that grows
|
|
19
|
+
* down.
|
|
19
20
|
* 3. Read what it is and its name, a few facts, a list such as its
|
|
20
21
|
* Workers, then each value as a tree.
|
|
21
22
|
*/
|
|
@@ -66,108 +67,97 @@ export function Sheet({
|
|
|
66
67
|
<motion.section
|
|
67
68
|
aria-label={`${kind} ${title}`}
|
|
68
69
|
className={cn(
|
|
69
|
-
|
|
70
|
-
'
|
|
71
|
-
|
|
72
|
-
'
|
|
70
|
+
'bg-popover text-popover-foreground absolute z-30 flex flex-col overflow-hidden border',
|
|
71
|
+
'shadow-[0_1px_2px_rgb(0_0_0/0.06),0_12px_32px_rgb(0_0_0/0.16)]',
|
|
72
|
+
// 2. Fit: a drawer from the bottom when narrow, a panel on the right when wide.
|
|
73
|
+
'inset-x-0 bottom-0 max-h-[70%] rounded-t-xl border-b-0',
|
|
74
|
+
'@3xl:inset-x-auto @3xl:top-3 @3xl:right-3 @3xl:bottom-auto @3xl:max-h-[calc(100%-1.5rem)] @3xl:w-88 @3xl:rounded-xl @3xl:border-b',
|
|
73
75
|
)}
|
|
74
|
-
initial={still ? false : {
|
|
75
|
-
animate={{
|
|
76
|
+
initial={still ? false : { opacity: 0, y: 12 }}
|
|
77
|
+
animate={{ opacity: 1, y: 0 }}
|
|
76
78
|
exit={
|
|
77
79
|
still
|
|
78
|
-
? {
|
|
79
|
-
: {
|
|
80
|
+
? { opacity: 0, transition: { duration: 0 } }
|
|
81
|
+
: { opacity: 0, y: 8, transition: { duration: 0.12, ease: 'easeIn' } }
|
|
80
82
|
}
|
|
81
|
-
transition={{ duration: 0.
|
|
83
|
+
transition={{ duration: 0.2, ease: OUT }}
|
|
82
84
|
>
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
<
|
|
86
|
-
<
|
|
87
|
-
<
|
|
88
|
-
className="
|
|
89
|
-
style={{
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
85
|
+
{/* 3. Read: what it is */}
|
|
86
|
+
<header className="flex shrink-0 items-start gap-3 border-b px-4 py-3">
|
|
87
|
+
<div className="min-w-0 flex-1">
|
|
88
|
+
<p className="text-muted-foreground flex items-center gap-1.5 text-[11px] font-medium">
|
|
89
|
+
<span
|
|
90
|
+
className="size-1.5 rounded-full"
|
|
91
|
+
style={{ backgroundColor: ink(tone) }}
|
|
92
|
+
/>
|
|
93
|
+
{kind}
|
|
94
|
+
</p>
|
|
95
|
+
<h2 className="mt-0.5 truncate text-[15px] font-semibold">{title}</h2>
|
|
96
|
+
</div>
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
aria-label="Close"
|
|
100
|
+
onClick={onClose}
|
|
101
|
+
className="text-muted-foreground hover:text-foreground hover:bg-muted -mr-1.5 grid size-8 shrink-0 place-items-center rounded-md transition-colors duration-150 outline-none focus-visible:ring-2 [&_svg]:size-4"
|
|
102
|
+
>
|
|
103
|
+
<X />
|
|
104
|
+
</button>
|
|
105
|
+
</header>
|
|
104
106
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
</dl>
|
|
119
|
-
)}
|
|
120
|
-
{rows && (
|
|
121
|
-
<Part title={rows.title}>
|
|
122
|
-
{rows.items.length === 0 ? (
|
|
123
|
-
<p className="text-muted-foreground text-xs">
|
|
124
|
-
None running.
|
|
125
|
-
</p>
|
|
126
|
-
) : (
|
|
127
|
-
<ul className="flex flex-col gap-1">
|
|
128
|
-
{rows.items.map((row) => (
|
|
129
|
-
<li
|
|
130
|
-
key={row.label}
|
|
131
|
-
className="flex items-center gap-2 text-xs"
|
|
132
|
-
>
|
|
133
|
-
<span
|
|
134
|
-
className="size-1.5 shrink-0 rounded-full"
|
|
135
|
-
style={{ backgroundColor: ink(row.tone) }}
|
|
136
|
-
/>
|
|
137
|
-
<span className="min-w-0 flex-1 truncate font-mono text-[11.5px]">
|
|
138
|
-
{row.label}
|
|
139
|
-
</span>
|
|
140
|
-
<span className="text-muted-foreground">
|
|
141
|
-
{row.detail}
|
|
142
|
-
</span>
|
|
143
|
-
</li>
|
|
144
|
-
))}
|
|
145
|
-
</ul>
|
|
146
|
-
)}
|
|
147
|
-
</Part>
|
|
148
|
-
)}
|
|
149
|
-
</div>
|
|
150
|
-
)}
|
|
107
|
+
<div className="flex min-h-0 flex-col gap-5 overflow-y-auto overscroll-contain px-4 py-3.5">
|
|
108
|
+
{facts.length > 0 && (
|
|
109
|
+
<dl className="grid grid-cols-[minmax(4.5rem,auto)_1fr] gap-x-4 gap-y-2 text-xs">
|
|
110
|
+
{facts.map((fact) => (
|
|
111
|
+
<div key={fact.label} className="contents">
|
|
112
|
+
<dt className="text-muted-foreground">{fact.label}</dt>
|
|
113
|
+
<dd className="min-w-0 truncate font-mono text-[11.5px]">
|
|
114
|
+
{fact.value}
|
|
115
|
+
</dd>
|
|
116
|
+
</div>
|
|
117
|
+
))}
|
|
118
|
+
</dl>
|
|
119
|
+
)}
|
|
151
120
|
|
|
152
|
-
|
|
153
|
-
{
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
className="
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
121
|
+
{rows && (
|
|
122
|
+
<Part title={rows.title}>
|
|
123
|
+
{rows.items.length === 0 ? (
|
|
124
|
+
<p className="text-muted-foreground text-xs">None running.</p>
|
|
125
|
+
) : (
|
|
126
|
+
<ul className="flex flex-col gap-1.5">
|
|
127
|
+
{rows.items.map((row) => (
|
|
128
|
+
<li
|
|
129
|
+
key={row.label}
|
|
130
|
+
className="flex items-center gap-2 text-xs"
|
|
131
|
+
>
|
|
132
|
+
<span
|
|
133
|
+
className="size-1.5 shrink-0 rounded-full"
|
|
134
|
+
style={{ backgroundColor: ink(row.tone) }}
|
|
135
|
+
/>
|
|
136
|
+
<span className="min-w-0 flex-1 truncate font-mono text-[11.5px]">
|
|
137
|
+
{row.label}
|
|
138
|
+
</span>
|
|
139
|
+
<span className="text-muted-foreground">{row.detail}</span>
|
|
140
|
+
</li>
|
|
141
|
+
))}
|
|
142
|
+
</ul>
|
|
143
|
+
)}
|
|
144
|
+
</Part>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{/* 3. Read: each value */}
|
|
148
|
+
{sections.map((section) => (
|
|
149
|
+
<Part key={section.title} title={section.title}>
|
|
150
|
+
{section.value !== null && typeof section.value === 'object' ? (
|
|
151
|
+
<div className="bg-muted/40 -mx-1 rounded-lg px-2.5 py-2">
|
|
152
|
+
<JsonTree value={section.value} collapsed={3} tone="syntax" />
|
|
153
|
+
</div>
|
|
154
|
+
) : (
|
|
155
|
+
<p className="font-mono text-xs">
|
|
156
|
+
{JSON.stringify(section.value) ?? 'nothing'}
|
|
157
|
+
</p>
|
|
158
|
+
)}
|
|
159
|
+
</Part>
|
|
160
|
+
))}
|
|
171
161
|
</div>
|
|
172
162
|
</motion.section>
|
|
173
163
|
);
|
|
@@ -183,10 +173,8 @@ function Part({
|
|
|
183
173
|
readonly children: ReactNode;
|
|
184
174
|
}) {
|
|
185
175
|
return (
|
|
186
|
-
<section className={cn('flex flex-col gap-
|
|
187
|
-
<h3 className="text-muted-foreground text-[
|
|
188
|
-
{title}
|
|
189
|
-
</h3>
|
|
176
|
+
<section className={cn('flex flex-col gap-2', className)}>
|
|
177
|
+
<h3 className="text-muted-foreground text-[11px] font-medium">{title}</h3>
|
|
190
178
|
{children}
|
|
191
179
|
</section>
|
|
192
180
|
);
|
|
@@ -1,6 +1,16 @@
|
|
|
1
|
-
import { useEffect, useRef } from 'react';
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
2
3
|
import { ChevronLeft, ChevronRight, GitBranch } from 'lucide-react';
|
|
4
|
+
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
|
|
3
5
|
import { cn } from '../../../components/lib/utils.ts';
|
|
6
|
+
import {
|
|
7
|
+
Select,
|
|
8
|
+
SelectContent,
|
|
9
|
+
SelectItem,
|
|
10
|
+
SelectSeparator,
|
|
11
|
+
SelectTrigger,
|
|
12
|
+
SelectValue,
|
|
13
|
+
} from '#components/ui/select';
|
|
4
14
|
import { ink } from '../ink/index.ts';
|
|
5
15
|
|
|
6
16
|
/*
|
|
@@ -8,12 +18,28 @@ import { ink } from '../ink/index.ts';
|
|
|
8
18
|
*
|
|
9
19
|
* 1. Step one step back or on.
|
|
10
20
|
* 2. Track one tick per Step on the Branch in view, init first; the one
|
|
11
|
-
* shown stands taller
|
|
21
|
+
* shown stands taller. When they do not all fit, the row glides
|
|
22
|
+
* to keep the one shown in the middle, stopping at either end,
|
|
23
|
+
* and live it rests at its right end; a finger or a trackpad
|
|
24
|
+
* can still swipe it. The tick nearest the pointer rises with
|
|
25
|
+
* its Message's name over it, and a click or tap anywhere on the
|
|
26
|
+
* track picks the nearest. The last tick, when it is the Head,
|
|
27
|
+
* goes live.
|
|
12
28
|
* 3. Read the Step shown: its place and its Message, once there is room.
|
|
13
29
|
* 4. Branch bring another Branch into view, when there is more than one.
|
|
14
30
|
* 5. Live back to the Head, or the sign that it is there.
|
|
31
|
+
* 6. Window how many entries the History keeps: a preset, or any number
|
|
32
|
+
* typed in, set on Enter or blur. A smaller one Compacts at once,
|
|
33
|
+
* and cannot be undone. 1 keeps only the Snapshot; 2 is the least
|
|
34
|
+
* that still shows a change.
|
|
15
35
|
*/
|
|
16
36
|
|
|
37
|
+
const PRESETS = [1, 2, 5, 10, 25, 50, 100, 500];
|
|
38
|
+
const HINTS: Partial<Record<number, string>> = {
|
|
39
|
+
1: 'Snapshot only',
|
|
40
|
+
2: 'last change',
|
|
41
|
+
};
|
|
42
|
+
|
|
17
43
|
export interface TimelineStep {
|
|
18
44
|
readonly id: string;
|
|
19
45
|
readonly label: string;
|
|
@@ -28,11 +54,14 @@ export function Timeline({
|
|
|
28
54
|
steps,
|
|
29
55
|
shown,
|
|
30
56
|
live,
|
|
31
|
-
onShow,
|
|
57
|
+
onShow: showStep,
|
|
32
58
|
onLive,
|
|
59
|
+
head = false,
|
|
33
60
|
branches = [],
|
|
34
61
|
branch,
|
|
35
62
|
onBranch,
|
|
63
|
+
window,
|
|
64
|
+
onWindow,
|
|
36
65
|
}: {
|
|
37
66
|
/** Every Step on the Branch in view, init first. */
|
|
38
67
|
readonly steps: ReadonlyArray<TimelineStep>;
|
|
@@ -42,23 +71,99 @@ export function Timeline({
|
|
|
42
71
|
readonly live: boolean;
|
|
43
72
|
readonly onShow: (index: number) => void;
|
|
44
73
|
readonly onLive: () => void;
|
|
74
|
+
/** The last Step is the Head: picking it goes live. */
|
|
75
|
+
readonly head?: boolean;
|
|
45
76
|
readonly branches?: ReadonlyArray<TimelineBranch>;
|
|
46
77
|
/** The Branch in view. */
|
|
47
78
|
readonly branch?: string;
|
|
48
79
|
readonly onBranch?: (id: string) => void;
|
|
80
|
+
/** How many entries the History keeps on the Head's Branch, `null` for every one. */
|
|
81
|
+
readonly window?: number | null;
|
|
82
|
+
readonly onWindow?: (window: number | null) => void;
|
|
49
83
|
}) {
|
|
50
84
|
const track = useRef<HTMLDivElement>(null);
|
|
51
|
-
useEffect(() => {
|
|
52
|
-
track.current
|
|
53
|
-
?.querySelector<HTMLElement>('[aria-current="step"]')
|
|
54
|
-
?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
55
|
-
}, [shown]);
|
|
56
85
|
|
|
57
86
|
const step = steps[shown];
|
|
58
87
|
const last = steps.length - 1;
|
|
88
|
+
const onShow = (index: number) =>
|
|
89
|
+
head && index === last ? onLive() : showStep(index);
|
|
90
|
+
const nameOf = (index: number) =>
|
|
91
|
+
index === 0
|
|
92
|
+
? 'init'
|
|
93
|
+
: head && index === last && !live
|
|
94
|
+
? `${steps[index]!.label} · go live`
|
|
95
|
+
: steps[index]!.label;
|
|
96
|
+
|
|
97
|
+
const bar = useRef<HTMLDivElement>(null);
|
|
98
|
+
const still = useReducedMotion();
|
|
99
|
+
const [near, setNear] = useState<{ index: number; x: number }>();
|
|
100
|
+
/** The tick nearest the pointer, gaps included, and its middle in the bar. */
|
|
101
|
+
/** The tick nearest a point across the track, gaps included. */
|
|
102
|
+
const nearestTo = (clientX: number) => {
|
|
103
|
+
const ticks = [...track.current!.children];
|
|
104
|
+
let index = 0;
|
|
105
|
+
let best = Infinity;
|
|
106
|
+
ticks.forEach((tick, at) => {
|
|
107
|
+
const { left, width } = tick.getBoundingClientRect();
|
|
108
|
+
const off = Math.abs(left + width / 2 - clientX);
|
|
109
|
+
if (off < best) [index, best] = [at, off];
|
|
110
|
+
});
|
|
111
|
+
return index;
|
|
112
|
+
};
|
|
113
|
+
/** Where the mouse is over the track, to follow it as the row glides. */
|
|
114
|
+
const pointer = useRef<number | undefined>(undefined);
|
|
115
|
+
const point = () => {
|
|
116
|
+
if (pointer.current === undefined) return;
|
|
117
|
+
const index = nearestTo(pointer.current);
|
|
118
|
+
const { left, width } =
|
|
119
|
+
track.current!.children[index]!.getBoundingClientRect();
|
|
120
|
+
const x = left + width / 2 - bar.current!.getBoundingClientRect().left;
|
|
121
|
+
setNear((was) =>
|
|
122
|
+
was?.index === index && was.x === x ? was : { index, x },
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// The Step shown in the middle of the track, or as near as its ends allow.
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
const at = track.current;
|
|
129
|
+
const tick = at?.children[shown] as HTMLElement | undefined;
|
|
130
|
+
if (!at || !tick) return;
|
|
131
|
+
at.scrollTo({
|
|
132
|
+
left: tick.offsetLeft + tick.offsetWidth / 2 - at.clientWidth / 2,
|
|
133
|
+
behavior: still ? 'instant' : 'smooth',
|
|
134
|
+
});
|
|
135
|
+
}, [shown, steps.length, still]);
|
|
59
136
|
|
|
60
137
|
return (
|
|
61
|
-
<div
|
|
138
|
+
<div
|
|
139
|
+
ref={bar}
|
|
140
|
+
className="bg-background/90 relative flex h-14 shrink-0 items-center gap-2 border-t px-2 backdrop-blur-sm @md:gap-3 @md:px-3"
|
|
141
|
+
// Clear of a button laid over its right end, such as the Devtools Dock's.
|
|
142
|
+
style={{ paddingRight: 'calc(var(--dock-reserve, 0px) + 0.75rem)' }}
|
|
143
|
+
>
|
|
144
|
+
<AnimatePresence>
|
|
145
|
+
{near && steps[near.index] && (
|
|
146
|
+
<motion.div
|
|
147
|
+
key="tip"
|
|
148
|
+
role="tooltip"
|
|
149
|
+
className="bg-popover text-popover-foreground pointer-events-none absolute bottom-full left-0 mb-1.5 rounded-md border px-2 py-1 text-xs font-medium whitespace-nowrap shadow-[0_1px_2px_rgb(0_0_0/0.06),0_4px_12px_rgb(0_0_0/0.1)]"
|
|
150
|
+
initial={{ opacity: 0, y: 4, x: near.x, translateX: '-50%' }}
|
|
151
|
+
animate={{ opacity: 1, y: 0, x: near.x, translateX: '-50%' }}
|
|
152
|
+
exit={{ opacity: 0, y: 4 }}
|
|
153
|
+
transition={
|
|
154
|
+
still
|
|
155
|
+
? { duration: 0 }
|
|
156
|
+
: {
|
|
157
|
+
x: { type: 'spring', stiffness: 700, damping: 45 },
|
|
158
|
+
default: { duration: 0.12, ease: 'easeOut' },
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
>
|
|
162
|
+
{nameOf(near.index)}
|
|
163
|
+
</motion.div>
|
|
164
|
+
)}
|
|
165
|
+
</AnimatePresence>
|
|
166
|
+
|
|
62
167
|
{/* 1. Step */}
|
|
63
168
|
<div className="flex">
|
|
64
169
|
<Press
|
|
@@ -70,7 +175,7 @@ export function Timeline({
|
|
|
70
175
|
</Press>
|
|
71
176
|
<Press
|
|
72
177
|
label="Step on"
|
|
73
|
-
disabled={shown >= last}
|
|
178
|
+
disabled={shown >= last && (live || !head)}
|
|
74
179
|
onClick={() => onShow(shown + 1)}
|
|
75
180
|
>
|
|
76
181
|
<ChevronRight />
|
|
@@ -82,7 +187,21 @@ export function Timeline({
|
|
|
82
187
|
ref={track}
|
|
83
188
|
role="list"
|
|
84
189
|
aria-label="Steps"
|
|
85
|
-
className="flex h-full min-w-0 flex-1 items-center gap-[3px] overflow-x-auto px-1 [scrollbar-width:none]"
|
|
190
|
+
className="relative flex h-full min-w-0 flex-1 touch-pan-x items-center gap-[3px] overflow-x-auto overscroll-x-contain px-1 [scrollbar-width:none]"
|
|
191
|
+
onPointerMove={(event) => {
|
|
192
|
+
if (event.pointerType !== 'mouse') return;
|
|
193
|
+
pointer.current = event.clientX;
|
|
194
|
+
point();
|
|
195
|
+
}}
|
|
196
|
+
onPointerLeave={() => {
|
|
197
|
+
pointer.current = undefined;
|
|
198
|
+
setNear(undefined);
|
|
199
|
+
}}
|
|
200
|
+
onScroll={point}
|
|
201
|
+
onClick={(event) => {
|
|
202
|
+
if (event.target === event.currentTarget)
|
|
203
|
+
onShow(nearestTo(event.clientX));
|
|
204
|
+
}}
|
|
86
205
|
>
|
|
87
206
|
{steps.map((each, index) => (
|
|
88
207
|
<button
|
|
@@ -97,18 +216,22 @@ export function Timeline({
|
|
|
97
216
|
>
|
|
98
217
|
<span
|
|
99
218
|
className={cn(
|
|
100
|
-
'w-[3px] rounded-full transition-[height,background-color] duration-150',
|
|
219
|
+
'w-[3px] rounded-full transition-[height,background-color] duration-150 ease-out',
|
|
101
220
|
index === shown
|
|
102
221
|
? 'h-6'
|
|
103
|
-
:
|
|
222
|
+
: index === near?.index
|
|
223
|
+
? 'h-5'
|
|
224
|
+
: 'h-3 group-focus-visible:h-5',
|
|
104
225
|
)}
|
|
105
226
|
style={{
|
|
106
227
|
backgroundColor:
|
|
107
228
|
index === shown
|
|
108
229
|
? ink('message')
|
|
109
|
-
: index
|
|
110
|
-
?
|
|
111
|
-
:
|
|
230
|
+
: index === near?.index
|
|
231
|
+
? 'var(--foreground)'
|
|
232
|
+
: index < shown
|
|
233
|
+
? ink('line-lit')
|
|
234
|
+
: ink('line'),
|
|
112
235
|
}}
|
|
113
236
|
/>
|
|
114
237
|
</button>
|
|
@@ -121,7 +244,7 @@ export function Timeline({
|
|
|
121
244
|
<span className="truncate text-xs font-medium">
|
|
122
245
|
{shown === 0 ? 'init' : step.label}
|
|
123
246
|
</span>
|
|
124
|
-
<span className="text-muted-foreground text-[10.5px] tabular-nums">
|
|
247
|
+
<span className="text-muted-foreground text-[10.5px] whitespace-nowrap tabular-nums">
|
|
125
248
|
{shown} of {last}
|
|
126
249
|
</span>
|
|
127
250
|
</div>
|
|
@@ -152,7 +275,7 @@ export function Timeline({
|
|
|
152
275
|
onClick={onLive}
|
|
153
276
|
disabled={live}
|
|
154
277
|
className={cn(
|
|
155
|
-
'flex h-9 items-center gap-2 rounded-md border px-3 text-xs font-medium transition-[background-color,color,scale] duration-150 outline-none focus-visible:ring-2 active:scale-[0.97]',
|
|
278
|
+
'flex h-9 shrink-0 items-center gap-2 rounded-md border px-3 text-xs font-medium transition-[background-color,color,scale] duration-150 outline-none focus-visible:ring-2 active:scale-[0.97]',
|
|
156
279
|
live
|
|
157
280
|
? 'text-foreground cursor-default'
|
|
158
281
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
@@ -174,12 +297,108 @@ export function Timeline({
|
|
|
174
297
|
}
|
|
175
298
|
/>
|
|
176
299
|
</span>
|
|
177
|
-
{
|
|
300
|
+
{/* As wide as its longer word either way, so nothing beside it moves. */}
|
|
301
|
+
<span className="grid whitespace-nowrap">
|
|
302
|
+
<span aria-hidden className="invisible col-start-1 row-start-1">
|
|
303
|
+
Go live
|
|
304
|
+
</span>
|
|
305
|
+
<span className="col-start-1 row-start-1">
|
|
306
|
+
{live ? 'Live' : 'Go live'}
|
|
307
|
+
</span>
|
|
308
|
+
</span>
|
|
178
309
|
</button>
|
|
310
|
+
|
|
311
|
+
{/* 6. Window */}
|
|
312
|
+
{onWindow && <WindowPicker window={window ?? null} onWindow={onWindow} />}
|
|
179
313
|
</div>
|
|
180
314
|
);
|
|
181
315
|
}
|
|
182
316
|
|
|
317
|
+
function WindowPicker({
|
|
318
|
+
window,
|
|
319
|
+
onWindow,
|
|
320
|
+
}: {
|
|
321
|
+
readonly window: number | null;
|
|
322
|
+
readonly onWindow: (window: number | null) => void;
|
|
323
|
+
}) {
|
|
324
|
+
const [typing, setTyping] = useState(false);
|
|
325
|
+
|
|
326
|
+
if (typing)
|
|
327
|
+
return (
|
|
328
|
+
<label className="text-muted-foreground focus-within:text-foreground hidden h-9 items-center gap-1.5 rounded-md border px-2.5 text-xs focus-within:ring-2 @md:flex">
|
|
329
|
+
<span>Keep</span>
|
|
330
|
+
<input
|
|
331
|
+
autoFocus
|
|
332
|
+
type="number"
|
|
333
|
+
min={1}
|
|
334
|
+
step={1}
|
|
335
|
+
inputMode="numeric"
|
|
336
|
+
defaultValue={window ?? ''}
|
|
337
|
+
aria-label="Entries the History keeps"
|
|
338
|
+
onKeyDown={(event) => {
|
|
339
|
+
if (event.key === 'Enter') event.currentTarget.blur();
|
|
340
|
+
if (event.key === 'Escape') setTyping(false);
|
|
341
|
+
}}
|
|
342
|
+
onBlur={(event) => {
|
|
343
|
+
const typed = event.currentTarget.valueAsNumber;
|
|
344
|
+
if (Number.isInteger(typed) && typed >= 1 && typed !== window)
|
|
345
|
+
onWindow(typed);
|
|
346
|
+
setTyping(false);
|
|
347
|
+
}}
|
|
348
|
+
className="text-foreground w-14 bg-transparent text-base tabular-nums outline-none @md:text-xs"
|
|
349
|
+
/>
|
|
350
|
+
</label>
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
const sizes =
|
|
354
|
+
window === null || PRESETS.includes(window)
|
|
355
|
+
? PRESETS
|
|
356
|
+
: [...PRESETS, window].sort((a, b) => a - b);
|
|
357
|
+
const items = {
|
|
358
|
+
all: 'all',
|
|
359
|
+
...Object.fromEntries(sizes.map((each) => [`${each}`, `${each}`])),
|
|
360
|
+
custom: 'Custom…',
|
|
361
|
+
};
|
|
362
|
+
return (
|
|
363
|
+
<Select
|
|
364
|
+
items={items}
|
|
365
|
+
value={window === null ? 'all' : `${window}`}
|
|
366
|
+
onValueChange={(value) => {
|
|
367
|
+
if (value === 'custom') setTyping(true);
|
|
368
|
+
else if (value) onWindow(value === 'all' ? null : Number(value));
|
|
369
|
+
}}
|
|
370
|
+
>
|
|
371
|
+
<SelectTrigger
|
|
372
|
+
size="sm"
|
|
373
|
+
aria-label="Entries the History keeps"
|
|
374
|
+
className="hidden text-xs @md:flex"
|
|
375
|
+
>
|
|
376
|
+
<span className="text-muted-foreground">Keep</span>
|
|
377
|
+
<SelectValue className="tabular-nums" />
|
|
378
|
+
</SelectTrigger>
|
|
379
|
+
<SelectContent className="min-w-44">
|
|
380
|
+
<SelectItem value="all">
|
|
381
|
+
all <Hint>unbounded</Hint>
|
|
382
|
+
</SelectItem>
|
|
383
|
+
{sizes.map((each) => (
|
|
384
|
+
<SelectItem key={each} value={`${each}`} className="tabular-nums">
|
|
385
|
+
{each}
|
|
386
|
+
{HINTS[each] && <Hint>{HINTS[each]}</Hint>}
|
|
387
|
+
</SelectItem>
|
|
388
|
+
))}
|
|
389
|
+
<SelectSeparator />
|
|
390
|
+
<SelectItem value="custom">Custom…</SelectItem>
|
|
391
|
+
</SelectContent>
|
|
392
|
+
</Select>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function Hint({ children }: { readonly children: ReactNode }) {
|
|
397
|
+
return (
|
|
398
|
+
<span className="text-muted-foreground ml-auto text-xs">{children}</span>
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
183
402
|
function Press({
|
|
184
403
|
label,
|
|
185
404
|
disabled,
|