@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.
@@ -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
- <div className={frame}>
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
- </div>
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: props.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
- [props.source],
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
- minWidth: 0,
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: 0,
163
- insetInlineEnd: 0,
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
- justifyContent: "flex-end",
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
+ };