@zoneflow/renderer-dom 0.0.15 → 0.0.17

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.
@@ -1,4 +1,5 @@
1
1
  import { resolveZoneAnchorRect } from "../anchors";
2
+ import { normalizeZoneShape } from "../zoneShape";
2
3
  import { getZoneDepth, isZoneInputEnabled, isZoneOutputEnabled, } from "@zoneflow/core";
3
4
  import { appendEdgeFlowStyle, resolveCollapsedEdgeStroke, resolveDrawableEdgeSegments, resolveEdgeFlowMotion, } from "./edgeFlow";
4
5
  const SCENE_PADDING = 64;
@@ -552,11 +553,13 @@ function drawEdges(params) {
552
553
  }
553
554
  }
554
555
  }
556
+ // Depth for clipped shapes: box-shadow is cut away by clip-path, so a
557
+ // polygon-following drop-shadow filter is used instead. Tuned to match the
558
+ // zone surface box-shadow tokens (rgba(15, 23, 42, …)).
559
+ const ZONE_CLIP_SHADOW = "drop-shadow(0 14px 22px rgba(15, 23, 42, 0.12)) drop-shadow(0 3px 6px rgba(15, 23, 42, 0.08))";
555
560
  function createSurfaceChrome(params) {
556
- const { owner, accent, radius, theme, topBandOpacity = 0.64 } = params;
561
+ const { owner, accent, radius, theme, header = true, topBandOpacity = 0.64 } = params;
557
562
  const chrome = document.createElement("div");
558
- const topBand = document.createElement("div");
559
- const cornerGlow = document.createElement("div");
560
563
  applyStyles(chrome, {
561
564
  position: "absolute",
562
565
  inset: "0",
@@ -564,43 +567,108 @@ function createSurfaceChrome(params) {
564
567
  pointerEvents: "none",
565
568
  background: theme.surface.chrome.overlay,
566
569
  });
567
- applyStyles(topBand, {
568
- position: "absolute",
569
- left: "0",
570
- top: "0",
571
- right: "0",
572
- height: "44px",
573
- borderTopLeftRadius: radius,
574
- borderTopRightRadius: radius,
575
- background: `linear-gradient(90deg, ${accent} 0%, ${theme.surface.chrome.accentFade} 72%)`,
576
- opacity: topBandOpacity,
577
- pointerEvents: "none",
578
- });
579
- applyStyles(cornerGlow, {
580
- position: "absolute",
581
- right: "-20px",
582
- top: "-24px",
583
- width: "116px",
584
- height: "116px",
585
- borderRadius: "999px",
586
- background: theme.surface.chrome.glow,
587
- pointerEvents: "none",
588
- });
589
- chrome.appendChild(topBand);
590
- chrome.appendChild(cornerGlow);
570
+ if (header) {
571
+ const topBand = document.createElement("div");
572
+ const cornerGlow = document.createElement("div");
573
+ applyStyles(topBand, {
574
+ position: "absolute",
575
+ left: "0",
576
+ top: "0",
577
+ right: "0",
578
+ height: "44px",
579
+ borderTopLeftRadius: radius,
580
+ borderTopRightRadius: radius,
581
+ background: `linear-gradient(90deg, ${accent} 0%, ${theme.surface.chrome.accentFade} 72%)`,
582
+ opacity: topBandOpacity,
583
+ pointerEvents: "none",
584
+ });
585
+ applyStyles(cornerGlow, {
586
+ position: "absolute",
587
+ right: "-20px",
588
+ top: "-24px",
589
+ width: "116px",
590
+ height: "116px",
591
+ borderRadius: "999px",
592
+ background: theme.surface.chrome.glow,
593
+ pointerEvents: "none",
594
+ });
595
+ chrome.appendChild(topBand);
596
+ chrome.appendChild(cornerGlow);
597
+ }
598
+ else {
599
+ // Header-less shapes (circle/pill/diamond/…) keep their accent identity
600
+ // via a soft top-centered wash instead of the rectangular band.
601
+ const accentWash = document.createElement("div");
602
+ applyStyles(accentWash, {
603
+ position: "absolute",
604
+ inset: "0",
605
+ borderRadius: radius,
606
+ background: `radial-gradient(135% 100% at 50% 0%, ${accent} 0%, ${theme.surface.chrome.accentFade} 70%)`,
607
+ opacity: 0.85,
608
+ pointerEvents: "none",
609
+ });
610
+ chrome.appendChild(accentWash);
611
+ }
591
612
  owner.appendChild(chrome);
592
613
  }
593
614
  function drawZoneAnchors(params) {
594
- const { owner, zone, input } = params;
595
- const zoneBorderColor = zone.zone.zoneType === "action"
596
- ? input.theme.zoneActionBorder
597
- : input.theme.zoneContainerBorder;
598
- const anchorAccentColor = zone.zone.zoneType === "action"
599
- ? input.theme.surface.anchor.actionAccent
600
- : input.theme.surface.anchor.containerAccent;
615
+ const { owner, zone, input, mode = "edge" } = params;
616
+ const zoneColor = input.resolveZoneColor?.(zone.zone) ?? undefined;
617
+ const zoneBorderColor = zoneColor ??
618
+ (zone.zone.zoneType === "action"
619
+ ? input.theme.zoneActionBorder
620
+ : input.theme.zoneContainerBorder);
621
+ const anchorAccentColor = zoneColor ??
622
+ (zone.zone.zoneType === "action"
623
+ ? input.theme.surface.anchor.actionAccent
624
+ : input.theme.surface.anchor.containerAccent);
625
+ const anchorGlowColor = zoneColor
626
+ ? `color-mix(in srgb, ${zoneColor} 12%, transparent)`
627
+ : anchorAccentColor.replace("0.96", "0.12");
601
628
  const shouldRenderAnchor = (kind) => kind === "inlet"
602
629
  ? isZoneInputEnabled(zone.zone)
603
630
  : isZoneOutputEnabled(zone.zone);
631
+ // Vertex mode: a compact dot centered on the left/right edge midpoint,
632
+ // sitting exactly on a round/diamond node's side. The interactive anchor
633
+ // geometry is unchanged — this only swaps the visual indicator.
634
+ if (mode === "vertex") {
635
+ const dotSize = 14;
636
+ for (const kind of ["inlet", "outlet"]) {
637
+ if (!shouldRenderAnchor(kind))
638
+ continue;
639
+ const dot = document.createElement("div");
640
+ const accentDot = document.createElement("div");
641
+ applyStyles(dot, {
642
+ position: "absolute",
643
+ top: "50%",
644
+ left: kind === "inlet" ? "0" : "auto",
645
+ right: kind === "outlet" ? "0" : "auto",
646
+ width: `${dotSize}px`,
647
+ height: `${dotSize}px`,
648
+ transform: kind === "inlet"
649
+ ? "translate(-50%, -50%)"
650
+ : "translate(50%, -50%)",
651
+ borderRadius: "999px",
652
+ background: input.theme.surface.anchor.background,
653
+ border: `1px solid ${zoneBorderColor}`,
654
+ boxShadow: input.theme.surface.anchor.shadow,
655
+ boxSizing: "border-box",
656
+ display: "flex",
657
+ alignItems: "center",
658
+ justifyContent: "center",
659
+ pointerEvents: "none",
660
+ });
661
+ applyStyles(accentDot, {
662
+ width: "6px",
663
+ height: "6px",
664
+ borderRadius: "999px",
665
+ background: anchorAccentColor,
666
+ });
667
+ dot.appendChild(accentDot);
668
+ owner.appendChild(dot);
669
+ }
670
+ return;
671
+ }
604
672
  for (const kind of ["inlet", "outlet"]) {
605
673
  if (!shouldRenderAnchor(kind))
606
674
  continue;
@@ -648,7 +716,7 @@ function drawZoneAnchors(params) {
648
716
  background: anchorAccentColor,
649
717
  left: kind === "inlet" ? "8px" : "auto",
650
718
  right: kind === "outlet" ? "8px" : "auto",
651
- boxShadow: `0 0 0 4px ${anchorAccentColor.replace("0.96", "0.12")}`,
719
+ boxShadow: `0 0 0 4px ${anchorGlowColor}`,
652
720
  });
653
721
  el.appendChild(seam);
654
722
  el.appendChild(accent);
@@ -795,7 +863,6 @@ export const domDrawEngine = {
795
863
  const zoneDepth = getZoneDepth(input.model, zoneVisual.zoneId);
796
864
  const zoneEl = document.createElement("div");
797
865
  const zoneBodyEl = document.createElement("div");
798
- const zoneChromeEl = document.createElement("div");
799
866
  zoneEl.dataset.zoneflowZoneId = zoneVisual.zoneId;
800
867
  zoneBodyEl.dataset.zoneflowZoneBody = zoneVisual.zoneId;
801
868
  applyStyles(zoneEl, {
@@ -808,34 +875,82 @@ export const domDrawEngine = {
808
875
  overflow: "visible",
809
876
  zIndex: zoneDepth + RENDER_Z_INDEX.zoneBase,
810
877
  });
811
- applyStyles(zoneBodyEl, {
812
- position: "absolute",
813
- left: "0",
814
- top: "0",
815
- width: "100%",
816
- height: "100%",
817
- borderRadius: "0",
818
- border: `1px solid ${zoneVisual.zone.zoneType === "action"
878
+ const shape = normalizeZoneShape(input.resolveZoneShape?.(zoneVisual.zone));
879
+ // Consumer-resolved per-zone color overrides the theme's border + accent
880
+ // (body background and text stay theme-driven to preserve contrast).
881
+ const zoneColor = input.resolveZoneColor?.(zoneVisual.zone) ?? undefined;
882
+ const zoneBorderColor = zoneColor ??
883
+ (zoneVisual.zone.zoneType === "action"
819
884
  ? theme.zoneActionBorder
820
- : theme.zoneContainerBorder}`,
821
- background: theme.surface.zone.background,
822
- boxSizing: "border-box",
823
- boxShadow: theme.surface.zone.shadow,
824
- overflow: "hidden",
825
- });
885
+ : theme.zoneContainerBorder);
886
+ const zoneAccentColor = zoneColor
887
+ ? `color-mix(in srgb, ${zoneColor} 18%, transparent)`
888
+ : zoneVisual.zone.zoneType === "action"
889
+ ? theme.surface.zone.actionAccent
890
+ : theme.surface.zone.containerAccent;
891
+ if (shape.clipPath) {
892
+ // Clipped polygon (diamond/hexagon/custom). A CSS border would be
893
+ // cut by clip-path, so the outline is synthesized: a border-colored
894
+ // base layer with a 1px-inset fill layer on top. Depth comes from a
895
+ // drop-shadow filter since box-shadow is clipped away.
896
+ applyStyles(zoneBodyEl, {
897
+ position: "absolute",
898
+ left: "0",
899
+ top: "0",
900
+ width: "100%",
901
+ height: "100%",
902
+ background: zoneBorderColor,
903
+ clipPath: shape.clipPath,
904
+ boxSizing: "border-box",
905
+ overflow: "hidden",
906
+ filter: ZONE_CLIP_SHADOW,
907
+ });
908
+ const zoneFillEl = document.createElement("div");
909
+ applyStyles(zoneFillEl, {
910
+ position: "absolute",
911
+ inset: "1px",
912
+ background: theme.surface.zone.background,
913
+ clipPath: shape.clipPath,
914
+ boxSizing: "border-box",
915
+ overflow: "hidden",
916
+ });
917
+ zoneBodyEl.appendChild(zoneFillEl);
918
+ createSurfaceChrome({
919
+ owner: zoneFillEl,
920
+ accent: zoneAccentColor,
921
+ radius: "0",
922
+ theme,
923
+ header: shape.header,
924
+ });
925
+ }
926
+ else {
927
+ applyStyles(zoneBodyEl, {
928
+ position: "absolute",
929
+ left: "0",
930
+ top: "0",
931
+ width: "100%",
932
+ height: "100%",
933
+ borderRadius: shape.borderRadius,
934
+ border: `1px solid ${zoneBorderColor}`,
935
+ background: theme.surface.zone.background,
936
+ boxSizing: "border-box",
937
+ boxShadow: theme.surface.zone.shadow,
938
+ overflow: "hidden",
939
+ });
940
+ const zoneChromeEl = document.createElement("div");
941
+ createSurfaceChrome({
942
+ owner: zoneChromeEl,
943
+ accent: zoneAccentColor,
944
+ radius: shape.borderRadius,
945
+ theme,
946
+ header: shape.header,
947
+ });
948
+ zoneBodyEl.appendChild(zoneChromeEl);
949
+ }
826
950
  zoneEl.addEventListener("click", (event) => {
827
951
  event.stopPropagation();
828
952
  interactionHandlers?.onZoneClick?.(zoneVisual.zoneId);
829
953
  });
830
- createSurfaceChrome({
831
- owner: zoneChromeEl,
832
- accent: zoneVisual.zone.zoneType === "action"
833
- ? theme.surface.zone.actionAccent
834
- : theme.surface.zone.containerAccent,
835
- radius: "0",
836
- theme,
837
- });
838
- zoneBodyEl.appendChild(zoneChromeEl);
839
954
  zoneEl.appendChild(zoneBodyEl);
840
955
  for (const slot of Object.keys(componentLayout?.slots ?? {})) {
841
956
  createZoneSlotHost({
@@ -851,6 +966,7 @@ export const domDrawEngine = {
851
966
  owner: zoneEl,
852
967
  zone: zoneVisual,
853
968
  input,
969
+ mode: shape.anchors,
854
970
  });
855
971
  zoneLayer.appendChild(zoneEl);
856
972
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./theme";
2
2
  export * from "./themes/defaultTheme";
3
3
  export * from "./types";
4
+ export * from "./zoneShape";
4
5
  export * from "./anchors";
5
6
  export * from "./pipeline";
6
7
  export * from "./renderer";
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./theme";
2
2
  export * from "./themes/defaultTheme";
3
3
  export * from "./types";
4
+ export * from "./zoneShape";
4
5
  export * from "./anchors";
5
6
  export * from "./pipeline";
6
7
  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, 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, 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({
@@ -119,6 +119,8 @@ export function createRenderer() {
119
119
  pipeline,
120
120
  zoneComponentRenderers,
121
121
  pathComponentRenderers,
122
+ resolveZoneShape,
123
+ resolveZoneColor,
122
124
  backgroundRenderer,
123
125
  gridOptions,
124
126
  interactionHandlers,
package/dist/types.d.ts CHANGED
@@ -1,5 +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
4
  export type CameraState = {
4
5
  x: number;
5
6
  y: number;
@@ -203,6 +204,8 @@ export type RendererDrawInput = {
203
204
  pipeline: RenderPipelineResult;
204
205
  zoneComponentRenderers?: ZoneComponentRendererMap;
205
206
  pathComponentRenderers?: PathComponentRendererMap;
207
+ resolveZoneShape?: ResolveZoneShape;
208
+ resolveZoneColor?: ResolveZoneColor;
206
209
  backgroundRenderer?: BackgroundRenderer;
207
210
  gridOptions?: GridOptions;
208
211
  interactionHandlers?: RendererInteractionHandlers;
@@ -266,6 +269,8 @@ export type RendererInput = {
266
269
  drawEngine?: DrawEngine;
267
270
  zoneComponentRenderers?: ZoneComponentRendererMap;
268
271
  pathComponentRenderers?: PathComponentRendererMap;
272
+ resolveZoneShape?: ResolveZoneShape;
273
+ resolveZoneColor?: ResolveZoneColor;
269
274
  backgroundRenderer?: BackgroundRenderer;
270
275
  gridOptions?: GridOptions;
271
276
  interactionHandlers?: RendererInteractionHandlers;
@@ -0,0 +1,62 @@
1
+ import type { Zone } from "@zoneflow/core";
2
+ /**
3
+ * Where a zone's connection anchors are drawn.
4
+ * - `edge`: the default full-height side tabs (good for rectangular cards).
5
+ * - `vertex`: a compact dot centered on the left/right edge midpoint
6
+ * (good for round/diamond nodes whose sides are not flat).
7
+ */
8
+ export type ZoneAnchorRenderMode = "edge" | "vertex";
9
+ /** Built-in shape presets. */
10
+ export type ZoneShapeName = "rect" | "rounded" | "pill" | "circle" | "diamond" | "hexagon";
11
+ /**
12
+ * Fully custom shape spec — the escape hatch for arbitrary geometry.
13
+ *
14
+ * Provide either `borderRadius` (rendered with a real CSS border) or
15
+ * `clipPath` (rendered as a clipped polygon with a synthesized outline).
16
+ * When `clipPath` is set, `borderRadius` is ignored.
17
+ */
18
+ export type ZoneShapeSpec = {
19
+ /** CSS `border-radius` value, e.g. `"14px"`, `"50%"`, `"999px"`. */
20
+ borderRadius?: string;
21
+ /**
22
+ * CSS `clip-path` value, e.g. `"polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)"`.
23
+ * When set the zone is drawn as a clipped polygon.
24
+ */
25
+ clipPath?: string;
26
+ /**
27
+ * Whether to draw the rectangular accent header band at the top.
28
+ * Defaults to `true` for radius-based shapes and `false` for clipped /
29
+ * fully-rounded shapes (where a rectangular band looks wrong).
30
+ */
31
+ header?: boolean;
32
+ /**
33
+ * How connection anchors attach. Defaults to `"vertex"` for clipped or
34
+ * circular shapes and `"edge"` otherwise.
35
+ */
36
+ anchors?: ZoneAnchorRenderMode;
37
+ };
38
+ export type ZoneShape = ZoneShapeName | ZoneShapeSpec;
39
+ /**
40
+ * Resolver invoked once per zone to decide how it is drawn. Return
41
+ * `undefined`/`null` to fall back to the default rectangle. Purely a
42
+ * presentation concern — the zone's geometry, hit-testing, and anchor
43
+ * points are unaffected, so paths still connect exactly as before.
44
+ */
45
+ export type ResolveZoneShape = (zone: Zone) => ZoneShape | null | undefined;
46
+ /**
47
+ * Resolver invoked once per zone to decide its accent color. Return a CSS
48
+ * color to override the zone's border + accent (and matching anchor) for that
49
+ * zone; return `undefined`/`null` to fall back to the theme's zoneType-based
50
+ * colors. Like {@link ResolveZoneShape}, a purely presentational hook decided
51
+ * by the consumer (e.g. from `zone.meta.color`, `zone.action`, …). The body
52
+ * background and text stay theme-driven so contrast is preserved.
53
+ */
54
+ export type ResolveZoneColor = (zone: Zone) => string | null | undefined;
55
+ /** Normalized geometry consumed by the draw engine. */
56
+ export type ResolvedZoneShape = {
57
+ borderRadius: string;
58
+ clipPath: string | null;
59
+ header: boolean;
60
+ anchors: ZoneAnchorRenderMode;
61
+ };
62
+ export declare function normalizeZoneShape(shape: ZoneShape | null | undefined): ResolvedZoneShape;
@@ -0,0 +1,39 @@
1
+ const DIAMOND_CLIP = "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)";
2
+ const HEXAGON_CLIP = "polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%)";
3
+ const RECT_SHAPE = {
4
+ borderRadius: "0",
5
+ clipPath: null,
6
+ header: true,
7
+ anchors: "edge",
8
+ };
9
+ function specFromName(name) {
10
+ switch (name) {
11
+ case "rounded":
12
+ return { borderRadius: "14px" };
13
+ case "pill":
14
+ return { borderRadius: "999px", header: false, anchors: "vertex" };
15
+ case "circle":
16
+ return { borderRadius: "50%", header: false, anchors: "vertex" };
17
+ case "diamond":
18
+ return { clipPath: DIAMOND_CLIP };
19
+ case "hexagon":
20
+ return { clipPath: HEXAGON_CLIP };
21
+ case "rect":
22
+ default:
23
+ return { borderRadius: "0" };
24
+ }
25
+ }
26
+ export function normalizeZoneShape(shape) {
27
+ if (!shape)
28
+ return RECT_SHAPE;
29
+ const spec = typeof shape === "string" ? specFromName(shape) : shape;
30
+ const clipPath = spec.clipPath ?? null;
31
+ const borderRadius = clipPath ? "0" : spec.borderRadius ?? "0";
32
+ const isRound = !clipPath && borderRadius === "50%";
33
+ return {
34
+ borderRadius,
35
+ clipPath,
36
+ header: spec.header ?? !clipPath,
37
+ anchors: spec.anchors ?? (clipPath || isRound ? "vertex" : "edge"),
38
+ };
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zoneflow/renderer-dom",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "license": "MIT",
5
5
  "description": "Low-level DOM renderer engines for Zoneflow.",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  "dist"
20
20
  ],
21
21
  "dependencies": {
22
- "@zoneflow/core": "0.0.15"
22
+ "@zoneflow/core": "0.0.17"
23
23
  },
24
24
  "scripts": {
25
25
  "build": "tsc -p tsconfig.json",