@tanishqkancharla/diffmap 0.4.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.
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
2
2
  import { CodeView, type CodeViewHandle } from "@pierre/diffs/react";
3
3
  import type { CodeViewOptions, CodeViewLineSelection } from "@pierre/diffs";
4
4
  import {
5
+ ActivityIndicator,
5
6
  backgroundColor,
6
7
  border,
7
8
  borderColor,
@@ -9,7 +10,11 @@ import {
9
10
  Button,
10
11
  ChevronDown,
11
12
  flex,
13
+ motion,
14
+ radius,
15
+ shadow,
12
16
  spacing,
17
+ Text,
13
18
  text,
14
19
  useTheme,
15
20
  } from "maui";
@@ -19,7 +24,12 @@ import {
19
24
  type SourceAnnotation,
20
25
  type SourceReference,
21
26
  } from "../annotations.js";
22
- import { changedFileItem, orderForReview, type Rev } from "../changes.js";
27
+ import {
28
+ changedFileItem,
29
+ orderForReview,
30
+ type Changes,
31
+ type Rev,
32
+ } from "../changes.js";
23
33
  import type { SourceDefinition } from "../definitions.js";
24
34
  import { useGistPin } from "../gist/pin.ts";
25
35
  import { useGitHubPin } from "../github/pin.ts";
@@ -40,7 +50,18 @@ export type SourceSelection = {
40
50
  type DefinitionNavigation = {
41
51
  selection: SourceSelection | undefined;
42
52
  history: SourceDefinition[];
43
- status: string | undefined;
53
+ /** A definition request in flight, or the error it ended with. */
54
+ request: "finding" | Error | undefined;
55
+ };
56
+
57
+ /** Everything the panel renders from; it keeps the last loaded page while the next one loads. */
58
+ type PanelPage = {
59
+ rev: Rev;
60
+ review: string[];
61
+ selection: SourceSelection | undefined;
62
+ history: SourceDefinition[];
63
+ changes: Changes | Error | undefined;
64
+ source: SourceDefinition | Error | undefined;
44
65
  };
45
66
 
46
67
  export function SourceDiffPanel(props: {
@@ -59,35 +80,74 @@ export function SourceDiffPanel(props: {
59
80
  const [navigation, setNavigation] = useState<DefinitionNavigation>({
60
81
  selection: props.selection,
61
82
  history: [],
62
- status: undefined,
83
+ request: undefined,
63
84
  });
64
85
  if (navigation.selection !== props.selection) {
65
86
  setNavigation({
66
87
  selection: props.selection,
67
88
  history: [],
68
- status: undefined,
89
+ request: undefined,
69
90
  });
70
91
  }
71
- const { history } = navigation;
72
92
  const request = useRef(0);
93
+ const hostFiles = useHostFiles();
94
+ const nextChanges = useChanges(props.rev);
95
+ const nextReference = props.selection?.reference;
96
+ // A rev-less reference resolves against current files; a commit reference names its lines directly.
97
+ const nextSource = useSourceReference(
98
+ nextReference?.kind === "file" && nextReference.rev === undefined
99
+ ? nextReference
100
+ : undefined,
101
+ );
102
+ const loading =
103
+ nextChanges === undefined
104
+ ? "Loading changes…"
105
+ : nextReference?.kind === "file" &&
106
+ nextReference.rev === undefined &&
107
+ nextSource === undefined &&
108
+ (mode === "local" || mode === "github" || hostFiles !== undefined)
109
+ ? "Opening source…"
110
+ : navigation.request === "finding"
111
+ ? "Finding definition…"
112
+ : undefined;
113
+ const next: PanelPage = {
114
+ rev: props.rev,
115
+ review: props.review,
116
+ selection: props.selection,
117
+ history: navigation.history,
118
+ changes: nextChanges,
119
+ source: nextSource,
120
+ };
121
+ // The new page replaces the old one only once it has loaded, so nothing half-loaded shows in between.
122
+ const [page, setPage] = useState(next);
123
+ if (
124
+ loading === undefined &&
125
+ (page.rev !== next.rev ||
126
+ page.review !== next.review ||
127
+ page.selection !== next.selection ||
128
+ page.history !== next.history ||
129
+ page.changes !== next.changes ||
130
+ page.source !== next.source)
131
+ ) {
132
+ setPage(next);
133
+ }
134
+ const { history, selection, changes, source, review } = page;
73
135
  const definition = history.at(-1);
74
136
  const panel = useStyles(styles.panel);
75
- const header = useStyles(styles.header);
137
+ const header = useStyles(styles.header, styles.dimmed);
76
138
  const titleRow = useStyles(styles.titleRow);
77
139
  const titleClass = useStyles(styles.title);
78
140
  const pinClass = useStyles(styles.pin);
79
141
  const links = useStyles(styles.links);
80
142
  const code = useStyles(styles.code);
81
143
  const summary = useStyles(styles.summary);
82
- const codeWrap = useStyles(styles.codeWrap);
144
+ const codeWrap = useStyles(styles.codeWrap, styles.dimmed);
83
145
  const collapseIcon = useStyles(styles.collapseIcon);
84
146
  const github = useGitHubPin();
85
147
  const gist = useGistPin();
86
- const hostFiles = useHostFiles();
87
- const selection = props.selection;
148
+ const loadingClass = useStyles(styles.loading);
149
+ const spinner = useStyles(styles.spinner);
88
150
  const reference = selection?.reference;
89
- const changes = useChanges(props.rev);
90
- const { review } = props;
91
151
  const items = useMemo(() => {
92
152
  if (changes === undefined || changes instanceof Error) return [];
93
153
  const { reviewed, other } = orderForReview(changes.files, review);
@@ -95,12 +155,6 @@ export function SourceDiffPanel(props: {
95
155
  (changed) => changedFileItem(changed) ?? [],
96
156
  );
97
157
  }, [changes, review]);
98
- // A rev-less reference resolves against current files; a commit reference names its lines directly.
99
- const source = useSourceReference(
100
- reference?.kind === "file" && reference.rev === undefined
101
- ? reference
102
- : undefined,
103
- );
104
158
  const resolvedFile = source instanceof Error ? undefined : source;
105
159
  const inChanges =
106
160
  resolvedFile !== undefined &&
@@ -111,25 +165,16 @@ export function SourceDiffPanel(props: {
111
165
  ? resolvedFile
112
166
  : undefined);
113
167
  const status =
114
- navigation.status !== undefined
115
- ? navigation.status
168
+ navigation.request instanceof Error
169
+ ? navigation.request.message
116
170
  : source instanceof Error
117
171
  ? source.message
118
172
  : // A shown file replaces the changes, so their error is moot, as in a gist without uncommitted changes.
119
173
  changes instanceof Error && file === undefined
120
174
  ? changes.message
121
- : changes === undefined
122
- ? "Loading changes…"
123
- : reference?.kind === "file" &&
124
- reference.rev === undefined &&
125
- source === undefined &&
126
- (mode === "local" ||
127
- mode === "github" ||
128
- hostFiles !== undefined)
129
- ? "Opening source…"
130
- : items.length === 0 && file === undefined
131
- ? "No changes."
132
- : undefined;
175
+ : changes !== undefined && items.length === 0 && file === undefined
176
+ ? "No changes."
177
+ : undefined;
133
178
  const selectedLines = useMemo<CodeViewLineSelection | null>(() => {
134
179
  if (file !== undefined)
135
180
  return { id: file.path, range: { start: file.start, end: file.end } };
@@ -156,9 +201,9 @@ export function SourceDiffPanel(props: {
156
201
  rev: Rev;
157
202
  selection: SourceSelection | undefined;
158
203
  paths: ReadonlySet<string>;
159
- }>({ rev: props.rev, selection, paths: new Set() });
160
- if (collapse.rev !== props.rev || collapse.selection !== selection) {
161
- setCollapse({ rev: props.rev, selection, paths: new Set() });
204
+ }>({ rev: page.rev, selection, paths: new Set() });
205
+ if (collapse.rev !== page.rev || collapse.selection !== selection) {
206
+ setCollapse({ rev: page.rev, selection, paths: new Set() });
162
207
  }
163
208
  const rangeId = selectedLines?.id;
164
209
  const rangeStart = selectedLines?.range.start;
@@ -300,28 +345,33 @@ export function SourceDiffPanel(props: {
300
345
  text: lineText,
301
346
  });
302
347
  const currentRequest = ++request.current;
303
- setNavigation({ selection, history, status: "Finding definition…" });
348
+ setNavigation({ selection, history, request: "finding" });
304
349
  // oxlint-disable-next-line typescript/no-floating-promises -- Token callbacks cannot await; the request owns its status update.
305
350
  void requestSource("definition", params).then((result) => {
306
351
  if (currentRequest !== request.current) return;
307
352
  setNavigation((current) => {
308
353
  if (current.selection !== selection) return current;
309
- if (result instanceof Error)
310
- return { ...current, status: result.message };
354
+ if (result instanceof Error) return { ...current, request: result };
311
355
  return {
312
356
  selection,
313
357
  history: [...history, result],
314
- status: undefined,
358
+ request: undefined,
315
359
  };
316
360
  });
317
361
  });
318
362
  };
319
363
 
320
364
  return (
321
- <aside id="source-diff-panel" aria-label="Source changes" className={panel}>
365
+ <aside
366
+ id="source-diff-panel"
367
+ aria-label="Source changes"
368
+ aria-busy={loading !== undefined}
369
+ className={panel}
370
+ >
322
371
  <div
323
372
  className={header}
324
373
  data-diffmap-kind="source-header"
374
+ data-loading={loading !== undefined}
325
375
  // Pierre's file headers draw their own top border, which divides them from this header.
326
376
  data-file-headers={!showTitle && items.length > 0}
327
377
  >
@@ -337,7 +387,11 @@ export function SourceDiffPanel(props: {
337
387
  props.onRevChange === undefined &&
338
388
  loadedChanges !== undefined && (
339
389
  <div className={titleRow}>
340
- {loadedChanges.url === undefined ? (
390
+ {selection?.annotation.title !== undefined ? (
391
+ <div className={titleClass} title={selection.annotation.title}>
392
+ {selection.annotation.title}
393
+ </div>
394
+ ) : loadedChanges.url === undefined ? (
341
395
  <div className={titleClass} title={loadedChanges.title}>
342
396
  {loadedChanges.title}
343
397
  </div>
@@ -365,7 +419,7 @@ export function SourceDiffPanel(props: {
365
419
  setNavigation({
366
420
  selection,
367
421
  history: history.slice(0, -1),
368
- status: undefined,
422
+ request: undefined,
369
423
  });
370
424
  }}
371
425
  >
@@ -417,7 +471,13 @@ export function SourceDiffPanel(props: {
417
471
  )}
418
472
  </div>
419
473
  {/* oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- Pierre's file headers are the click targets; this only catches their clicks. */}
420
- <div className={codeWrap} onClick={onHeaderClick}>
474
+ <div
475
+ className={codeWrap}
476
+ data-loading={loading !== undefined}
477
+ // The last page stays visible while the next one loads, but it is no longer the one to act on.
478
+ inert={loading !== undefined}
479
+ onClick={onHeaderClick}
480
+ >
421
481
  <CodeView
422
482
  ref={viewer}
423
483
  className={code}
@@ -458,6 +518,19 @@ export function SourceDiffPanel(props: {
458
518
  )}
459
519
  />
460
520
  </div>
521
+ <div
522
+ className={loadingClass}
523
+ role="status"
524
+ data-loading={loading !== undefined}
525
+ >
526
+ {loading !== undefined && (
527
+ // Inline in the label, the spinner sits on the text's baseline at its size, so it lines up in any font.
528
+ <Text size="sm" color="lowContrast">
529
+ <ActivityIndicator className={spinner} />
530
+ {loading}
531
+ </Text>
532
+ )}
533
+ </div>
461
534
  </aside>
462
535
  );
463
536
  }
@@ -468,6 +541,7 @@ const panelBackgroundCss = `:host { --diffs-light-bg: ${backgroundColor.app}; --
468
541
 
469
542
  const styles = {
470
543
  panel: style(flex({ direction: "column" }), border(["left"], "border"), {
544
+ position: "relative",
471
545
  gridArea: "diff",
472
546
  minWidth: 0,
473
547
  minHeight: 0,
@@ -527,6 +601,28 @@ const styles = {
527
601
  minWidth: 0,
528
602
  minHeight: 0,
529
603
  }),
604
+ // Loads that finish within the delay swap pages without dimming.
605
+ dimmed: style(motion.standard("opacity"), {
606
+ "&[data-loading='true']": { opacity: 0.4, transitionDelay: "150ms" },
607
+ }),
608
+ loading: style(
609
+ radius.md,
610
+ shadow.medium,
611
+ spacing.padding({ x: 6, y: 3 }),
612
+ motion.standard("opacity"),
613
+ {
614
+ position: "absolute",
615
+ top: "50%",
616
+ left: "50%",
617
+ transform: "translate(-50%, -50%)",
618
+ whiteSpace: "nowrap",
619
+ backgroundColor: backgroundColor.app,
620
+ pointerEvents: "none",
621
+ opacity: 0,
622
+ "&[data-loading='true']": { opacity: 1, transitionDelay: "150ms" },
623
+ },
624
+ ),
625
+ spinner: style({ marginRight: spacing.value(4) }),
530
626
  collapseIcon: style({
531
627
  display: "flex",
532
628
  color: colors.gray[11],
@@ -10,6 +10,7 @@ import { motion, useReducedMotion, type Transition } from "motion/react";
10
10
  import {
11
11
  background,
12
12
  colors,
13
+ Drawer,
13
14
  focusRing,
14
15
  motionEasing,
15
16
  overlayMotion,
@@ -67,6 +68,9 @@ export function tocLineWidth(level: number, shallowestLevel: number) {
67
68
  export function TableOfContents(props: {
68
69
  headings: ViewerHeading[];
69
70
  articleRef: RefObject<HTMLElement | null>;
71
+ /** The narrow-screen drawer, opened from the header's TOC button. */
72
+ drawerOpen: boolean;
73
+ onDrawerOpenChange: (open: boolean) => void;
70
74
  }) {
71
75
  const headings = tocHeadings(props.headings);
72
76
  const items = nestHeadings(headings);
@@ -78,6 +82,7 @@ export function TableOfContents(props: {
78
82
  const docked = useDocked(navRef);
79
83
  const railClass = useStyles(styles.rail);
80
84
  const dockedClass = useStyles(styles.docked);
85
+ const drawerClass = useStyles(styles.drawer);
81
86
  const listClass = useStyles(styles.list);
82
87
 
83
88
  if (items.length === 0) return undefined;
@@ -92,24 +97,43 @@ export function TableOfContents(props: {
92
97
  );
93
98
 
94
99
  return (
95
- <nav
96
- ref={navRef}
97
- id="diffmap-toc"
98
- className={railClass}
99
- aria-label="Table of contents"
100
- data-diffmap-kind="toc"
101
- data-docked={docked ? "true" : "false"}
102
- >
103
- {docked ? (
104
- <div className={dockedClass} data-diffmap-kind="toc-list">
105
- {list}
106
- </div>
107
- ) : (
108
- <TocRail headings={headings} activeId={activeId}>
109
- {list}
110
- </TocRail>
111
- )}
112
- </nav>
100
+ <>
101
+ <nav
102
+ ref={navRef}
103
+ id="diffmap-toc"
104
+ className={railClass}
105
+ aria-label="Table of contents"
106
+ data-diffmap-kind="toc"
107
+ data-docked={docked ? "true" : "false"}
108
+ >
109
+ {docked ? (
110
+ <div className={dockedClass} data-diffmap-kind="toc-list">
111
+ {list}
112
+ </div>
113
+ ) : (
114
+ <TocRail headings={headings} activeId={activeId}>
115
+ {list}
116
+ </TocRail>
117
+ )}
118
+ </nav>
119
+ <Drawer
120
+ isOpen={props.drawerOpen}
121
+ onOpenChange={props.onDrawerOpenChange}
122
+ aria-label="Table of contents"
123
+ >
124
+ <nav className={drawerClass} aria-label="Table of contents">
125
+ <TocList
126
+ items={items}
127
+ className={listClass}
128
+ activeId={activeId}
129
+ onSelect={(id) => {
130
+ setActiveId(id);
131
+ props.onDrawerOpenChange(false);
132
+ }}
133
+ />
134
+ </nav>
135
+ </Drawer>
136
+ </>
113
137
  );
114
138
  }
115
139
 
@@ -458,6 +482,8 @@ const styles = {
458
482
  pointerEvents: "auto",
459
483
  ...tocListRules,
460
484
  }),
485
+ // The same list in Maui's Drawer, which supplies the surface and scrolling.
486
+ drawer: style(spacing.padding({ x: 3, y: 3 }), tocListRules),
461
487
  list: style(text({ size: "sm", fontWeight: 400, color: "lowContrast" })),
462
488
  item: style({
463
489
  "&::before": {
@@ -0,0 +1,28 @@
1
+ import { Button, UnorderedList } from "maui";
2
+ import { style, useStyles } from "purse-styles";
3
+
4
+ /** Opens the table of contents on narrow screens, where the gutter rail is hidden. */
5
+ export function TocButton(props: { expanded: boolean; onClick: () => void }) {
6
+ const className = useStyles(styles.button);
7
+ return (
8
+ <Button
9
+ className={className}
10
+ variant="quiet"
11
+ aria-label="Table of contents"
12
+ aria-haspopup="dialog"
13
+ aria-expanded={props.expanded}
14
+ onClick={props.onClick}
15
+ >
16
+ <UnorderedList size="sm" />
17
+ </Button>
18
+ );
19
+ }
20
+
21
+ const styles = {
22
+ button: style({
23
+ flexShrink: 0,
24
+ "@media (min-width: 601px)": {
25
+ display: "none",
26
+ },
27
+ }),
28
+ };
@@ -19,6 +19,7 @@ import { ComarkView } from "./ComarkView.tsx";
19
19
  import { SourceDiffPanel, type SourceSelection } from "./SourceDiffPanel.js";
20
20
  import { CloseServerButton } from "./CloseServerButton.tsx";
21
21
  import { DiffButton } from "./DiffButton.tsx";
22
+ import { TocButton } from "./TocButton.tsx";
22
23
  import {
23
24
  hasTableOfContents,
24
25
  TableOfContents,
@@ -87,6 +88,7 @@ export function ViewerApp(props: {
87
88
  const stage = useStyles(styles.stage);
88
89
  const articleRef = useRef<HTMLElement>(null);
89
90
  const hasToc = hasTableOfContents(viewerDocument.headings);
91
+ const [tocDrawerOpen, setTocDrawerOpen] = useState(false);
90
92
  useEffect(() => {
91
93
  document.title = title;
92
94
  }, [title]);
@@ -113,6 +115,12 @@ export function ViewerApp(props: {
113
115
  <div className={shell}>
114
116
  <header className={header}>
115
117
  <div className={heading}>
118
+ {hasToc && (
119
+ <TocButton
120
+ expanded={tocDrawerOpen}
121
+ onClick={() => setTocDrawerOpen(true)}
122
+ />
123
+ )}
116
124
  <div className={titleClass}>{title}</div>
117
125
  </div>
118
126
  <div className={actions}>
@@ -155,6 +163,8 @@ export function ViewerApp(props: {
155
163
  <TableOfContents
156
164
  headings={viewerDocument.headings}
157
165
  articleRef={articleRef}
166
+ drawerOpen={tocDrawerOpen}
167
+ onDrawerOpenChange={setTocDrawerOpen}
158
168
  />
159
169
  )}
160
170
  </div>
@@ -0,0 +1,69 @@
1
+ const edgeSelector =
2
+ ".edge, .edge-label, .message, .class-relationship, .er-relationship";
3
+
4
+ /**
5
+ * Marks what changed in a `mermaid diff`. `svg` draws every line; `today`
6
+ * and `proposed` draw each side alone. A node or edge missing from Proposed
7
+ * gets data-change="-", one missing from Today "+", and a node whose label
8
+ * differs between the sides "~".
9
+ */
10
+ export function markDiagramChanges(
11
+ svg: string,
12
+ sides: { today: string; proposed: string },
13
+ ) {
14
+ const document = parse(svg);
15
+ const today = parse(sides.today);
16
+ const proposed = parse(sides.proposed);
17
+
18
+ const todayNodes = nodeLabels(today);
19
+ const proposedNodes = nodeLabels(proposed);
20
+ for (const node of document.querySelectorAll("[data-id]")) {
21
+ const id = node.getAttribute("data-id");
22
+ if (!proposedNodes.has(id)) node.setAttribute("data-change", "-");
23
+ else if (!todayNodes.has(id)) node.setAttribute("data-change", "+");
24
+ else if (todayNodes.get(id) !== proposedNodes.get(id))
25
+ node.setAttribute("data-change", "~");
26
+ }
27
+ // A removed sequence participant takes its lifeline with it.
28
+ for (const lifeline of document.querySelectorAll("[data-actor]")) {
29
+ const id = lifeline.getAttribute("data-actor");
30
+ if (!proposedNodes.has(id)) lifeline.setAttribute("data-change", "-");
31
+ else if (!todayNodes.has(id)) lifeline.setAttribute("data-change", "+");
32
+ }
33
+
34
+ const todayEdges = edgeKeys(today);
35
+ const proposedEdges = edgeKeys(proposed);
36
+ for (const edge of document.querySelectorAll(edgeSelector)) {
37
+ const key = edgeKey(edge);
38
+ if (!proposedEdges.has(key)) edge.setAttribute("data-change", "-");
39
+ else if (!todayEdges.has(key)) edge.setAttribute("data-change", "+");
40
+ }
41
+ return document.documentElement.outerHTML;
42
+ }
43
+
44
+ function parse(svg: string) {
45
+ return new DOMParser().parseFromString(svg, "image/svg+xml");
46
+ }
47
+
48
+ function nodeLabels(document: Document) {
49
+ return new Map(
50
+ [...document.querySelectorAll("[data-id]")].map((node) => [
51
+ node.getAttribute("data-id"),
52
+ node.getAttribute("data-label"),
53
+ ]),
54
+ );
55
+ }
56
+
57
+ /** An edge, edge label, or message by its kind, ends, and label. */
58
+ function edgeKey(edge: Element) {
59
+ return [
60
+ edge.getAttribute("class"),
61
+ edge.getAttribute("data-from"),
62
+ edge.getAttribute("data-to"),
63
+ edge.getAttribute("data-label") ?? "",
64
+ ].join("\u0000");
65
+ }
66
+
67
+ function edgeKeys(document: Document) {
68
+ return new Set([...document.querySelectorAll(edgeSelector)].map(edgeKey));
69
+ }
@@ -1,14 +1,21 @@
1
+ import { colors } from "maui";
1
2
  import type { DiagramAnnotation } from "../diagramAnnotations.js";
2
3
  import { DiffmapAnnotationError } from "../errors.js";
3
4
 
5
+ /**
6
+ * Arrowhead colors for edges that are not plain gray: changed in a diff,
7
+ * hovered, or selected. Later tones win when several apply.
8
+ */
9
+ const markerTones = [
10
+ ["added", "[data-change='+']", colors.green[9]],
11
+ ["removed", "[data-change='-']", colors.red[9]],
12
+ ["hover", ".source-edge:hover", colors.blue[8]],
13
+ ["selected", ".source-edge[aria-pressed='true']", colors.blue[9]],
14
+ ] as const;
15
+
4
16
  export function linkDiagram(svg: string, links: DiagramAnnotation[]) {
5
17
  const document = new DOMParser().parseFromString(svg, "image/svg+xml");
6
- for (const markerPart of document.querySelectorAll("marker *")) {
7
- if (markerPart.getAttribute("fill") === "var(--_arrow)")
8
- markerPart.setAttribute("fill", "context-stroke");
9
- if (markerPart.getAttribute("stroke") === "var(--_arrow)")
10
- markerPart.setAttribute("stroke", "context-stroke");
11
- }
18
+ colorMarkers(document);
12
19
  const edges = [
13
20
  ...document.querySelectorAll<SVGElement>(
14
21
  ".edge, .message, .class-relationship, .er-relationship",
@@ -72,3 +79,44 @@ export function linkDiagram(svg: string, links: DiagramAnnotation[]) {
72
79
  }
73
80
  return document.documentElement.outerHTML;
74
81
  }
82
+
83
+ /**
84
+ * A marker draws in its own colors, not its edge's, and `context-stroke`
85
+ * falls back to black where browsers lack it. So each marker gets one copy
86
+ * per tone, and a style rule swaps an edge to the copy its state needs.
87
+ */
88
+ function colorMarkers(document: Document) {
89
+ const rules: string[] = [];
90
+ for (const marker of document.querySelectorAll("marker[id]")) {
91
+ const id = marker.id;
92
+ for (const [tone, , color] of markerTones) {
93
+ // SAFETY: cloning an element preserves its element type.
94
+ const copy = marker.cloneNode(true) as Element;
95
+ copy.id = `${id}--${tone}`;
96
+ paintArrow(copy, color);
97
+ marker.after(copy);
98
+ }
99
+ // Mermaid draws arrowheads in the accent color; match the gray line.
100
+ paintArrow(marker, "var(--_line)");
101
+ for (const end of ["start", "end"]) {
102
+ const uses = `[marker-${end}="url(#${id})"]`;
103
+ if (document.querySelector(uses) === null) continue;
104
+ for (const [tone, state] of markerTones)
105
+ rules.push(
106
+ `:is(${state}, ${state} *)${uses} { marker-${end}: url(#${id}--${tone}); }`,
107
+ );
108
+ }
109
+ }
110
+ if (rules.length === 0) return;
111
+ const style = document.createElementNS("http://www.w3.org/2000/svg", "style");
112
+ style.textContent = rules.join("\n");
113
+ document.documentElement.prepend(style);
114
+ }
115
+
116
+ /** Repaints a marker's mermaid arrow color. */
117
+ function paintArrow(marker: Element, color: string) {
118
+ for (const part of [marker, ...marker.querySelectorAll("*")])
119
+ for (const paint of ["fill", "stroke"])
120
+ if (part.getAttribute(paint) === "var(--_arrow)")
121
+ part.setAttribute(paint, color);
122
+ }
@@ -1,9 +1,18 @@
1
- import { colors, radius, shadow } from "maui";
1
+ import {
2
+ backgroundColor,
3
+ border,
4
+ colors,
5
+ radius,
6
+ shadow,
7
+ spacing,
8
+ text,
9
+ } from "maui";
2
10
  import { style } from "purse-styles";
3
11
  import { codeFontFamily } from "../codeFont.js";
4
12
  import { diffsTheme } from "./diffsTheme.ts";
5
13
 
6
- const pierreUnsafeCss = `:host { --diffs-font-family: ${codeFontFamily}; }`;
14
+ // Pierre paints its own white; widgets use the element surface instead.
15
+ const pierreUnsafeCss = `:host { --diffs-font-family: ${codeFontFamily}; --diffs-light-bg: ${backgroundColor.app}; --diffs-dark-bg: ${backgroundColor.app}; }`;
7
16
 
8
17
  export const sourceSelectionCss = `
9
18
  [data-selected-line] {
@@ -18,11 +27,20 @@ export const sourceSelectionCss = `
18
27
  }
19
28
  `;
20
29
 
30
+ /** The card every widget sits in: a subtle ring on the page background. */
21
31
  export const pierreShell = style(radius.md, shadow.subtle, {
22
32
  overflow: "hidden",
23
33
  minWidth: 0,
24
34
  });
25
35
 
36
+ /** The header row atop a card: a fence's `[Title]` or a diagram's caption. */
37
+ export const fenceTitle = style(
38
+ text({ size: "xs", fontWeight: 500, color: "lowContrast" }),
39
+ spacing.padding({ x: 6, y: 4 }),
40
+ border(["bottom"], "border"),
41
+ { margin: 0, overflowWrap: "anywhere", backgroundColor: colors.gray[2] },
42
+ );
43
+
26
44
  export function pierreDiffOptions(input: {
27
45
  themeType: "light" | "dark";
28
46
  disableFileHeader: boolean;