@tanishqkancharla/diffmap 0.3.0 → 0.6.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/README.md +171 -1
- package/index.html +6 -0
- package/package.json +3 -2
- package/src/annotations.ts +17 -1
- package/src/changes.ts +15 -0
- package/src/codeNotes.ts +123 -0
- package/src/errors.ts +5 -0
- package/src/extractDocument.ts +10 -2
- package/src/git.test.ts +13 -1
- package/src/git.ts +15 -2
- package/src/host/HostHome.tsx +22 -6
- package/src/host/home.md +154 -109
- package/src/host/home.test.ts +6 -8
- package/src/parseFence.ts +132 -23
- package/src/parseViewer.test.ts +359 -2
- package/src/parseViewer.ts +126 -25
- package/src/serve.test.ts +22 -1
- package/src/services.ts +396 -0
- package/src/share.ts +1 -1
- package/src/viewer/CallStackDiff.tsx +119 -55
- package/src/viewer/ChangesCard.tsx +133 -45
- package/src/viewer/CodeBlock.tsx +204 -0
- package/src/viewer/ComarkView.tsx +34 -0
- package/src/viewer/Fence.tsx +26 -2
- package/src/viewer/HtmlBlock.tsx +2 -1
- package/src/viewer/MermaidBlock.tsx +55 -14
- package/src/viewer/ServiceDiagramBlock.tsx +358 -0
- package/src/viewer/ServiceStage.tsx +1129 -0
- package/src/viewer/SourceDiffPanel.tsx +147 -48
- package/src/viewer/TableOfContents.tsx +47 -18
- package/src/viewer/TocButton.tsx +28 -0
- package/src/viewer/ViewerApp.tsx +28 -3
- package/src/viewer/diagramDiff.ts +69 -0
- package/src/viewer/diagramLinks.ts +54 -6
- package/src/viewer/notes.tsx +126 -0
- package/src/viewer/pierre.ts +20 -2
- package/src/viewer/serviceLayout.ts +200 -0
- package/vite.config.ts +5 -0
- package/vite.gist.config.ts +1 -0
|
@@ -8,26 +8,36 @@ import {
|
|
|
8
8
|
colors,
|
|
9
9
|
fontFamily,
|
|
10
10
|
spacing,
|
|
11
|
+
text,
|
|
11
12
|
} from "maui";
|
|
12
13
|
import { style, useStyles } from "purse-styles";
|
|
13
14
|
import { mermaidSvg } from "../mermaid.js";
|
|
14
15
|
import type { DiagramAnnotation } from "../diagramAnnotations.js";
|
|
15
16
|
import type { SourceNavigation } from "../annotations.js";
|
|
17
|
+
import { markDiagramChanges } from "./diagramDiff.js";
|
|
16
18
|
import { linkDiagram } from "./diagramLinks.js";
|
|
19
|
+
import { fenceTitle, pierreShell } from "./pierre.js";
|
|
17
20
|
|
|
18
21
|
type MermaidProps = {
|
|
19
22
|
source: string;
|
|
20
23
|
annotations: DiagramAnnotation[];
|
|
24
|
+
sides: { today: string; proposed: string } | undefined;
|
|
25
|
+
title: string | undefined;
|
|
21
26
|
} & SourceNavigation;
|
|
22
27
|
|
|
23
28
|
export function MermaidBlock(props: MermaidProps) {
|
|
24
29
|
const [fullscreen, setFullscreen] = useState(false);
|
|
25
|
-
const frame = useStyles(styles.frame);
|
|
30
|
+
const frame = useStyles(pierreShell, styles.frame);
|
|
26
31
|
const expand = useStyles(styles.expand);
|
|
27
32
|
const dialog = useStyles(styles.dialog);
|
|
28
33
|
const header = useStyles(styles.header);
|
|
34
|
+
const caption = useStyles(fenceTitle);
|
|
35
|
+
const dialogTitle = useStyles(styles.dialogTitle);
|
|
29
36
|
return (
|
|
30
|
-
<
|
|
37
|
+
<figure className={frame}>
|
|
38
|
+
{props.title !== undefined && (
|
|
39
|
+
<figcaption className={caption}>{props.title}</figcaption>
|
|
40
|
+
)}
|
|
31
41
|
<MermaidDiagram {...props} fullscreen={false} />
|
|
32
42
|
<Button
|
|
33
43
|
className={expand}
|
|
@@ -42,10 +52,11 @@ export function MermaidBlock(props: MermaidProps) {
|
|
|
42
52
|
isOpen={fullscreen}
|
|
43
53
|
onOpenChange={setFullscreen}
|
|
44
54
|
size="lg"
|
|
45
|
-
aria-label="Diagram"
|
|
55
|
+
aria-label={props.title ?? "Diagram"}
|
|
46
56
|
className={dialog}
|
|
47
57
|
>
|
|
48
58
|
<div className={header}>
|
|
59
|
+
<span className={dialogTitle}>{props.title}</span>
|
|
49
60
|
<Button
|
|
50
61
|
size="sm"
|
|
51
62
|
variant="quiet"
|
|
@@ -65,7 +76,7 @@ export function MermaidBlock(props: MermaidProps) {
|
|
|
65
76
|
}}
|
|
66
77
|
/>
|
|
67
78
|
</Dialog>
|
|
68
|
-
</
|
|
79
|
+
</figure>
|
|
69
80
|
);
|
|
70
81
|
}
|
|
71
82
|
|
|
@@ -75,10 +86,10 @@ function MermaidDiagram(props: MermaidProps & { fullscreen: boolean }) {
|
|
|
75
86
|
styles.shell,
|
|
76
87
|
props.fullscreen ? styles.fullscreenShell : styles.inlineShell,
|
|
77
88
|
);
|
|
78
|
-
const svg = useMemo(
|
|
79
|
-
() =>
|
|
89
|
+
const svg = useMemo(() => {
|
|
90
|
+
const draw = (source: string) =>
|
|
80
91
|
mermaidSvg({
|
|
81
|
-
source
|
|
92
|
+
source,
|
|
82
93
|
bg: backgroundColor.app,
|
|
83
94
|
fg: colors.gray[12],
|
|
84
95
|
accent: colors.accent[9],
|
|
@@ -86,9 +97,15 @@ function MermaidDiagram(props: MermaidProps & { fullscreen: boolean }) {
|
|
|
86
97
|
surface: backgroundColor.element,
|
|
87
98
|
border: colors.gray[6],
|
|
88
99
|
font: fontFamily,
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
|
|
100
|
+
});
|
|
101
|
+
const all = draw(props.source);
|
|
102
|
+
if (all instanceof Error || props.sides === undefined) return all;
|
|
103
|
+
const today = draw(props.sides.today);
|
|
104
|
+
if (today instanceof Error) return today;
|
|
105
|
+
const proposed = draw(props.sides.proposed);
|
|
106
|
+
if (proposed instanceof Error) return proposed;
|
|
107
|
+
return markDiagramChanges(all, { today, proposed });
|
|
108
|
+
}, [props.source, props.sides]);
|
|
92
109
|
const linked = useMemo(
|
|
93
110
|
() => (svg instanceof Error ? svg : linkDiagram(svg, props.annotations)),
|
|
94
111
|
[svg, props.annotations],
|
|
@@ -152,16 +169,23 @@ function MermaidDiagram(props: MermaidProps & { fullscreen: boolean }) {
|
|
|
152
169
|
}
|
|
153
170
|
|
|
154
171
|
const styles = {
|
|
172
|
+
// A card like a code block: the title row, then the diagram, padded.
|
|
155
173
|
frame: style({
|
|
156
174
|
position: "relative",
|
|
157
175
|
width: "100%",
|
|
158
|
-
|
|
176
|
+
margin: 0,
|
|
177
|
+
// Room for the full-screen button, which sits in the title row.
|
|
178
|
+
"& > figcaption": { paddingRight: "40px" },
|
|
179
|
+
"& > [data-diffmap-kind='mermaid']": { padding: spacing.value(6) },
|
|
159
180
|
}),
|
|
160
181
|
expand: style({
|
|
161
182
|
position: "absolute",
|
|
162
|
-
top:
|
|
163
|
-
|
|
183
|
+
top: "6px",
|
|
184
|
+
right: "6px",
|
|
164
185
|
}),
|
|
186
|
+
dialogTitle: style(
|
|
187
|
+
text({ size: "xs", fontWeight: 500, color: "lowContrast" }),
|
|
188
|
+
),
|
|
165
189
|
dialog: style({
|
|
166
190
|
width: "calc(100vw - 32px)",
|
|
167
191
|
height: "calc(100dvh - 32px)",
|
|
@@ -170,7 +194,8 @@ const styles = {
|
|
|
170
194
|
}),
|
|
171
195
|
header: style({
|
|
172
196
|
display: "flex",
|
|
173
|
-
|
|
197
|
+
alignItems: "center",
|
|
198
|
+
justifyContent: "space-between",
|
|
174
199
|
}),
|
|
175
200
|
inlineShell: style({
|
|
176
201
|
maxHeight: "60vh",
|
|
@@ -210,5 +235,21 @@ const styles = {
|
|
|
210
235
|
width: "100%",
|
|
211
236
|
height: "auto",
|
|
212
237
|
},
|
|
238
|
+
...changeStyles("-", colors.red),
|
|
239
|
+
...changeStyles("+", colors.green),
|
|
240
|
+
...changeStyles("~", colors.amber),
|
|
213
241
|
}),
|
|
214
242
|
};
|
|
243
|
+
|
|
244
|
+
/** Colors a `mermaid diff`'s marked nodes and edges; removed ones are dashed. */
|
|
245
|
+
function changeStyles(sign: string, scale: typeof colors.red) {
|
|
246
|
+
const marked = `[data-change='${sign}']`;
|
|
247
|
+
const dash = sign === "-" ? { strokeDasharray: "5 3" } : {};
|
|
248
|
+
return {
|
|
249
|
+
[`& [data-id]${marked} > :is(rect, polygon, circle, ellipse, path), & .edge-label${marked} > rect`]:
|
|
250
|
+
{ fill: scale[2], stroke: scale[9], strokeWidth: 1.5, ...dash },
|
|
251
|
+
[`& ${marked} text`]: { fill: scale[11] },
|
|
252
|
+
[`& :is(polyline, line)${marked}, & ${marked} > :is(line, polyline, path)`]:
|
|
253
|
+
{ stroke: scale[9], strokeWidth: 1.5, ...dash },
|
|
254
|
+
};
|
|
255
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { type KeyboardEvent, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { useReducedMotion } from "motion/react";
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
ChevronLeft,
|
|
6
|
+
ChevronRight,
|
|
7
|
+
Pause,
|
|
8
|
+
Play,
|
|
9
|
+
RotateAntiClockwise,
|
|
10
|
+
Text,
|
|
11
|
+
Tooltip,
|
|
12
|
+
colors,
|
|
13
|
+
flex,
|
|
14
|
+
focusRing,
|
|
15
|
+
radius,
|
|
16
|
+
spacing,
|
|
17
|
+
text,
|
|
18
|
+
} from "maui";
|
|
19
|
+
import { style, useStyles } from "purse-styles";
|
|
20
|
+
import type { SourceNavigation } from "../annotations.js";
|
|
21
|
+
import { fenceTitle, pierreShell } from "./pierre.js";
|
|
22
|
+
import { ServiceStage, TRAVEL_MS } from "./ServiceStage.tsx";
|
|
23
|
+
import {
|
|
24
|
+
serviceStateAt,
|
|
25
|
+
type ServiceDiagram,
|
|
26
|
+
type ServicesParse,
|
|
27
|
+
} from "../services.js";
|
|
28
|
+
|
|
29
|
+
const DWELL_MS = 1200;
|
|
30
|
+
|
|
31
|
+
/** Names a diff step's side before its caption. */
|
|
32
|
+
const signLabel = { "+": "Added: ", "-": "Removed: ", " ": "" } as const;
|
|
33
|
+
|
|
34
|
+
type Phase = "travel" | "settled";
|
|
35
|
+
|
|
36
|
+
export function ServiceDiagramBlock(
|
|
37
|
+
props: {
|
|
38
|
+
source: string;
|
|
39
|
+
parsed: ServicesParse;
|
|
40
|
+
} & SourceNavigation,
|
|
41
|
+
) {
|
|
42
|
+
const error = useStyles(styles.error);
|
|
43
|
+
if (!props.parsed.ok)
|
|
44
|
+
return (
|
|
45
|
+
<div className={error} data-diffmap-kind="services">
|
|
46
|
+
Line {props.parsed.line}: {props.parsed.reason}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
// Remount on edits so playback restarts and the stage measures new boxes.
|
|
50
|
+
return (
|
|
51
|
+
<ServicePlayer
|
|
52
|
+
key={props.source}
|
|
53
|
+
{...props}
|
|
54
|
+
diagram={props.parsed.diagram}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ServicePlayer(props: { diagram: ServiceDiagram } & SourceNavigation) {
|
|
60
|
+
const diagram = props.diagram;
|
|
61
|
+
const last = diagram.steps.length;
|
|
62
|
+
const reduceMotion = Boolean(useReducedMotion());
|
|
63
|
+
const [position, setPosition] = useState<{ step: number; phase: Phase }>({
|
|
64
|
+
step: 0,
|
|
65
|
+
phase: "settled",
|
|
66
|
+
});
|
|
67
|
+
const [playing, setPlaying] = useState(false);
|
|
68
|
+
const states = useMemo(
|
|
69
|
+
() =>
|
|
70
|
+
Array.from({ length: last + 1 }, (_, step) => ({
|
|
71
|
+
today: serviceStateAt(diagram, step, "today"),
|
|
72
|
+
proposed: serviceStateAt(diagram, step, "proposed"),
|
|
73
|
+
})),
|
|
74
|
+
[diagram, last],
|
|
75
|
+
);
|
|
76
|
+
const step = position.step;
|
|
77
|
+
const current = step === 0 ? undefined : diagram.steps[step - 1]!;
|
|
78
|
+
// Before step 1, the author's start caption, if any, explains the state.
|
|
79
|
+
const captioned = current ?? diagram.start;
|
|
80
|
+
const settled = position.phase === "settled";
|
|
81
|
+
// In flight, the events have not landed, so show the previous state.
|
|
82
|
+
const shownState = states[settled ? step : step - 1]!;
|
|
83
|
+
const changed = new Set(
|
|
84
|
+
settled && current !== undefined
|
|
85
|
+
? current.updates.map((update) => `${update.service}.${update.key}`)
|
|
86
|
+
: [],
|
|
87
|
+
);
|
|
88
|
+
const active = new Set(
|
|
89
|
+
current?.events.flatMap((event) => [event.from, event.to]) ?? [],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const goTo = (target: number, animate: boolean) =>
|
|
93
|
+
setPosition(positionFor(diagram, target, animate && !reduceMotion));
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (position.phase !== "travel") return;
|
|
97
|
+
const timer = setTimeout(
|
|
98
|
+
() => setPosition({ step: position.step, phase: "settled" }),
|
|
99
|
+
TRAVEL_MS,
|
|
100
|
+
);
|
|
101
|
+
return () => clearTimeout(timer);
|
|
102
|
+
}, [position]);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!playing || position.phase !== "settled") return;
|
|
106
|
+
const timer = setTimeout(
|
|
107
|
+
() => {
|
|
108
|
+
if (position.step >= last) setPlaying(false);
|
|
109
|
+
else
|
|
110
|
+
setPosition(positionFor(diagram, position.step + 1, !reduceMotion));
|
|
111
|
+
},
|
|
112
|
+
position.step === 0 ? 300 : DWELL_MS,
|
|
113
|
+
);
|
|
114
|
+
return () => clearTimeout(timer);
|
|
115
|
+
}, [playing, position, last, diagram, reduceMotion]);
|
|
116
|
+
|
|
117
|
+
const prev = () => {
|
|
118
|
+
setPlaying(false);
|
|
119
|
+
if (step > 0) goTo(step - 1, false);
|
|
120
|
+
};
|
|
121
|
+
const next = () => {
|
|
122
|
+
setPlaying(false);
|
|
123
|
+
if (step < last) goTo(step + 1, true);
|
|
124
|
+
};
|
|
125
|
+
const togglePlay = () => {
|
|
126
|
+
if (playing) return setPlaying(false);
|
|
127
|
+
if (step >= last) goTo(0, false);
|
|
128
|
+
setPlaying(true);
|
|
129
|
+
};
|
|
130
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
131
|
+
if (event.key === "ArrowLeft") {
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
prev();
|
|
134
|
+
}
|
|
135
|
+
if (event.key === "ArrowRight") {
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
next();
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const frame = useStyles(pierreShell, styles.frame);
|
|
142
|
+
const body = useStyles(styles.body);
|
|
143
|
+
const controls = useStyles(styles.controls);
|
|
144
|
+
const scrubber = useStyles(styles.scrubber);
|
|
145
|
+
const segment = useStyles(styles.segment);
|
|
146
|
+
const detail = useStyles(fenceTitle, styles.detail);
|
|
147
|
+
const counter = useStyles(styles.counter);
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<figure
|
|
151
|
+
className={frame}
|
|
152
|
+
data-diffmap-kind="services"
|
|
153
|
+
// Capture: Maui's Button stops arrow keys from bubbling.
|
|
154
|
+
onKeyDownCapture={onKeyDown}
|
|
155
|
+
>
|
|
156
|
+
{/* The caption leads: before step 1 it is the start caption, which
|
|
157
|
+
captions the whole diagram. */}
|
|
158
|
+
{(captioned !== undefined || last > 0) && (
|
|
159
|
+
<figcaption className={detail} aria-live="polite">
|
|
160
|
+
{captioned !== undefined && (
|
|
161
|
+
<Text size="sm" fontWeight={500}>
|
|
162
|
+
{current !== undefined && current.sign !== " " && (
|
|
163
|
+
<Text
|
|
164
|
+
size="sm"
|
|
165
|
+
fontWeight={600}
|
|
166
|
+
style={{
|
|
167
|
+
color:
|
|
168
|
+
current.sign === "+" ? colors.green[11] : colors.red[11],
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
{signLabel[current.sign]}
|
|
172
|
+
</Text>
|
|
173
|
+
)}
|
|
174
|
+
{captioned.caption}
|
|
175
|
+
</Text>
|
|
176
|
+
)}
|
|
177
|
+
{captioned?.description !== undefined && (
|
|
178
|
+
<Text size="xs" color="lowContrast">
|
|
179
|
+
{captioned.description}
|
|
180
|
+
</Text>
|
|
181
|
+
)}
|
|
182
|
+
</figcaption>
|
|
183
|
+
)}
|
|
184
|
+
<div className={body}>
|
|
185
|
+
{last > 0 && (
|
|
186
|
+
<div className={controls}>
|
|
187
|
+
<Tooltip content="Restart" placement="top">
|
|
188
|
+
<Button
|
|
189
|
+
size="sm"
|
|
190
|
+
variant="quiet"
|
|
191
|
+
aria-label="Restart"
|
|
192
|
+
isDisabled={step === 0 && !playing}
|
|
193
|
+
onClick={() => {
|
|
194
|
+
setPlaying(false);
|
|
195
|
+
goTo(0, false);
|
|
196
|
+
}}
|
|
197
|
+
>
|
|
198
|
+
<RotateAntiClockwise size="xs" />
|
|
199
|
+
</Button>
|
|
200
|
+
</Tooltip>
|
|
201
|
+
<Tooltip content="Previous step (←)" placement="top">
|
|
202
|
+
<Button
|
|
203
|
+
size="sm"
|
|
204
|
+
variant="quiet"
|
|
205
|
+
aria-label="Previous step"
|
|
206
|
+
isDisabled={step === 0}
|
|
207
|
+
onClick={prev}
|
|
208
|
+
>
|
|
209
|
+
<ChevronLeft size="xs" />
|
|
210
|
+
</Button>
|
|
211
|
+
</Tooltip>
|
|
212
|
+
<Button
|
|
213
|
+
size="sm"
|
|
214
|
+
aria-label={playing ? "Pause" : "Play"}
|
|
215
|
+
onClick={togglePlay}
|
|
216
|
+
>
|
|
217
|
+
{playing ? <Pause size="xs" /> : <Play size="xs" />}
|
|
218
|
+
{playing ? "Pause" : step >= last ? "Replay" : "Play"}
|
|
219
|
+
</Button>
|
|
220
|
+
<Tooltip content="Next step (→)" placement="top">
|
|
221
|
+
<Button
|
|
222
|
+
size="sm"
|
|
223
|
+
variant="quiet"
|
|
224
|
+
aria-label="Next step"
|
|
225
|
+
isDisabled={step >= last}
|
|
226
|
+
onClick={next}
|
|
227
|
+
>
|
|
228
|
+
<ChevronRight size="xs" />
|
|
229
|
+
</Button>
|
|
230
|
+
</Tooltip>
|
|
231
|
+
<div className={scrubber} role="group" aria-label="Steps">
|
|
232
|
+
{diagram.steps.map((entry, index) => (
|
|
233
|
+
<Tooltip
|
|
234
|
+
key={index}
|
|
235
|
+
placement="top"
|
|
236
|
+
content={
|
|
237
|
+
<>
|
|
238
|
+
<strong>
|
|
239
|
+
{index + 1}. {entry.caption}
|
|
240
|
+
</strong>
|
|
241
|
+
{entry.description !== undefined && (
|
|
242
|
+
<> {entry.description}</>
|
|
243
|
+
)}
|
|
244
|
+
</>
|
|
245
|
+
}
|
|
246
|
+
>
|
|
247
|
+
<button
|
|
248
|
+
type="button"
|
|
249
|
+
className={segment}
|
|
250
|
+
data-change={entry.sign}
|
|
251
|
+
data-state={
|
|
252
|
+
index + 1 === step
|
|
253
|
+
? "current"
|
|
254
|
+
: index + 1 < step
|
|
255
|
+
? "done"
|
|
256
|
+
: "todo"
|
|
257
|
+
}
|
|
258
|
+
aria-label={`Step ${index + 1}: ${signLabel[entry.sign]}${entry.caption}`}
|
|
259
|
+
aria-current={index + 1 === step ? "step" : undefined}
|
|
260
|
+
onClick={() => {
|
|
261
|
+
setPlaying(false);
|
|
262
|
+
goTo(index + 1, index + 1 === step + 1);
|
|
263
|
+
}}
|
|
264
|
+
/>
|
|
265
|
+
</Tooltip>
|
|
266
|
+
))}
|
|
267
|
+
</div>
|
|
268
|
+
<Text className={counter} size="xs" color="lowContrast" tabular>
|
|
269
|
+
{step} / {last}
|
|
270
|
+
</Text>
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
<ServiceStage
|
|
274
|
+
{...props}
|
|
275
|
+
state={shownState}
|
|
276
|
+
previousState={states[Math.max(step - 1, 0)]!}
|
|
277
|
+
changed={changed}
|
|
278
|
+
active={active}
|
|
279
|
+
events={current?.events ?? []}
|
|
280
|
+
updates={current?.updates ?? []}
|
|
281
|
+
phase={position.phase}
|
|
282
|
+
stepKey={step}
|
|
283
|
+
/>
|
|
284
|
+
</div>
|
|
285
|
+
</figure>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function positionFor(
|
|
290
|
+
diagram: ServiceDiagram,
|
|
291
|
+
step: number,
|
|
292
|
+
animate: boolean,
|
|
293
|
+
): { step: number; phase: Phase } {
|
|
294
|
+
const events = diagram.steps[step - 1]?.events.length ?? 0;
|
|
295
|
+
return { step, phase: animate && events > 0 ? "travel" : "settled" };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const styles = {
|
|
299
|
+
error: style(text({ size: "sm", color: "lowContrast" })),
|
|
300
|
+
// A card like a code block: the caption row, then the controls and stage.
|
|
301
|
+
frame: style({ width: "100%", margin: 0 }),
|
|
302
|
+
body: style(
|
|
303
|
+
flex({ direction: "column", gap: 4 }),
|
|
304
|
+
spacing.padding({ all: 6 }),
|
|
305
|
+
),
|
|
306
|
+
controls: style(flex({ direction: "row", alignItems: "center", gap: 2 }), {
|
|
307
|
+
// Buttons keep their full label; only the step scrubber gives up width.
|
|
308
|
+
"& > button": { flexShrink: 0, overflow: "visible" },
|
|
309
|
+
}),
|
|
310
|
+
scrubber: style(flex({ direction: "row", alignItems: "center", gap: 1 }), {
|
|
311
|
+
flex: "1 1 auto",
|
|
312
|
+
minWidth: 0,
|
|
313
|
+
marginInline: spacing.value(4),
|
|
314
|
+
}),
|
|
315
|
+
segment: style(
|
|
316
|
+
radius.pill,
|
|
317
|
+
{
|
|
318
|
+
flex: "1 1 0",
|
|
319
|
+
minWidth: 6,
|
|
320
|
+
height: 20,
|
|
321
|
+
padding: 0,
|
|
322
|
+
border: 0,
|
|
323
|
+
cursor: "pointer",
|
|
324
|
+
background: "transparent",
|
|
325
|
+
position: "relative",
|
|
326
|
+
"&::after": {
|
|
327
|
+
content: "''",
|
|
328
|
+
position: "absolute",
|
|
329
|
+
insetInline: 0,
|
|
330
|
+
top: 8,
|
|
331
|
+
height: 4,
|
|
332
|
+
borderRadius: 2,
|
|
333
|
+
backgroundColor: colors.gray[5],
|
|
334
|
+
},
|
|
335
|
+
"&:hover::after": { backgroundColor: colors.gray[7] },
|
|
336
|
+
"&[data-state='done']::after": { backgroundColor: colors.accent[7] },
|
|
337
|
+
"&[data-state='current']::after": { backgroundColor: colors.accent[9] },
|
|
338
|
+
"&[data-change='+']::after": { backgroundColor: colors.green[6] },
|
|
339
|
+
"&[data-change='+'][data-state='done']::after": {
|
|
340
|
+
backgroundColor: colors.green[8],
|
|
341
|
+
},
|
|
342
|
+
"&[data-change='+'][data-state='current']::after": {
|
|
343
|
+
backgroundColor: colors.green[10],
|
|
344
|
+
},
|
|
345
|
+
"&[data-change='-']::after": { backgroundColor: colors.red[6] },
|
|
346
|
+
"&[data-change='-'][data-state='done']::after": {
|
|
347
|
+
backgroundColor: colors.red[8],
|
|
348
|
+
},
|
|
349
|
+
"&[data-change='-'][data-state='current']::after": {
|
|
350
|
+
backgroundColor: colors.red[10],
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
focusRing(),
|
|
354
|
+
),
|
|
355
|
+
counter: style({ flexShrink: 0, minWidth: "5ch", textAlign: "end" }),
|
|
356
|
+
// Holds two lines so the controls below don't move between steps.
|
|
357
|
+
detail: style(flex({ direction: "column", gap: 2 }), { minHeight: 44 }),
|
|
358
|
+
};
|