@kestack/web-platform 0.0.1 → 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.
Files changed (32) hide show
  1. package/README.md +6 -0
  2. package/dist/components/viewers/diff-viewer/diff-viewer.js +1 -1
  3. package/dist/components/viewers/source-explorer/source-explorer.js +1 -1
  4. package/dist/recipes/devtools-dock/devtools-dock.d.ts +23 -0
  5. package/dist/recipes/devtools-dock/devtools-dock.d.ts.map +1 -0
  6. package/dist/recipes/devtools-dock/devtools-dock.js +343 -0
  7. package/dist/recipes/devtools-dock/devtools-dock.js.map +1 -0
  8. package/dist/recipes/devtools-dock/index.d.ts +2 -0
  9. package/dist/recipes/devtools-dock/index.js +2 -0
  10. package/dist/recipes/kabuki-devtools/kabuki-devtools.d.ts.map +1 -1
  11. package/dist/recipes/kabuki-devtools/kabuki-devtools.js +4 -1
  12. package/dist/recipes/kabuki-devtools/kabuki-devtools.js.map +1 -1
  13. package/dist/recipes/kabuki-devtools/map/canvas/canvas.d.ts.map +1 -1
  14. package/dist/recipes/kabuki-devtools/map/canvas/canvas.js +13 -0
  15. package/dist/recipes/kabuki-devtools/map/canvas/canvas.js.map +1 -1
  16. package/dist/recipes/kabuki-devtools/scene/tree.js +1 -1
  17. package/dist/recipes/kabuki-devtools/scene/tree.js.map +1 -1
  18. package/dist/recipes/kabuki-devtools/sheet/sheet.d.ts.map +1 -1
  19. package/dist/recipes/kabuki-devtools/sheet/sheet.js +84 -78
  20. package/dist/recipes/kabuki-devtools/sheet/sheet.js.map +1 -1
  21. package/dist/recipes/kabuki-devtools/timeline/timeline.d.ts +6 -1
  22. package/dist/recipes/kabuki-devtools/timeline/timeline.d.ts.map +1 -1
  23. package/dist/recipes/kabuki-devtools/timeline/timeline.js +202 -16
  24. package/dist/recipes/kabuki-devtools/timeline/timeline.js.map +1 -1
  25. package/package.json +13 -13
  26. package/src/recipes/devtools-dock/devtools-dock.tsx +505 -0
  27. package/src/recipes/devtools-dock/index.ts +2 -0
  28. package/src/recipes/kabuki-devtools/kabuki-devtools.tsx +4 -0
  29. package/src/recipes/kabuki-devtools/map/canvas/canvas.tsx +17 -1
  30. package/src/recipes/kabuki-devtools/scene/tree.ts +1 -1
  31. package/src/recipes/kabuki-devtools/sheet/sheet.tsx +95 -107
  32. 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, along the bottom of the Inspector.
10
+ * The Sheet: the data of what is picked, over the Map.
11
11
  *
12
- * 1. Open it grows up from the bottom, quickly, and shrinks away when
13
- * closed. Picking something else changes what it says in place:
14
- * it never closes and opens again.
15
- * 2. Fit on a wide Inspector it is a section of its own under the Map,
16
- * what is picked on its left and each value in a column beside
17
- * it; on a narrow one it is a drawer over the Map, the full width,
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
- // 2. Fit: a drawer over the Map when narrow, a section under it when wide.
70
- 'bg-background absolute inset-x-0 bottom-0 z-30 flex max-h-[65%] flex-col overflow-hidden border-t @3xl:max-h-72',
71
- 'rounded-t-xl shadow-[0_-1px_2px_rgb(0_0_0/0.04),0_-8px_24px_rgb(0_0_0/0.08)]',
72
- '@3xl:relative @3xl:z-auto @3xl:shrink-0 @3xl:rounded-none @3xl:shadow-none',
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 : { height: 0 }}
75
- animate={{ height: 'auto' }}
76
+ initial={still ? false : { opacity: 0, y: 12 }}
77
+ animate={{ opacity: 1, y: 0 }}
76
78
  exit={
77
79
  still
78
- ? { height: 0, transition: { duration: 0 } }
79
- : { height: 0, transition: { duration: 0.12, ease: 'easeIn' } }
80
+ ? { opacity: 0, transition: { duration: 0 } }
81
+ : { opacity: 0, y: 8, transition: { duration: 0.12, ease: 'easeIn' } }
80
82
  }
81
- transition={{ duration: 0.18, ease: OUT }}
83
+ transition={{ duration: 0.2, ease: OUT }}
82
84
  >
83
- <div className="flex min-h-0 flex-1 flex-col">
84
- {/* 3. Read: what it is */}
85
- <header className="flex shrink-0 items-center gap-3 px-4 pt-3 pb-2">
86
- <div className="min-w-0 flex-1">
87
- <p
88
- className="text-[10.5px] font-medium tracking-wider uppercase"
89
- style={{ color: ink(tone) }}
90
- >
91
- {kind}
92
- </p>
93
- <h2 className="truncate text-sm font-semibold">{title}</h2>
94
- </div>
95
- <button
96
- type="button"
97
- aria-label="Close"
98
- onClick={onClose}
99
- className="text-muted-foreground hover:text-foreground hover:bg-muted grid size-8 shrink-0 place-items-center rounded-md transition-colors duration-150 outline-none focus-visible:ring-2 [&_svg]:size-4"
100
- >
101
- <X />
102
- </button>
103
- </header>
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
- <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain px-4 pb-4 @3xl:flex-row @3xl:gap-6 @3xl:overflow-x-auto @3xl:overflow-y-hidden">
106
- {(facts.length > 0 || rows) && (
107
- <div className="flex shrink-0 flex-col gap-4 @3xl:w-64 @3xl:overflow-y-auto">
108
- {facts.length > 0 && (
109
- <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 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
- )}
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
- {/* 3. Read: each value */}
153
- {sections.map((section) => (
154
- <Part
155
- key={section.title}
156
- title={section.title}
157
- className="@3xl:w-80 @3xl:shrink-0 @3xl:overflow-y-auto"
158
- >
159
- {section.value !== null && typeof section.value === 'object' ? (
160
- <div className="bg-muted/40 rounded-lg border p-2">
161
- <JsonTree value={section.value} collapsed={2} tone="syntax" />
162
- </div>
163
- ) : (
164
- <p className="font-mono text-xs">
165
- {JSON.stringify(section.value) ?? 'nothing'}
166
- </p>
167
- )}
168
- </Part>
169
- ))}
170
- </div>
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-1.5', className)}>
187
- <h3 className="text-muted-foreground text-[10.5px] font-medium tracking-wider uppercase">
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, and stays scrolled into view.
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 className="bg-background/90 flex h-14 shrink-0 items-center gap-2 border-t px-2 backdrop-blur-sm @md:gap-3 @md:px-3">
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
- : 'group-hover:bg-foreground/50 h-3 group-focus-visible:h-5',
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 < shown
110
- ? ink('line-lit')
111
- : ink('line'),
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
- {live ? 'Live' : 'Go live'}
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,