@zoneflow/renderer-dom 0.0.21 → 0.0.22

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.
@@ -0,0 +1,67 @@
1
+ import type { UniverseModelDiff } from "@zoneflow/core";
2
+ import type { ResolvePathColor, ResolvePathLineColor, ResolvePathStyle } from "./types";
3
+ import type { ResolveZoneColor, ResolveZoneStyle, ZoneStyleOverride } from "./zoneShape";
4
+ export type DiffDecorationStatus = "removed" | "added" | "changed";
5
+ /** Default status colors — red / green / amber. */
6
+ export declare const DIFF_DECORATION_COLORS: Record<DiffDecorationStatus, string>;
7
+ export type DiffDecorationOptions = {
8
+ /** Per-status color overrides. Defaults to {@link DIFF_DECORATION_COLORS}. */
9
+ colors?: Partial<Record<DiffDecorationStatus, string>>;
10
+ /**
11
+ * Style applied to zones marked removed (the "ghost"). Defaults to a dashed
12
+ * outline at 45% opacity. Pass `null` to disable the ghost and keep only
13
+ * the accent color.
14
+ */
15
+ removedZoneStyle?: ZoneStyleOverride | null;
16
+ /**
17
+ * Blink elements marked removed (zones, path nodes/labels, connector
18
+ * lines). On by default — colors alone are ambiguous when the app already
19
+ * colors zones/paths for its own meaning. Set `false` for static colors
20
+ * only. (Pulse is suppressed by the renderer under
21
+ * `prefers-reduced-motion` regardless.)
22
+ */
23
+ pulseRemoved?: boolean;
24
+ /**
25
+ * Resolvers to fall back to for elements the diff does not mark — so the
26
+ * decorations can wrap an app's existing presentation (e.g. `meta.color`
27
+ * based resolvers) instead of replacing it.
28
+ */
29
+ base?: {
30
+ resolveZoneColor?: ResolveZoneColor;
31
+ resolveZoneStyle?: ResolveZoneStyle;
32
+ resolvePathColor?: ResolvePathColor;
33
+ resolvePathLineColor?: ResolvePathLineColor;
34
+ resolvePathStyle?: ResolvePathStyle;
35
+ };
36
+ };
37
+ export type DiffDecorations = {
38
+ resolveZoneColor: ResolveZoneColor;
39
+ resolveZoneStyle: ResolveZoneStyle;
40
+ resolvePathColor: ResolvePathColor;
41
+ resolvePathLineColor: ResolvePathLineColor;
42
+ resolvePathStyle: ResolvePathStyle;
43
+ };
44
+ /**
45
+ * Turn a {@link UniverseModelDiff} into the presentation resolvers that
46
+ * paint diff status onto the canvas:
47
+ *
48
+ * - removed → red accent, red connector lines, dashed ghost zones, and a
49
+ * blink (pulse) so removal stands out even in apps that already use color
50
+ * for their own meaning
51
+ * - added → green accent / lines
52
+ * - changed → amber accent / lines
53
+ *
54
+ * The resolvers match purely by id, so they work with whichever side of the
55
+ * diff you render: draw the `before` model to preview removals and changes
56
+ * ("what will happen"), or the `after` model to review additions and changes
57
+ * ("what just happened"). Ids absent from the rendered model simply never
58
+ * come up.
59
+ *
60
+ * @example
61
+ * const diff = diffUniverseModels(model, cleaned);
62
+ * const deco = createDiffDecorations(diff, {
63
+ * base: { resolvePathColor: myMetaColorResolver },
64
+ * });
65
+ * <UniverseCanvas model={model} {...deco} />
66
+ */
67
+ export declare function createDiffDecorations(diff: UniverseModelDiff, options?: DiffDecorationOptions): DiffDecorations;
@@ -0,0 +1,99 @@
1
+ /** Default status colors — red / green / amber. */
2
+ export const DIFF_DECORATION_COLORS = {
3
+ removed: "#dc2626",
4
+ added: "#16a34a",
5
+ changed: "#d97706",
6
+ };
7
+ const DEFAULT_REMOVED_ZONE_STYLE = {
8
+ borderStyle: "dashed",
9
+ opacity: 0.45,
10
+ };
11
+ /**
12
+ * Turn a {@link UniverseModelDiff} into the presentation resolvers that
13
+ * paint diff status onto the canvas:
14
+ *
15
+ * - removed → red accent, red connector lines, dashed ghost zones, and a
16
+ * blink (pulse) so removal stands out even in apps that already use color
17
+ * for their own meaning
18
+ * - added → green accent / lines
19
+ * - changed → amber accent / lines
20
+ *
21
+ * The resolvers match purely by id, so they work with whichever side of the
22
+ * diff you render: draw the `before` model to preview removals and changes
23
+ * ("what will happen"), or the `after` model to review additions and changes
24
+ * ("what just happened"). Ids absent from the rendered model simply never
25
+ * come up.
26
+ *
27
+ * @example
28
+ * const diff = diffUniverseModels(model, cleaned);
29
+ * const deco = createDiffDecorations(diff, {
30
+ * base: { resolvePathColor: myMetaColorResolver },
31
+ * });
32
+ * <UniverseCanvas model={model} {...deco} />
33
+ */
34
+ export function createDiffDecorations(diff, options = {}) {
35
+ const colors = { ...DIFF_DECORATION_COLORS, ...options.colors };
36
+ const pulseRemoved = options.pulseRemoved ?? true;
37
+ const baseRemovedZoneStyle = options.removedZoneStyle === undefined
38
+ ? DEFAULT_REMOVED_ZONE_STYLE
39
+ : options.removedZoneStyle;
40
+ const removedZoneStyle = baseRemovedZoneStyle === null
41
+ ? pulseRemoved
42
+ ? { pulse: true }
43
+ : null
44
+ : pulseRemoved
45
+ ? { ...baseRemovedZoneStyle, pulse: true }
46
+ : baseRemovedZoneStyle;
47
+ const removedPathStyle = pulseRemoved
48
+ ? { pulse: true }
49
+ : null;
50
+ const base = options.base ?? {};
51
+ const zoneStatusById = new Map();
52
+ for (const zoneId of diff.zones.removed)
53
+ zoneStatusById.set(zoneId, "removed");
54
+ for (const zoneId of diff.zones.added)
55
+ zoneStatusById.set(zoneId, "added");
56
+ for (const zoneId of Object.keys(diff.zones.changed)) {
57
+ zoneStatusById.set(zoneId, "changed");
58
+ }
59
+ const pathStatusById = new Map();
60
+ for (const ref of diff.paths.removed)
61
+ pathStatusById.set(ref.pathId, "removed");
62
+ for (const ref of diff.paths.added)
63
+ pathStatusById.set(ref.pathId, "added");
64
+ for (const pathId of Object.keys(diff.paths.changed)) {
65
+ pathStatusById.set(pathId, "changed");
66
+ }
67
+ return {
68
+ resolveZoneColor: (zone) => {
69
+ const status = zoneStatusById.get(zone.id);
70
+ return status ? colors[status] : base.resolveZoneColor?.(zone);
71
+ },
72
+ resolveZoneStyle: (zone) => {
73
+ const baseStyle = base.resolveZoneStyle?.(zone) ?? undefined;
74
+ if (zoneStatusById.get(zone.id) === "removed" && removedZoneStyle) {
75
+ // Merge so an app's own zone styling (e.g. a custom border) survives
76
+ // under the diff ghost/pulse instead of being dropped.
77
+ return { ...baseStyle, ...removedZoneStyle };
78
+ }
79
+ return baseStyle;
80
+ },
81
+ resolvePathColor: (path) => {
82
+ const status = pathStatusById.get(path.id);
83
+ return status ? colors[status] : base.resolvePathColor?.(path);
84
+ },
85
+ resolvePathLineColor: (path) => {
86
+ const status = pathStatusById.get(path.id);
87
+ return status ? colors[status] : base.resolvePathLineColor?.(path);
88
+ },
89
+ resolvePathStyle: (path) => {
90
+ const baseStyle = base.resolvePathStyle?.(path) ?? undefined;
91
+ if (pathStatusById.get(path.id) === "removed" && removedPathStyle) {
92
+ // A removed path that the app also marks (e.g. dashed "unconfigured")
93
+ // stays dashed AND gains the removal pulse.
94
+ return { ...baseStyle, ...removedPathStyle };
95
+ }
96
+ return baseStyle;
97
+ },
98
+ };
99
+ }
@@ -1,7 +1,7 @@
1
1
  import { resolveZoneAnchorRect } from "../anchors";
2
2
  import { normalizeZoneShape } from "../zoneShape";
3
3
  import { getZoneDepth, isZoneInputEnabled, isZoneOutputEnabled, } from "@zoneflow/core";
4
- import { appendEdgeFlowStyle, resolveCollapsedEdgeStroke, resolveDrawableEdgeSegments, resolveEdgeFlowMotion, } from "./edgeFlow";
4
+ import { appendEdgeFlowStyle, appendPulseStyle, resolveCollapsedEdgeStroke, resolveDrawableEdgeSegments, resolveEdgeFlowMotion, } from "./edgeFlow";
5
5
  const SCENE_PADDING = 64;
6
6
  const RENDER_Z_INDEX = {
7
7
  backgroundLayer: 0,
@@ -13,6 +13,8 @@ const RENDER_Z_INDEX = {
13
13
  pathLayer: 30,
14
14
  };
15
15
  const EDGE_FLOW_CLASS = "zoneflow-edge-flow";
16
+ const PULSE_CLASS = "zoneflow-pulse";
17
+ const PULSE_ANIMATION_NAME = "zoneflow-pulse";
16
18
  function applyStyles(el, styles) {
17
19
  for (const [key, value] of Object.entries(styles)) {
18
20
  // @ts-expect-error CSSStyleDeclaration index access
@@ -175,6 +177,19 @@ function getEdgeColor(params) {
175
177
  ? params.theme.pathEdge
176
178
  : params.theme.pathInboundEdge;
177
179
  }
180
+ // SVG stroke-dasharray for a consumer-chosen line style. Dotted relies on the
181
+ // round linecap (already set on every edge stroke) to render the ~0-length
182
+ // dashes as dots. Returns null for solid/undefined (no dash pattern).
183
+ function getEdgeDashPattern(lineStyle) {
184
+ switch (lineStyle) {
185
+ case "dashed":
186
+ return "7 6";
187
+ case "dotted":
188
+ return "0.1 6";
189
+ default:
190
+ return null;
191
+ }
192
+ }
178
193
  function getBezierCurvePathD(params) {
179
194
  const { source, target } = params;
180
195
  const distanceX = Math.abs(target.x - source.x);
@@ -500,6 +515,13 @@ function drawEdges(params) {
500
515
  className: EDGE_FLOW_CLASS,
501
516
  motion: edgeFlowMotion,
502
517
  });
518
+ // CSS rules are document-global even from an SVG <style>, so this also
519
+ // powers the pulse class on the DOM zone/path layers.
520
+ appendPulseStyle({
521
+ svg,
522
+ animationName: PULSE_ANIMATION_NAME,
523
+ className: PULSE_CLASS,
524
+ });
503
525
  for (const [pathId, edges] of Object.entries(input.pipeline.graphLayout.edgesByPathId)) {
504
526
  const visibility = input.pipeline.visibility.pathVisibilityById[pathId];
505
527
  if (!visibility?.shouldRenderEdge)
@@ -509,27 +531,66 @@ function drawEdges(params) {
509
531
  edges,
510
532
  visibility,
511
533
  });
534
+ // Consumer-resolved per-path line color overrides the theme's edge colors
535
+ // for every segment of this path, collapsed ones included.
536
+ const pathVisual = input.pipeline.graphLayout.pathsById[pathId];
537
+ const lineColor = pathVisual
538
+ ? input.resolvePathLineColor?.(pathVisual.path) ?? undefined
539
+ : undefined;
540
+ const pathStyle = pathVisual
541
+ ? input.resolvePathStyle?.(pathVisual.path) ?? undefined
542
+ : undefined;
543
+ // Pulsing segments are wrapped in a group so the blink animates the
544
+ // group's opacity without fighting the per-stroke flow animation class.
545
+ let edgeOwner = svg;
546
+ if (pathStyle?.pulse) {
547
+ const pulseGroup = createSvgElement("g");
548
+ pulseGroup.setAttribute("class", PULSE_CLASS);
549
+ svg.appendChild(pulseGroup);
550
+ edgeOwner = pulseGroup;
551
+ }
552
+ // dashed/dotted draws a single static patterned stroke and suppresses the
553
+ // moving flow layers — a flowing dash on top of a static dash reads as
554
+ // noise, and an inert path is exactly what "not wired up yet" should look
555
+ // like. Solid keeps the normal base + animated flow stack.
556
+ const patterned = getEdgeDashPattern(pathStyle?.lineStyle);
512
557
  for (const { edge, collapsed } of drawableEdges) {
513
- const stroke = collapsed
514
- ? resolveCollapsedEdgeStroke(input.theme)
515
- : getEdgeColor({
516
- kind: edge.kind,
517
- theme: input.theme,
518
- });
558
+ const stroke = lineColor ??
559
+ (collapsed
560
+ ? resolveCollapsedEdgeStroke(input.theme)
561
+ : getEdgeColor({
562
+ kind: edge.kind,
563
+ theme: input.theme,
564
+ }));
519
565
  const pathD = getBezierCurvePathD({
520
566
  source: edge.source,
521
567
  target: edge.target,
522
568
  });
523
569
  const opacity = getOpacity(visibility.emphasis);
570
+ const baseWidth = edge.kind === "path-to-zone" ? 2.25 : 1.85;
571
+ if (patterned) {
572
+ const dashed = createSvgElement("path");
573
+ dashed.setAttribute("d", pathD);
574
+ dashed.setAttribute("fill", "none");
575
+ dashed.setAttribute("stroke", stroke);
576
+ dashed.setAttribute("stroke-width", String(baseWidth));
577
+ dashed.setAttribute("stroke-linecap", "round");
578
+ dashed.setAttribute("stroke-linejoin", "round");
579
+ dashed.setAttribute("stroke-dasharray", patterned);
580
+ // Sole layer, so render it at full emphasis (no faint base stack).
581
+ dashed.setAttribute("opacity", String(opacity * 0.9));
582
+ edgeOwner.appendChild(dashed);
583
+ continue;
584
+ }
524
585
  const path = createSvgElement("path");
525
586
  path.setAttribute("d", pathD);
526
587
  path.setAttribute("fill", "none");
527
588
  path.setAttribute("stroke", stroke);
528
- path.setAttribute("stroke-width", edge.kind === "path-to-zone" ? "2.25" : "1.85");
589
+ path.setAttribute("stroke-width", String(baseWidth));
529
590
  path.setAttribute("stroke-linecap", "round");
530
591
  path.setAttribute("stroke-linejoin", "round");
531
592
  path.setAttribute("opacity", String(opacity * 0.42));
532
- svg.appendChild(path);
593
+ edgeOwner.appendChild(path);
533
594
  const flowGlow = createSvgElement("path");
534
595
  flowGlow.setAttribute("d", pathD);
535
596
  flowGlow.setAttribute("fill", "none");
@@ -541,7 +602,7 @@ function drawEdges(params) {
541
602
  flowGlow.setAttribute("stroke-dashoffset", edgeFlowMotion.dashOffset);
542
603
  flowGlow.setAttribute("opacity", String(opacity * 0.18));
543
604
  flowGlow.setAttribute("class", EDGE_FLOW_CLASS);
544
- svg.appendChild(flowGlow);
605
+ edgeOwner.appendChild(flowGlow);
545
606
  const flow = createSvgElement("path");
546
607
  flow.setAttribute("d", pathD);
547
608
  flow.setAttribute("fill", "none");
@@ -553,7 +614,7 @@ function drawEdges(params) {
553
614
  flow.setAttribute("stroke-dashoffset", edgeFlowMotion.dashOffset);
554
615
  flow.setAttribute("opacity", String(opacity * 0.94));
555
616
  flow.setAttribute("class", EDGE_FLOW_CLASS);
556
- svg.appendChild(flow);
617
+ edgeOwner.appendChild(flow);
557
618
  }
558
619
  }
559
620
  }
@@ -869,19 +930,28 @@ export const domDrawEngine = {
869
930
  const zoneBodyEl = document.createElement("div");
870
931
  zoneEl.dataset.zoneflowZoneId = zoneVisual.zoneId;
871
932
  zoneBodyEl.dataset.zoneflowZoneBody = zoneVisual.zoneId;
933
+ // Consumer-resolved style overrides (diff-preview ghosts etc.). Opacity
934
+ // composes onto the visibility-driven value and dims the whole subtree —
935
+ // body, slots, and anchors alike.
936
+ const zoneStyle = input.resolveZoneStyle?.(zoneVisual.zone) ?? undefined;
937
+ const zoneStyleOpacity = Math.min(Math.max(zoneStyle?.opacity ?? 1, 0), 1);
872
938
  applyStyles(zoneEl, {
873
939
  position: "absolute",
874
940
  left: `${zoneVisual.rect.x}px`,
875
941
  top: `${zoneVisual.rect.y}px`,
876
942
  width: `${zoneVisual.rect.width}px`,
877
943
  height: `${zoneVisual.rect.height}px`,
878
- opacity: getOpacity(visibility.emphasis),
944
+ opacity: getOpacity(visibility.emphasis) * zoneStyleOpacity,
879
945
  overflow: "visible",
880
946
  zIndex: zoneDepth + RENDER_Z_INDEX.zoneBase,
881
947
  // zoneLayer 가 pointer-events: none 이고 이 속성은 상속되므로,
882
948
  // 명시하지 않으면 카드에서 슬롯 사각형 밖 영역의 클릭이 배경으로 빠진다.
883
949
  pointerEvents: "auto",
884
950
  });
951
+ if (zoneStyle?.pulse) {
952
+ // Pulses from the inline opacity above, so it composes with ghosting.
953
+ zoneEl.classList.add(PULSE_CLASS);
954
+ }
885
955
  const shape = normalizeZoneShape(input.resolveZoneShape?.(zoneVisual.zone));
886
956
  // Consumer-resolved per-zone color overrides the theme's border + accent
887
957
  // (body background and text stay theme-driven to preserve contrast).
@@ -938,7 +1008,7 @@ export const domDrawEngine = {
938
1008
  width: "100%",
939
1009
  height: "100%",
940
1010
  borderRadius: shape.borderRadius,
941
- border: `1px solid ${zoneBorderColor}`,
1011
+ border: `1px ${zoneStyle?.borderStyle ?? "solid"} ${zoneBorderColor}`,
942
1012
  background: theme.surface.zone.background,
943
1013
  boxSizing: "border-box",
944
1014
  boxShadow: theme.surface.zone.shadow,
@@ -1008,6 +1078,9 @@ export const domDrawEngine = {
1008
1078
  // pathLayer 의 pointer-events: none 상속 차단 — zoneEl 과 동일한 이유.
1009
1079
  pointerEvents: "auto",
1010
1080
  });
1081
+ if (input.resolvePathStyle?.(pathVisual.path)?.pulse) {
1082
+ pathEl.classList.add(PULSE_CLASS);
1083
+ }
1011
1084
  pathEl.addEventListener("click", (event) => {
1012
1085
  event.stopPropagation();
1013
1086
  interactionHandlers?.onPathClick?.(pathVisual.pathId);
@@ -8,6 +8,18 @@ export type EdgeFlowMotion = {
8
8
  dashOffset: string;
9
9
  };
10
10
  export declare function resolveEdgeFlowMotion(theme: ZoneflowTheme): EdgeFlowMotion;
11
+ /**
12
+ * Inject the shared blink animation used by pulse-decorated elements (diff
13
+ * previews etc.). The keyframes only define the 50% stop, so each element
14
+ * pulses from its own current opacity — ghosted zones dip from 0.45, full
15
+ * elements from 1. The <style> lives inside the edge SVG but CSS rules are
16
+ * document-global, so the same class works on the DOM zone/path layers too.
17
+ */
18
+ export declare function appendPulseStyle(params: {
19
+ svg: SVGSVGElement;
20
+ animationName: string;
21
+ className: string;
22
+ }): void;
11
23
  export declare function appendEdgeFlowStyle(params: {
12
24
  svg: SVGSVGElement;
13
25
  animationName: string;
@@ -16,6 +16,31 @@ export function resolveEdgeFlowMotion(theme) {
16
16
  dashOffset: String(cycleLength),
17
17
  };
18
18
  }
19
+ /**
20
+ * Inject the shared blink animation used by pulse-decorated elements (diff
21
+ * previews etc.). The keyframes only define the 50% stop, so each element
22
+ * pulses from its own current opacity — ghosted zones dip from 0.45, full
23
+ * elements from 1. The <style> lives inside the edge SVG but CSS rules are
24
+ * document-global, so the same class works on the DOM zone/path layers too.
25
+ */
26
+ export function appendPulseStyle(params) {
27
+ const { svg, animationName, className } = params;
28
+ const style = document.createElementNS("http://www.w3.org/2000/svg", "style");
29
+ style.textContent = `
30
+ @keyframes ${animationName} {
31
+ 50% { opacity: 0.12; }
32
+ }
33
+ .${className} {
34
+ animation: ${animationName} 1100ms ease-in-out infinite;
35
+ }
36
+ @media (prefers-reduced-motion: reduce) {
37
+ .${className} {
38
+ animation: none;
39
+ }
40
+ }
41
+ `;
42
+ svg.appendChild(style);
43
+ }
19
44
  export function appendEdgeFlowStyle(params) {
20
45
  const { svg, animationName, className, motion } = params;
21
46
  const style = document.createElementNS("http://www.w3.org/2000/svg", "style");
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export * from "./theme";
2
2
  export * from "./themes/defaultTheme";
3
3
  export * from "./types";
4
4
  export * from "./zoneShape";
5
+ export * from "./diffDecorations";
5
6
  export * from "./anchors";
6
7
  export * from "./pipeline";
7
8
  export * from "./renderer";
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ export * from "./theme";
2
2
  export * from "./themes/defaultTheme";
3
3
  export * from "./types";
4
4
  export * from "./zoneShape";
5
+ export * from "./diffDecorations";
5
6
  export * from "./anchors";
6
7
  export * from "./pipeline";
7
8
  export * from "./renderer";
package/dist/renderer.js CHANGED
@@ -69,7 +69,7 @@ export function createRenderer() {
69
69
  update(input) {
70
70
  if (!host)
71
71
  return;
72
- const { model, layoutModel, theme, textScale = "md", camera = DEFAULT_CAMERA, graphLayoutEngine = defaultGraphLayoutEngine, densityEngine = defaultDensityEngine, visibilityEngine = defaultVisibilityEngine, componentLayoutEngine = defaultComponentLayoutEngine, drawEngine = domDrawEngine, zoneComponentRenderers, pathComponentRenderers, resolveZoneShape, resolveZoneColor, resolvePathColor, backgroundRenderer, gridOptions, interactionHandlers, exclusionState, debug, } = input;
72
+ const { model, layoutModel, theme, textScale = "md", camera = DEFAULT_CAMERA, graphLayoutEngine = defaultGraphLayoutEngine, densityEngine = defaultDensityEngine, visibilityEngine = defaultVisibilityEngine, componentLayoutEngine = defaultComponentLayoutEngine, drawEngine = domDrawEngine, zoneComponentRenderers, pathComponentRenderers, resolveZoneShape, resolveZoneColor, resolveZoneStyle, resolvePathColor, resolvePathLineColor, resolvePathStyle, backgroundRenderer, gridOptions, interactionHandlers, exclusionState, debug, } = input;
73
73
  const mergedTheme = resolveTheme(theme);
74
74
  const viewportInfo = resolveViewportInfo(host, camera, input);
75
75
  const pipeline = runRenderPipeline({
@@ -121,7 +121,10 @@ export function createRenderer() {
121
121
  pathComponentRenderers,
122
122
  resolveZoneShape,
123
123
  resolveZoneColor,
124
+ resolveZoneStyle,
124
125
  resolvePathColor,
126
+ resolvePathLineColor,
127
+ resolvePathStyle,
125
128
  backgroundRenderer,
126
129
  gridOptions,
127
130
  interactionHandlers,
package/dist/types.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { AnchorRect, Path, PathId, Point, UniverseId, UniverseLayoutModel, UniverseModel, Zone, ZoneId } from "@zoneflow/core";
2
2
  import type { TextScaleLevel, ZoneflowTheme } from "./theme";
3
- import type { ResolveZoneColor, ResolveZoneShape } from "./zoneShape";
3
+ import type { ResolveZoneColor, ResolveZoneShape, ResolveZoneStyle } from "./zoneShape";
4
4
  export type CameraState = {
5
5
  x: number;
6
6
  y: number;
@@ -155,6 +155,46 @@ export type PathComponentRendererMap = Partial<Record<PathComponentSlotName, Pat
155
155
  * is affected — the rule/target/body slots stay theme-driven for contrast.
156
156
  */
157
157
  export type ResolvePathColor = (path: Path) => string | null | undefined;
158
+ /**
159
+ * Resolver invoked once per path to decide the color of its connector lines
160
+ * (the zone→path and path→zone edge strokes, including the flow animation).
161
+ * Return `undefined`/`null` to fall back to the theme's edge colors. The
162
+ * override also applies to the path's collapsed edge segments so a decorated
163
+ * path stays recognizable while a subtree is folded. Pair with
164
+ * {@link ResolvePathColor} when the label should match the line — built for
165
+ * states like a diff preview ("this connection will be removed/retargeted").
166
+ */
167
+ export type ResolvePathLineColor = (path: Path) => string | null | undefined;
168
+ /**
169
+ * Per-path presentation overrides beyond color — the path-side counterpart
170
+ * of {@link ZoneStyleOverride}.
171
+ */
172
+ export type PathLineStyle = "solid" | "dashed" | "dotted";
173
+ export type PathStyleOverride = {
174
+ /**
175
+ * Blink the path's node (label slots included) and its connector lines.
176
+ * Built for diff previews where color alone is ambiguous because apps may
177
+ * color paths for their own reasons. Disabled automatically under
178
+ * `prefers-reduced-motion`.
179
+ */
180
+ pulse?: boolean;
181
+ /**
182
+ * Dash pattern for the path's connector lines. `"dashed"`/`"dotted"` draw a
183
+ * single static stroke (the moving flow animation is suppressed so the
184
+ * pattern stays legible), which reads as "inert / not yet wired up" — e.g.
185
+ * an unconfigured path with no rule or target. Defaults to `"solid"` (the
186
+ * normal animated flow). Pair with {@link ResolvePathLineColor} to also tint
187
+ * it, and with the node `border-style` is left untouched — only the
188
+ * connector line is affected.
189
+ */
190
+ lineStyle?: PathLineStyle;
191
+ };
192
+ /**
193
+ * Resolver invoked once per path to decide its style overrides. Return
194
+ * `undefined`/`null` for the default presentation. Purely presentational —
195
+ * geometry and hit-testing are unaffected.
196
+ */
197
+ export type ResolvePathStyle = (path: Path) => PathStyleOverride | null | undefined;
158
198
  export type ZoneComponentMount = {
159
199
  key: string;
160
200
  zoneId: ZoneId;
@@ -232,7 +272,10 @@ export type RendererDrawInput = {
232
272
  pathComponentRenderers?: PathComponentRendererMap;
233
273
  resolveZoneShape?: ResolveZoneShape;
234
274
  resolveZoneColor?: ResolveZoneColor;
275
+ resolveZoneStyle?: ResolveZoneStyle;
235
276
  resolvePathColor?: ResolvePathColor;
277
+ resolvePathLineColor?: ResolvePathLineColor;
278
+ resolvePathStyle?: ResolvePathStyle;
236
279
  backgroundRenderer?: BackgroundRenderer;
237
280
  gridOptions?: GridOptions;
238
281
  interactionHandlers?: RendererInteractionHandlers;
@@ -298,7 +341,10 @@ export type RendererInput = {
298
341
  pathComponentRenderers?: PathComponentRendererMap;
299
342
  resolveZoneShape?: ResolveZoneShape;
300
343
  resolveZoneColor?: ResolveZoneColor;
344
+ resolveZoneStyle?: ResolveZoneStyle;
301
345
  resolvePathColor?: ResolvePathColor;
346
+ resolvePathLineColor?: ResolvePathLineColor;
347
+ resolvePathStyle?: ResolvePathStyle;
302
348
  backgroundRenderer?: BackgroundRenderer;
303
349
  gridOptions?: GridOptions;
304
350
  interactionHandlers?: RendererInteractionHandlers;
@@ -52,6 +52,38 @@ export type ResolveZoneShape = (zone: Zone) => ZoneShape | null | undefined;
52
52
  * background and text stay theme-driven so contrast is preserved.
53
53
  */
54
54
  export type ResolveZoneColor = (zone: Zone) => string | null | undefined;
55
+ /**
56
+ * Per-zone presentation overrides beyond color — built for transient states
57
+ * like a diff preview's "will be removed" ghost (dashed outline + dimmed).
58
+ */
59
+ export type ZoneStyleOverride = {
60
+ /**
61
+ * CSS `border-style` for the zone outline. Applies to radius-based shapes;
62
+ * clipped shapes (diamond/hexagon/custom `clipPath`) synthesize their
63
+ * outline from a fill layer, so `borderStyle` is ignored there.
64
+ */
65
+ borderStyle?: "solid" | "dashed" | "dotted";
66
+ /**
67
+ * 0..1 multiplier composed onto the zone's computed visibility opacity.
68
+ * Dims the whole zone including its slots and anchors.
69
+ */
70
+ opacity?: number;
71
+ /**
72
+ * Blink the whole zone (slots and anchors included) — for states color
73
+ * alone can't carry, e.g. "will be removed" in a diff preview where apps
74
+ * may already use colors for their own meaning. Pulses from the zone's
75
+ * computed opacity, so it composes with `opacity`. Disabled automatically
76
+ * under `prefers-reduced-motion`.
77
+ */
78
+ pulse?: boolean;
79
+ };
80
+ /**
81
+ * Resolver invoked once per zone to decide its style overrides. Return
82
+ * `undefined`/`null` for the default presentation. Like
83
+ * {@link ResolveZoneColor}, purely presentational — geometry, hit-testing,
84
+ * and anchors are unaffected.
85
+ */
86
+ export type ResolveZoneStyle = (zone: Zone) => ZoneStyleOverride | null | undefined;
55
87
  /** Normalized geometry consumed by the draw engine. */
56
88
  export type ResolvedZoneShape = {
57
89
  borderRadius: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zoneflow/renderer-dom",
3
- "version": "0.0.21",
3
+ "version": "0.0.22",
4
4
  "license": "MIT",
5
5
  "description": "Low-level DOM renderer engines for Zoneflow.",
6
6
  "type": "module",
@@ -19,10 +19,14 @@
19
19
  "dist"
20
20
  ],
21
21
  "dependencies": {
22
- "@zoneflow/core": "0.0.21"
22
+ "@zoneflow/core": "0.0.22"
23
+ },
24
+ "devDependencies": {
25
+ "vitest": "^4.1.8"
23
26
  },
24
27
  "scripts": {
25
28
  "build": "tsc -p tsconfig.json",
26
- "type-check": "tsc -p tsconfig.json --noEmit"
29
+ "type-check": "tsc -p tsconfig.json --noEmit",
30
+ "test": "vitest run"
27
31
  }
28
32
  }