@arcote.tech/arc-process 0.7.29 → 0.7.31

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-process",
3
3
  "type": "module",
4
- "version": "0.7.29",
4
+ "version": "0.7.31",
5
5
  "private": false,
6
6
  "description": "Process description & diagram fragment for Arc framework — generic graph model with profiles, MDX flow components and a deterministic 8-direction layout",
7
7
  "main": "./src/index.ts",
@@ -22,8 +22,8 @@
22
22
  "shiki": "^1.24.0"
23
23
  },
24
24
  "peerDependencies": {
25
- "@arcote.tech/arc": "^0.7.29",
26
- "@arcote.tech/platform": "^0.7.29",
25
+ "@arcote.tech/arc": "^0.7.31",
26
+ "@arcote.tech/platform": "^0.7.31",
27
27
  "react": ">=18.0.0",
28
28
  "react-dom": ">=18.0.0",
29
29
  "typescript": "^5.0.0"
@@ -323,7 +323,10 @@ export function layoutProcess(input: LayoutInput): ProcessLayout {
323
323
  const semantics = spec?.semantics ?? "in";
324
324
  const from = semantics === "in" ? tId : sId;
325
325
  const to = semantics === "in" ? sId : tId;
326
- if (semantics === "out" && depthOf(to) < depthOf(from)) continue; // wsteczne wyjście → szum
326
+ // Nie rysujemy relacji biegnącej prawo→lewo: gdy cel jest w kolumnie
327
+ // wcześniejszej LUB tej samej co źródło (strzałka wsteczna albo pionowa
328
+ // to szum wizualny — przepływ czyta się lewo→prawo).
329
+ if (depthOf(to) <= depthOf(from)) continue;
327
330
  const key = `${from}->${to}:${r.kind}`;
328
331
  if (seenEdge.has(key)) continue;
329
332
  seenEdge.add(key);
package/src/mdx/arc.tsx CHANGED
@@ -95,17 +95,26 @@ export function Arc(props: ArcProps) {
95
95
  },
96
96
  });
97
97
 
98
+ // Most token→realId: gdy konsument poda `resolveNodeId` (np. canvas grafu
99
+ // wiedzy), rozwiązanie i focusId idą przez niego — chip i nod dzielą tę samą
100
+ // przestrzeń id. Brak mostu = domyślne dopasowanie po `graph.nodes`.
101
+ const mappedId =
102
+ ref && data?.resolveNodeId && ref.kind !== CONTEXT_REF_KIND
103
+ ? data.resolveNodeId(ref)
104
+ : null;
98
105
  const resolvedNode =
99
106
  ref && data
100
107
  ? ref.kind === CONTEXT_REF_KIND
101
108
  ? resolveContextRef(data.graph.contexts ?? [], ref.nodeId)
102
- : data.graph.nodes.find((n) => n.id === ref.nodeId)
109
+ : mappedId
110
+ ? { id: mappedId, kind: ref.kind, label: label }
111
+ : data.graph.nodes.find((n) => n.id === ref.nodeId)
103
112
  : undefined;
104
113
  const resolved = !!resolvedNode;
105
114
  const focusId =
106
115
  ref?.kind === CONTEXT_REF_KIND && resolvedNode && "id" in resolvedNode
107
116
  ? `ctx:${resolvedNode.id}`
108
- : ref?.nodeId;
117
+ : (mappedId ?? ref?.nodeId);
109
118
  const active = !!focusId && focus.focusNodeId === focusId;
110
119
  // Kolor zawsze z ZADEKLAROWANEGO rodzaju (ref.kind) — element nieistniejący
111
120
  // (design-first TODO) zachowuje kolor eventu/komendy itd., a brak w grafie
@@ -2,13 +2,27 @@
2
2
 
3
3
  import { createContext, useContext } from "react";
4
4
  import type { ProcessProfile } from "../model/profile";
5
- import type { Direction, Occurrence, PathSeg, ProcessGraph } from "../model/types";
5
+ import type {
6
+ Direction,
7
+ Occurrence,
8
+ PathSeg,
9
+ ProcessGraph,
10
+ ResolvedRef,
11
+ } from "../model/types";
6
12
  import type { ExpansionKey, ProcessViewState } from "../state/view-state";
7
13
 
8
14
  /** Graf + profil — walidacja referencji i kolory chipów. */
9
15
  export interface ProcessData {
10
16
  graph: ProcessGraph;
11
17
  profile: ProcessProfile;
18
+ /**
19
+ * Opcjonalny MOST token→rzeczywiste id noda renderera. Gdy konsument (np.
20
+ * canvas grafu wiedzy BMF) go poda, `<Arc>` liczy `focusId` przez niego —
21
+ * dzięki temu hover chipa i hover noda operują w TEJ SAMEJ przestrzeni id,
22
+ * a dwukierunkowy highlight działa bez zgadywania. Brak = zachowanie
23
+ * domyślne (dopasowanie po `graph.nodes`), jak w arc-map.
24
+ */
25
+ resolveNodeId?: (ref: ResolvedRef) => string | null;
12
26
  }
13
27
  export const ProcessDataContext = createContext<ProcessData | null>(null);
14
28
  export const useProcessData = () => useContext(ProcessDataContext);
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `<DeclareNode>` / `<DeclareRelation>` — deklaracja nodów i relacji INLINE
3
+ * w prozie MDX (nie tylko referencja istniejących).
4
+ *
5
+ * Generyczne prymitywy rdzenia: rejestrują wystąpienie (jak `<Arc>`/`<Note>`),
6
+ * uczestniczą w focusie (hover chip↔nod) i selekcji (klik→drawer), a renderer
7
+ * diagramu (np. canvas grafu wiedzy BMF) decyduje, jak je pokazać. Konsument
8
+ * owija je cienkim aliasem (`<Node>`/`<Rel>` dokumentów).
9
+ */
10
+
11
+ import { useId, type ReactNode } from "react";
12
+ import { kindColor } from "../model/profile";
13
+ import { useFocus, useProcessData, useRegister, useSelection } from "./contexts";
14
+ import { MISSING_COLOR, tint } from "../react/theme";
15
+
16
+ function textOf(node: ReactNode): string {
17
+ if (node == null || node === false) return "";
18
+ if (typeof node === "string" || typeof node === "number") return String(node);
19
+ if (Array.isArray(node)) return node.map(textOf).join("");
20
+ if (typeof node === "object" && "props" in (node as unknown as Record<string, unknown>)) {
21
+ return textOf(
22
+ (node as unknown as { props?: { children?: ReactNode } }).props?.children,
23
+ );
24
+ }
25
+ return "";
26
+ }
27
+
28
+ export interface DeclareNodeProps {
29
+ /** Id noda (stabilny). Gdy brak — pochodzi z etykiety. */
30
+ id?: string;
31
+ /** Rodzaj (kind) — kolor/ikona z profilu. */
32
+ kind?: string;
33
+ /** Etykieta wyświetlana (fallback: children). */
34
+ label?: string;
35
+ children?: ReactNode;
36
+ /** Dowolne dodatkowe dane noda (props domeny). */
37
+ [k: string]: unknown;
38
+ }
39
+
40
+ function slug(s: string): string {
41
+ return s.toLowerCase().trim().replace(/\s+/g, "-");
42
+ }
43
+
44
+ export function DeclareNode(props: DeclareNodeProps) {
45
+ const { id, kind, label, children, ...rest } = props;
46
+ const domId = useId();
47
+ const register = useRegister();
48
+ const data = useProcessData();
49
+ const focus = useFocus();
50
+ const selection = useSelection();
51
+
52
+ const text = label ?? textOf(children);
53
+ const nodeId = id ?? `eph:${slug(text || domId)}`;
54
+ const nodeKind = kind ?? "ephemeral";
55
+
56
+ register?.({
57
+ domId,
58
+ occurrence: {
59
+ type: "declare-node",
60
+ occId: domId,
61
+ node: { id: nodeId, kind: nodeKind, label: text || nodeId, meta: rest },
62
+ },
63
+ });
64
+
65
+ const active = focus.focusNodeId === nodeId;
66
+ const color = data ? kindColor(data.profile, nodeKind) : MISSING_COLOR;
67
+
68
+ return (
69
+ <span
70
+ data-arc-id={domId}
71
+ data-arc-node={nodeId}
72
+ onMouseEnter={() => focus.setFocusNodeId(nodeId)}
73
+ onMouseLeave={() => focus.setFocusNodeId(null)}
74
+ onClick={() => selection.selectOcc(domId)}
75
+ title={`${nodeKind}: ${nodeId}`}
76
+ style={{
77
+ display: "inline-flex",
78
+ alignItems: "center",
79
+ gap: 4,
80
+ padding: "1px 8px",
81
+ margin: "0 1px",
82
+ borderRadius: 8,
83
+ border: `1.5px solid ${color}`,
84
+ background: tint(color, 0.1),
85
+ color: "#2a173f",
86
+ fontWeight: 600,
87
+ fontSize: "0.92em",
88
+ cursor: "pointer",
89
+ boxShadow: active ? `0 0 0 2px ${color}` : "none",
90
+ whiteSpace: "nowrap",
91
+ }}
92
+ >
93
+ <span style={{ width: 7, height: 7, borderRadius: 2, background: color }} />
94
+ {text || nodeId}
95
+ </span>
96
+ );
97
+ }
98
+
99
+ export interface DeclareRelationProps {
100
+ from: string;
101
+ to: string;
102
+ kind?: string;
103
+ dir?: "out" | "in" | "both";
104
+ label?: string;
105
+ }
106
+
107
+ export function DeclareRelation({
108
+ from,
109
+ to,
110
+ kind = "rel",
111
+ dir = "out",
112
+ label,
113
+ }: DeclareRelationProps) {
114
+ const domId = useId();
115
+ const register = useRegister();
116
+ const data = useProcessData();
117
+
118
+ register?.({
119
+ domId,
120
+ occurrence: { type: "relation", occId: domId, from, to, kind, dir, label },
121
+ });
122
+
123
+ const color = data ? kindColor(data.profile, kind) : MISSING_COLOR;
124
+ const arrow = dir === "in" ? "←" : dir === "both" ? "↔" : "→";
125
+ return (
126
+ <span
127
+ data-arc-id={domId}
128
+ title={`${from} ${arrow} ${to}`}
129
+ style={{
130
+ display: "inline-flex",
131
+ alignItems: "center",
132
+ gap: 4,
133
+ padding: "1px 8px",
134
+ margin: "0 1px",
135
+ borderRadius: 999,
136
+ border: `1px solid ${tint(color, 0.55)}`,
137
+ background: tint(color, 0.08),
138
+ color: "#2a173f",
139
+ fontSize: "0.85em",
140
+ fontWeight: 600,
141
+ whiteSpace: "nowrap",
142
+ }}
143
+ >
144
+ {from} {label ? `—${label}${arrow}` : arrow} {to}
145
+ </span>
146
+ );
147
+ }
@@ -87,12 +87,18 @@ export function parseProcessFrontmatter(mdx: string): ProcessFrontmatter {
87
87
  export function MdxView({
88
88
  mdx,
89
89
  onOccurrences,
90
+ components,
90
91
  }: {
91
92
  mdx: string;
92
93
  onOccurrences: (occurrences: Occurrence[]) => void;
94
+ /**
95
+ * Dodatkowe / nadpisujące komponenty MDX (np. Ref/Node/Rel/Metric/Formula
96
+ * konsumenta — dokumenty BMF). Scalane nad domyślnym słownikiem procesu.
97
+ */
98
+ components?: Record<string, ComponentType<Record<string, unknown>>>;
93
99
  }) {
94
100
  const [Comp, setComp] = useState<ComponentType<{
95
- components: typeof mdxComponents;
101
+ components: Record<string, unknown>;
96
102
  }> | null>(null);
97
103
  const [error, setError] = useState<string | null>(null);
98
104
 
@@ -106,7 +112,7 @@ export function MdxView({
106
112
  } as Parameters<typeof evaluate>[1])
107
113
  .then((mod) => {
108
114
  if (!cancelled) {
109
- setComp(() => mod.default as ComponentType<{ components: typeof mdxComponents }>);
115
+ setComp(() => mod.default as ComponentType<{ components: Record<string, unknown> }>);
110
116
  }
111
117
  })
112
118
  .catch((e) => {
@@ -127,9 +133,10 @@ export function MdxView({
127
133
  }
128
134
  if (!Comp) return <div style={{ color: GLASS.textMuted }}>Renderuję dokument…</div>;
129
135
 
136
+ const merged = components ? { ...mdxComponents, ...components } : mdxComponents;
130
137
  return (
131
138
  <ArcRefsProvider onOccurrences={onOccurrences}>
132
- <Comp components={mdxComponents} />
139
+ <Comp components={merged} />
133
140
  </ArcRefsProvider>
134
141
  );
135
142
  }
@@ -41,7 +41,11 @@ export function ArcRefsProvider({
41
41
  ? (o.ref?.key ?? `?${o.occId}`)
42
42
  : o.type === "continue"
43
43
  ? `cont:${o.to}`
44
- : `note:${o.occId}`,
44
+ : o.type === "note"
45
+ ? `note:${o.occId}`
46
+ : o.type === "declare-node"
47
+ ? `decl:${o.node.id}`
48
+ : `rel:${o.from}->${o.to}:${o.kind}`,
45
49
  )
46
50
  .join("|");
47
51
  if (sig === lastSig.current) return;
@@ -190,7 +190,46 @@ export interface NoteOccurrence {
190
190
  path?: PathSeg[];
191
191
  }
192
192
 
193
- export type Occurrence = StepOccurrence | ContinueOccurrence | NoteOccurrence;
193
+ /**
194
+ * Deklaracja noda INLINE w MDX (`<Node>` dokumentów) — nod istniejący tylko w
195
+ * tej narracji (efemeryczny). Rejestrowany jak krok; renderer diagramu (canvas
196
+ * grafu wiedzy) decyduje, jak go pokazać. `buildProcessModel` (profil
197
+ * procesowy) go ignoruje.
198
+ */
199
+ export interface DeclareNodeOccurrence {
200
+ type: "declare-node";
201
+ occId: string;
202
+ node: {
203
+ id: string;
204
+ kind: string;
205
+ label: string;
206
+ meta?: Record<string, unknown>;
207
+ };
208
+ path?: PathSeg[];
209
+ }
210
+
211
+ /**
212
+ * Deklaracja relacji INLINE w MDX (`<Rel from to>` dokumentów) — krawędź między
213
+ * dowolnymi nodami. Konsumowana przez renderer grafu wiedzy; profil procesowy
214
+ * ją ignoruje.
215
+ */
216
+ export interface RelationOccurrence {
217
+ type: "relation";
218
+ occId: string;
219
+ from: string;
220
+ to: string;
221
+ kind: string;
222
+ dir?: "out" | "in" | "both";
223
+ label?: string;
224
+ path?: PathSeg[];
225
+ }
226
+
227
+ export type Occurrence =
228
+ | StepOccurrence
229
+ | ContinueOccurrence
230
+ | NoteOccurrence
231
+ | DeclareNodeOccurrence
232
+ | RelationOccurrence;
194
233
 
195
234
  // ---------------------------------------------------------------------------
196
235
  // Model wynikowy
@@ -3,6 +3,12 @@
3
3
  */
4
4
 
5
5
  export { ProcessDiagram, type ProcessDiagramProps } from "./process-diagram";
6
+ export {
7
+ ProseInteractionRoot,
8
+ type ProseInteractionRootProps,
9
+ type ProseRootRenderApi,
10
+ } from "./prose-root";
11
+ export { ProcessFlowCanvas, type ProcessFlowCanvasProps } from "./process-flow-canvas";
6
12
  export { ProcessDrawer, type ProcessDrawerProps, type SourceProvider } from "./drawer";
7
13
  export { makeNodeTypes } from "./nodes";
8
14
  export { Pin, Person, Metric, type PinProps } from "../mdx/pin";
@@ -15,6 +21,12 @@ export {
15
21
  } from "./context-card";
16
22
  export { GLASS, BG_GRADIENT, MISSING_COLOR, tint } from "./theme";
17
23
  export { Arc, type ArcProps } from "../mdx/arc";
24
+ export {
25
+ DeclareNode,
26
+ DeclareRelation,
27
+ type DeclareNodeProps,
28
+ type DeclareRelationProps,
29
+ } from "../mdx/declare";
18
30
  export {
19
31
  Branch,
20
32
  Continue,
@@ -223,7 +223,11 @@ export function makeNodeTypes(profile: ProcessProfile) {
223
223
  </span>
224
224
  </div>
225
225
  <div style={{ fontSize: 10, color: GLASS.textMuted }}>
226
- {KIND_ICON[declaredKind] ?? "•"} {declaredKind}
226
+ {KIND_ICON[declaredKind] ?? "•"}{" "}
227
+ {/* NAZWA elementu (id) zamiast słowa-rodzaju — rodzaj widać po
228
+ ikonie i kolorze, a nazwa mówi, którego elementu dotyczy krok.
229
+ Dla nieistniejących (TODO) pada na nazwę z referencji. */}
230
+ {s.node?.id ?? s.ref?.nodeId ?? declaredKind}
227
231
  {s.method ? ` · ${s.method.name}` : ""}
228
232
  </div>
229
233
  <PinBadges profile={profile} pins={s.pins} />
@@ -1,54 +1,21 @@
1
1
  /**
2
2
  * `ProcessDiagram` — fetch-free viewer procesu z MDX.
3
3
  *
4
- * Proza (MDX z chipami) po lewej, diagram ReactFlow po prawej. MDX przychodzi
5
- * propsem; `graph` jest opcjonalny domyślnie pusty, więc każda referencja
6
- * renderuje się jako czerwony chip TODO (design-first).
4
+ * Proza (MDX z chipami) po lewej, diagram ReactFlow po prawej. Cienki wrapper
5
+ * nad wspólnym rdzeniem: `ProseInteractionRoot` (providery + proza + stan) +
6
+ * `ProcessFlowCanvas` (procesowy renderer). Publiczne API bez zmian.
7
7
  *
8
8
  * Pipeline: MDX → wystąpienia (DOM-order) → buildProcessModel →
9
- * collapseContexts (hierarchia kontekstów) → layoutProcess (8 kierunków,
10
- * track sizing) → ReactFlow. Stan otwarć: MDX deklaruje start, czytelnik
11
- * toggluje, persystencja przez `store` (localStorage / agregat).
9
+ * collapseContexts → layoutProcess (8 kierunków) → ReactFlow. Stan otwarć:
10
+ * MDX deklaruje start, czytelnik toggluje, persystencja przez `store`.
12
11
  */
13
12
 
14
- import "@xyflow/react/dist/style.css";
15
-
16
- import {
17
- Background,
18
- Controls,
19
- MarkerType,
20
- MiniMap,
21
- ReactFlow,
22
- type Edge,
23
- type Node,
24
- } from "@xyflow/react";
25
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
26
- import { buildProcessModel } from "../model/build";
27
- import { collapseContexts } from "../model/collapse";
28
- import { layoutProcess, type DiagramEdge, type DiagramNode } from "../layout/layout";
29
- import { kindColor, type ProcessProfile } from "../model/profile";
30
- import {
31
- EMPTY_GRAPH,
32
- type Occurrence,
33
- type ProcessGraph,
34
- type ProcessStep,
35
- } from "../model/types";
36
- import {
37
- initialViewState,
38
- toggleContext as toggleContextState,
39
- toggleGroup as toggleGroupState,
40
- type ProcessViewState,
41
- } from "../state/view-state";
13
+ import type { ProcessProfile } from "../model/profile";
14
+ import type { ProcessGraph } from "../model/types";
42
15
  import type { ProcessViewStore } from "../state/store";
43
- import { MdxView, parseProcessFrontmatter } from "../mdx/mdx-view";
44
- import {
45
- FocusContext,
46
- ProcessDataContext,
47
- SelectionContext,
48
- ViewStateContext,
49
- } from "../mdx/contexts";
50
- import { makeNodeTypes } from "./nodes";
51
- import { ProcessDrawer, type SourceProvider } from "./drawer";
16
+ import { ProseInteractionRoot } from "./prose-root";
17
+ import { ProcessFlowCanvas } from "./process-flow-canvas";
18
+ import type { SourceProvider } from "./drawer";
52
19
  import { GLASS } from "./theme";
53
20
 
54
21
  export interface ProcessDiagramProps {
@@ -76,44 +43,10 @@ export interface ProcessDiagramProps {
76
43
  sourceProvider?: SourceProvider;
77
44
  }
78
45
 
79
- function rfNode(n: DiagramNode): Node {
80
- return {
81
- id: n.id,
82
- type: n.type,
83
- position: { x: n.x, y: n.y },
84
- width: n.w,
85
- height: n.h,
86
- data: n.data,
87
- draggable: false,
88
- selectable: n.type !== "context-group",
89
- zIndex: n.type === "context-group" ? -1 : undefined,
90
- };
91
- }
92
-
93
- function rfEdge(e: DiagramEdge): Edge {
94
- return {
95
- id: e.id,
96
- source: e.source,
97
- target: e.target,
98
- label: e.label,
99
- style: {
100
- stroke: e.color,
101
- strokeWidth: 1.5,
102
- opacity: e.opacity ?? 0.75,
103
- strokeDasharray: e.dashed ? "4 3" : undefined,
104
- },
105
- labelStyle: { fill: e.color, fontSize: 9, fontWeight: 600 },
106
- labelBgStyle: { fill: "#fff", fillOpacity: 0.8 },
107
- markerEnd: e.backbone
108
- ? undefined
109
- : { type: MarkerType.ArrowClosed, color: e.color },
110
- };
111
- }
112
-
113
46
  export function ProcessDiagram({
114
47
  mdx,
115
48
  profile,
116
- graph = EMPTY_GRAPH,
49
+ graph,
117
50
  store,
118
51
  docId,
119
52
  title,
@@ -121,218 +54,47 @@ export function ProcessDiagram({
121
54
  homeContextId,
122
55
  sourceProvider,
123
56
  }: ProcessDiagramProps) {
124
- const [occurrences, setOccurrences] = useState<Occurrence[]>([]);
125
- const [viewState, setViewState] = useState<ProcessViewState | null>(null);
126
- const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
127
- const [scrollToKey, setScrollToKey] = useState<string | null>(null);
128
- const [selectedOcc, setSelectedOcc] = useState<string | null>(null);
129
- const proseRef = useRef<HTMLDivElement>(null);
130
-
131
- const frontmatter = useMemo(() => parseProcessFrontmatter(mdx), [mdx]);
132
- const docKey = docId ?? title ?? frontmatter.title ?? mdx.slice(0, 48);
133
- const collapsedByDefault =
134
- defaultCollapsed ?? (frontmatter.collapse ?? "all") === "all";
135
- const home = homeContextId ?? frontmatter.context;
136
-
137
- // Reset przy zmianie dokumentu.
138
- useEffect(() => {
139
- setOccurrences([]);
140
- setViewState(null);
141
- setSelectedOcc(null);
142
- }, [mdx]);
143
-
144
- const model = useMemo(
145
- () => buildProcessModel({ graph, profile, occurrences }),
146
- [graph, profile, occurrences],
147
- );
148
-
149
- // Kontekst domowy: id lub nazwa z frontmatter/propsa.
150
- const homeId = useMemo(() => {
151
- if (!home) return undefined;
152
- const ctxs = graph.contexts ?? [];
153
- return (ctxs.find((c) => c.id === home) ?? ctxs.find((c) => c.name === home))?.id;
154
- }, [graph, home]);
155
-
156
- // Stan efektywny: jawny stan czytelnika → zapisany w store → startowy z MDX.
157
- const effectiveState = useMemo<ProcessViewState>(() => {
158
- if (viewState) return viewState;
159
- const stored = store?.load(docKey);
160
- if (stored) return stored;
161
- return initialViewState(model);
162
- }, [viewState, store, docKey, model]);
163
-
164
- const updateState = useCallback(
165
- (next: ProcessViewState) => {
166
- setViewState(next);
167
- store?.save(docKey, next);
168
- },
169
- [store, docKey],
170
- );
171
-
172
- const collapse = useMemo(
173
- () =>
174
- collapseContexts(model, {
175
- contexts: graph.contexts ?? [],
176
- state: effectiveState,
177
- defaultCollapsed: collapsedByDefault,
178
- homeContextId: homeId,
179
- }),
180
- [model, graph, effectiveState, collapsedByDefault, homeId],
181
- );
182
-
183
- const layout = useMemo(
184
- () => layoutProcess({ model, collapse, graph, profile, state: effectiveState }),
185
- [model, collapse, graph, profile, effectiveState],
186
- );
187
-
188
- const nodeTypes = useMemo(() => makeNodeTypes(profile), [profile]);
189
- const nodes = useMemo(() => layout.nodes.map(rfNode), [layout]);
190
- const edges = useMemo(() => layout.edges.map(rfEdge), [layout]);
191
-
192
- // occId kroku -> klucz refa (klik w nod przewija prozę do chipu).
193
- const keyByOcc = useMemo(() => {
194
- const m = new Map<string, string>();
195
- for (const s of model.steps) if (s.ref) m.set(s.occId, s.ref.key);
196
- return m;
197
- }, [model]);
198
-
199
- useEffect(() => {
200
- if (!scrollToKey || !proseRef.current) return;
201
- const el = proseRef.current.querySelector(
202
- `[data-arc-key="${CSS.escape(scrollToKey)}"]`,
203
- );
204
- el?.scrollIntoView({ behavior: "smooth", block: "center" });
205
- setScrollToKey(null);
206
- }, [scrollToKey]);
207
-
208
- const focusState = useMemo(
209
- () => ({ focusNodeId, setFocusNodeId, scrollToKey, setScrollToKey }),
210
- [focusNodeId, scrollToKey],
211
- );
212
-
213
- const viewStateActions = useMemo(
214
- () => ({
215
- state: effectiveState,
216
- toggleGroup: (key: string, group: string) =>
217
- updateState(toggleGroupState(effectiveState, key, group)),
218
- toggleContext: (contextId: string) =>
219
- updateState(
220
- toggleContextState(effectiveState, contextId, collapsedByDefault),
221
- ),
222
- }),
223
- [effectiveState, updateState, collapsedByDefault],
224
- );
225
-
226
- const processData = useMemo(() => ({ graph, profile }), [graph, profile]);
227
-
228
- const selectionState = useMemo(
229
- () => ({ selectedOcc, selectOcc: setSelectedOcc }),
230
- [selectedOcc],
231
- );
232
-
233
- // Krok do drawera: wybrany occId szukamy najpierw wśród kroków po collapse
234
- // (pseudo-kroki boxów), potem w oryginalnych (chip prozy członka boxa).
235
- const selectedStep = useMemo(() => {
236
- if (!selectedOcc) return null;
237
- const group = collapse.groupByOcc.get(selectedOcc);
238
- return (
239
- collapse.steps.find((s) => s.occId === (group ?? selectedOcc)) ??
240
- model.steps.find((s) => s.occId === selectedOcc) ??
241
- null
242
- );
243
- }, [selectedOcc, collapse, model]);
244
-
57
+ const docKey = docId ?? title;
245
58
  return (
246
- <ProcessDataContext.Provider value={processData}>
247
- <FocusContext.Provider value={focusState}>
248
- <SelectionContext.Provider value={selectionState}>
249
- <ViewStateContext.Provider value={viewStateActions}>
59
+ <ProseInteractionRoot
60
+ mdx={mdx}
61
+ profile={profile}
62
+ graph={graph}
63
+ store={store}
64
+ docId={docId}
65
+ title={title}
66
+ defaultCollapsed={defaultCollapsed}
67
+ homeContextId={homeContextId}
68
+ >
69
+ {({ Prose, proseRef, model, collapse }) => (
70
+ <div
71
+ style={{
72
+ display: "grid",
73
+ gridTemplateColumns: "minmax(320px, 1fr) minmax(420px, 1.3fr)",
74
+ height: "100%",
75
+ minHeight: 0,
76
+ overflow: "hidden",
77
+ }}
78
+ >
250
79
  <div
80
+ ref={proseRef}
251
81
  style={{
252
- display: "grid",
253
- gridTemplateColumns: "minmax(320px, 1fr) minmax(420px, 1.3fr)",
254
- height: "100%",
82
+ overflow: "auto",
255
83
  minHeight: 0,
256
- overflow: "hidden",
84
+ padding: "20px 24px",
85
+ borderRight: `1px solid ${GLASS.border}`,
257
86
  }}
258
87
  >
259
- {/* Proza */}
260
- <div
261
- ref={proseRef}
262
- style={{
263
- overflow: "auto",
264
- minHeight: 0,
265
- padding: "20px 24px",
266
- borderRight: `1px solid ${GLASS.border}`,
267
- }}
268
- >
269
- <MdxView key={docKey} mdx={mdx} onOccurrences={setOccurrences} />
270
- </div>
271
-
272
- {/* Diagram — sztywno wypełnia dostępną wysokość, bez scrolla strony */}
273
- <div style={{ position: "relative", minHeight: 0, overflow: "hidden" }}>
274
- <ReactFlow
275
- key={docKey}
276
- nodes={nodes}
277
- edges={edges}
278
- nodeTypes={nodeTypes}
279
- nodesDraggable={false}
280
- onNodeMouseEnter={(_, n) => {
281
- const step = (n.data as { step?: ProcessStep }).step;
282
- const id =
283
- step?.node?.id ??
284
- (n.data as { node?: { id: string } }).node?.id;
285
- if (id) setFocusNodeId(id);
286
- }}
287
- onNodeMouseLeave={() => setFocusNodeId(null)}
288
- onNodeClick={(_, n) => {
289
- const k = keyByOcc.get(n.id);
290
- if (k) setScrollToKey(k);
291
- // Kroki (także boxy kontekstów) otwierają drawer.
292
- if (n.type === "step") setSelectedOcc(n.id);
293
- }}
294
- onPaneClick={() => setSelectedOcc(null)}
295
- fitView
296
- fitViewOptions={{ padding: 0.18 }}
297
- minZoom={0.1}
298
- proOptions={{ hideAttribution: true }}
299
- >
300
- <Background color="#dbeafe" gap={26} size={1.5} />
301
- <Controls />
302
- <MiniMap
303
- pannable
304
- zoomable
305
- nodeColor={(n) => {
306
- const step = (n.data as { step?: ProcessStep }).step;
307
- // Kolor z zadeklarowanego rodzaju — także dla TODO.
308
- const kind =
309
- step?.node?.kind ??
310
- step?.ref?.kind ??
311
- (n.data as { node?: { kind: string } }).node?.kind;
312
- return kind ? kindColor(profile, kind) : "#64748b";
313
- }}
314
- style={{
315
- background: "rgba(255,255,255,0.6)",
316
- border: `1px solid ${GLASS.border}`,
317
- borderRadius: 12,
318
- }}
319
- />
320
- </ReactFlow>
321
-
322
- {/* Drawer szczegółów wybranego kroku. */}
323
- {selectedStep ? (
324
- <ProcessDrawer
325
- step={selectedStep}
326
- profile={profile}
327
- onClose={() => setSelectedOcc(null)}
328
- sourceProvider={sourceProvider}
329
- />
330
- ) : null}
331
- </div>
88
+ {Prose}
332
89
  </div>
333
- </ViewStateContext.Provider>
334
- </SelectionContext.Provider>
335
- </FocusContext.Provider>
336
- </ProcessDataContext.Provider>
90
+ <ProcessFlowCanvas
91
+ model={model}
92
+ collapse={collapse}
93
+ docKey={docKey}
94
+ sourceProvider={sourceProvider}
95
+ />
96
+ </div>
97
+ )}
98
+ </ProseInteractionRoot>
337
99
  );
338
100
  }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * `ProcessFlowCanvas` — procesowy renderer diagramu (ReactFlow + drawer).
3
+ *
4
+ * Slot diagramu dla `ProseInteractionRoot`: czyta graf/profil/focus/selekcję/
5
+ * stan z kontekstu, liczy layout (8 kierunków, track sizing) i renderuje
6
+ * ReactFlow. To JEDYNE miejsce importujące `@xyflow` — rdzeń prozy jest od
7
+ * niego wolny.
8
+ */
9
+
10
+ import "@xyflow/react/dist/style.css";
11
+
12
+ import {
13
+ Background,
14
+ Controls,
15
+ MarkerType,
16
+ MiniMap,
17
+ ReactFlow,
18
+ type Edge,
19
+ type Node,
20
+ } from "@xyflow/react";
21
+ import { useMemo } from "react";
22
+ import type { CollapseResult } from "../model/collapse";
23
+ import { layoutProcess, type DiagramEdge, type DiagramNode } from "../layout/layout";
24
+ import { kindColor } from "../model/profile";
25
+ import type { ProcessModel, ProcessStep } from "../model/types";
26
+ import {
27
+ useFocus,
28
+ useProcessData,
29
+ useSelection,
30
+ useViewState,
31
+ } from "../mdx/contexts";
32
+ import { makeNodeTypes } from "./nodes";
33
+ import { ProcessDrawer, type SourceProvider } from "./drawer";
34
+ import { GLASS } from "./theme";
35
+
36
+ function rfNode(n: DiagramNode): Node {
37
+ return {
38
+ id: n.id,
39
+ type: n.type,
40
+ position: { x: n.x, y: n.y },
41
+ width: n.w,
42
+ height: n.h,
43
+ data: n.data,
44
+ draggable: false,
45
+ selectable: n.type !== "context-group",
46
+ zIndex: n.type === "context-group" ? -1 : undefined,
47
+ };
48
+ }
49
+
50
+ function rfEdge(e: DiagramEdge): Edge {
51
+ return {
52
+ id: e.id,
53
+ source: e.source,
54
+ target: e.target,
55
+ label: e.label,
56
+ style: {
57
+ stroke: e.color,
58
+ strokeWidth: 1.5,
59
+ opacity: e.opacity ?? 0.75,
60
+ strokeDasharray: e.dashed ? "4 3" : undefined,
61
+ },
62
+ labelStyle: { fill: e.color, fontSize: 9, fontWeight: 600 },
63
+ labelBgStyle: { fill: "#fff", fillOpacity: 0.8 },
64
+ markerEnd: e.backbone
65
+ ? undefined
66
+ : { type: MarkerType.ArrowClosed, color: e.color },
67
+ };
68
+ }
69
+
70
+ export interface ProcessFlowCanvasProps {
71
+ model: ProcessModel;
72
+ collapse: CollapseResult;
73
+ /** Klucz remountu (docId) — świeży ReactFlow przy zmianie dokumentu. */
74
+ docKey?: string;
75
+ sourceProvider?: SourceProvider;
76
+ }
77
+
78
+ export function ProcessFlowCanvas({
79
+ model,
80
+ collapse,
81
+ docKey,
82
+ sourceProvider,
83
+ }: ProcessFlowCanvasProps) {
84
+ const data = useProcessData();
85
+ const focus = useFocus();
86
+ const selection = useSelection();
87
+ const viewState = useViewState();
88
+ const profile = data!.profile;
89
+ const graph = data!.graph;
90
+
91
+ const layout = useMemo(
92
+ () =>
93
+ layoutProcess({
94
+ model,
95
+ collapse,
96
+ graph,
97
+ profile,
98
+ state: viewState.state,
99
+ }),
100
+ [model, collapse, graph, profile, viewState.state],
101
+ );
102
+
103
+ const nodeTypes = useMemo(() => makeNodeTypes(profile), [profile]);
104
+ const nodes = useMemo(() => layout.nodes.map(rfNode), [layout]);
105
+ const edges = useMemo(() => layout.edges.map(rfEdge), [layout]);
106
+
107
+ const keyByOcc = useMemo(() => {
108
+ const m = new Map<string, string>();
109
+ for (const s of model.steps) if (s.ref) m.set(s.occId, s.ref.key);
110
+ return m;
111
+ }, [model]);
112
+
113
+ const selectedStep = useMemo(() => {
114
+ if (!selection.selectedOcc) return null;
115
+ const group = collapse.groupByOcc.get(selection.selectedOcc);
116
+ return (
117
+ collapse.steps.find((s) => s.occId === (group ?? selection.selectedOcc)) ??
118
+ model.steps.find((s) => s.occId === selection.selectedOcc) ??
119
+ null
120
+ );
121
+ }, [selection.selectedOcc, collapse, model]);
122
+
123
+ return (
124
+ <div style={{ position: "relative", minHeight: 0, height: "100%", overflow: "hidden" }}>
125
+ <ReactFlow
126
+ key={docKey}
127
+ nodes={nodes}
128
+ edges={edges}
129
+ nodeTypes={nodeTypes}
130
+ nodesDraggable={false}
131
+ onNodeMouseEnter={(_, n) => {
132
+ const step = (n.data as { step?: ProcessStep }).step;
133
+ const id = step?.node?.id ?? (n.data as { node?: { id: string } }).node?.id;
134
+ if (id) focus.setFocusNodeId(id);
135
+ }}
136
+ onNodeMouseLeave={() => focus.setFocusNodeId(null)}
137
+ onNodeClick={(_, n) => {
138
+ const k = keyByOcc.get(n.id);
139
+ if (k) focus.setScrollToKey(k);
140
+ if (n.type === "step") selection.selectOcc(n.id);
141
+ }}
142
+ onPaneClick={() => selection.selectOcc(null)}
143
+ fitView
144
+ fitViewOptions={{ padding: 0.18 }}
145
+ minZoom={0.1}
146
+ proOptions={{ hideAttribution: true }}
147
+ >
148
+ <Background color="#dbeafe" gap={26} size={1.5} />
149
+ <Controls />
150
+ <MiniMap
151
+ pannable
152
+ zoomable
153
+ nodeColor={(n) => {
154
+ const step = (n.data as { step?: ProcessStep }).step;
155
+ const kind =
156
+ step?.node?.kind ??
157
+ step?.ref?.kind ??
158
+ (n.data as { node?: { kind: string } }).node?.kind;
159
+ return kind ? kindColor(profile, kind) : "#64748b";
160
+ }}
161
+ style={{
162
+ background: "rgba(255,255,255,0.6)",
163
+ border: `1px solid ${GLASS.border}`,
164
+ borderRadius: 12,
165
+ }}
166
+ />
167
+ </ReactFlow>
168
+
169
+ {selectedStep ? (
170
+ <ProcessDrawer
171
+ step={selectedStep}
172
+ profile={profile}
173
+ onClose={() => selection.selectOcc(null)}
174
+ sourceProvider={sourceProvider}
175
+ />
176
+ ) : null}
177
+ </div>
178
+ );
179
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * `ProseInteractionRoot` — headless rdzeń interakcji „proza MDX + chipy".
3
+ *
4
+ * Trzyma CAŁY stan (wystąpienia, model, stan widoku, focus, selekcja) i
5
+ * providery (ProcessData/Focus/Selection/ViewState), renderuje prozę i
6
+ * udostępnia współdzielone API render-propem. NIE importuje ReactFlow — dzięki
7
+ * temu konsument prozy (np. dokumenty BMF z własnym canvasem grafu wiedzy) nie
8
+ * ciągnie `@xyflow`. Renderer diagramu jest SLOTEM: `ProcessDiagram` wpina
9
+ * `ProcessFlowCanvas`, dokumenty wpinają swój canvas — OBA czytają ten sam
10
+ * `FocusContext`/`SelectionContext`.
11
+ */
12
+
13
+ import {
14
+ useCallback,
15
+ useEffect,
16
+ useMemo,
17
+ useRef,
18
+ useState,
19
+ type ComponentType,
20
+ type ReactNode,
21
+ type RefObject,
22
+ } from "react";
23
+ import { buildProcessModel } from "../model/build";
24
+ import { collapseContexts, type CollapseResult } from "../model/collapse";
25
+ import type { ProcessProfile } from "../model/profile";
26
+ import {
27
+ EMPTY_GRAPH,
28
+ type Occurrence,
29
+ type ProcessGraph,
30
+ type ProcessModel,
31
+ type ResolvedRef,
32
+ } from "../model/types";
33
+ import {
34
+ initialViewState,
35
+ toggleContext as toggleContextState,
36
+ toggleGroup as toggleGroupState,
37
+ type ProcessViewState,
38
+ } from "../state/view-state";
39
+ import type { ProcessViewStore } from "../state/store";
40
+ import { MdxView, parseProcessFrontmatter } from "../mdx/mdx-view";
41
+ import {
42
+ FocusContext,
43
+ ProcessDataContext,
44
+ SelectionContext,
45
+ ViewStateContext,
46
+ type FocusState,
47
+ type SelectionState,
48
+ type ViewStateActions,
49
+ } from "../mdx/contexts";
50
+
51
+ /** API współdzielone przez rdzeń z rendererem diagramu (render-prop). */
52
+ export interface ProseRootRenderApi {
53
+ /** Gotowy element prozy (MdxView w ArcRefsProvider). Osadź w kolumnie prozy. */
54
+ Prose: ReactNode;
55
+ /** Ref kolumny prozy — podepnij do kontenera, by działał scroll-do-chipu. */
56
+ proseRef: RefObject<HTMLDivElement | null>;
57
+ model: ProcessModel;
58
+ collapse: CollapseResult;
59
+ graph: ProcessGraph;
60
+ profile: ProcessProfile;
61
+ focus: FocusState;
62
+ selection: SelectionState;
63
+ viewState: ViewStateActions;
64
+ /** Domyślny tryb zwijania (z frontmatter/propsa). */
65
+ collapsedByDefault: boolean;
66
+ }
67
+
68
+ export interface ProseInteractionRootProps {
69
+ mdx: string;
70
+ profile: ProcessProfile;
71
+ graph?: ProcessGraph;
72
+ /** Extra/override komponenty MDX (Ref/Node/Rel/Metric/Formula konsumenta). */
73
+ components?: Record<string, ComponentType<Record<string, unknown>>>;
74
+ /** Most token→realId dla focusu (canvas grafu wiedzy). Brak = jak arc-map. */
75
+ resolveNodeId?: (ref: ResolvedRef) => string | null;
76
+ /** Podgląd zebranych wystąpień (np. do zbudowania overlayu grafu wiedzy). */
77
+ onOccurrences?: (occ: Occurrence[]) => void;
78
+ store?: ProcessViewStore;
79
+ docId?: string;
80
+ title?: string;
81
+ defaultCollapsed?: boolean;
82
+ homeContextId?: string;
83
+ children: (api: ProseRootRenderApi) => ReactNode;
84
+ }
85
+
86
+ export function ProseInteractionRoot({
87
+ mdx,
88
+ profile,
89
+ graph = EMPTY_GRAPH,
90
+ components,
91
+ resolveNodeId,
92
+ onOccurrences,
93
+ store,
94
+ docId,
95
+ title,
96
+ defaultCollapsed,
97
+ homeContextId,
98
+ children,
99
+ }: ProseInteractionRootProps) {
100
+ const [occurrences, setOccurrences] = useState<Occurrence[]>([]);
101
+ const [viewState, setViewState] = useState<ProcessViewState | null>(null);
102
+ const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
103
+ const [scrollToKey, setScrollToKey] = useState<string | null>(null);
104
+ const [selectedOcc, setSelectedOcc] = useState<string | null>(null);
105
+ const proseRef = useRef<HTMLDivElement | null>(null);
106
+
107
+ const frontmatter = useMemo(() => parseProcessFrontmatter(mdx), [mdx]);
108
+ const docKey = docId ?? title ?? frontmatter.title ?? mdx.slice(0, 48);
109
+ const collapsedByDefault =
110
+ defaultCollapsed ?? (frontmatter.collapse ?? "all") === "all";
111
+ const home = homeContextId ?? frontmatter.context;
112
+
113
+ useEffect(() => {
114
+ setOccurrences([]);
115
+ setViewState(null);
116
+ setSelectedOcc(null);
117
+ }, [mdx]);
118
+
119
+ const handleOccurrences = useCallback(
120
+ (occ: Occurrence[]) => {
121
+ setOccurrences(occ);
122
+ onOccurrences?.(occ);
123
+ },
124
+ [onOccurrences],
125
+ );
126
+
127
+ const model = useMemo(
128
+ () => buildProcessModel({ graph, profile, occurrences }),
129
+ [graph, profile, occurrences],
130
+ );
131
+
132
+ const homeId = useMemo(() => {
133
+ if (!home) return undefined;
134
+ const ctxs = graph.contexts ?? [];
135
+ return (ctxs.find((c) => c.id === home) ?? ctxs.find((c) => c.name === home))?.id;
136
+ }, [graph, home]);
137
+
138
+ const effectiveState = useMemo<ProcessViewState>(() => {
139
+ if (viewState) return viewState;
140
+ const stored = store?.load(docKey);
141
+ if (stored) return stored;
142
+ return initialViewState(model);
143
+ }, [viewState, store, docKey, model]);
144
+
145
+ const updateState = useCallback(
146
+ (next: ProcessViewState) => {
147
+ setViewState(next);
148
+ store?.save(docKey, next);
149
+ },
150
+ [store, docKey],
151
+ );
152
+
153
+ const collapse = useMemo(
154
+ () =>
155
+ collapseContexts(model, {
156
+ contexts: graph.contexts ?? [],
157
+ state: effectiveState,
158
+ defaultCollapsed: collapsedByDefault,
159
+ homeContextId: homeId,
160
+ }),
161
+ [model, graph, effectiveState, collapsedByDefault, homeId],
162
+ );
163
+
164
+ // Klik w nod przewija prozę do odpowiadającego chipu (data-arc-key).
165
+ useEffect(() => {
166
+ if (!scrollToKey || !proseRef.current) return;
167
+ const el = proseRef.current.querySelector(
168
+ `[data-arc-key="${CSS.escape(scrollToKey)}"]`,
169
+ );
170
+ el?.scrollIntoView({ behavior: "smooth", block: "center" });
171
+ setScrollToKey(null);
172
+ }, [scrollToKey]);
173
+
174
+ const focusState = useMemo<FocusState>(
175
+ () => ({ focusNodeId, setFocusNodeId, scrollToKey, setScrollToKey }),
176
+ [focusNodeId, scrollToKey],
177
+ );
178
+
179
+ const viewStateActions = useMemo<ViewStateActions>(
180
+ () => ({
181
+ state: effectiveState,
182
+ toggleGroup: (key, group) =>
183
+ updateState(toggleGroupState(effectiveState, key, group)),
184
+ toggleContext: (contextId) =>
185
+ updateState(
186
+ toggleContextState(effectiveState, contextId, collapsedByDefault),
187
+ ),
188
+ }),
189
+ [effectiveState, updateState, collapsedByDefault],
190
+ );
191
+
192
+ const processData = useMemo(
193
+ () => ({ graph, profile, resolveNodeId }),
194
+ [graph, profile, resolveNodeId],
195
+ );
196
+
197
+ const selectionState = useMemo<SelectionState>(
198
+ () => ({ selectedOcc, selectOcc: setSelectedOcc }),
199
+ [selectedOcc],
200
+ );
201
+
202
+ const Prose = (
203
+ <MdxView
204
+ key={docKey}
205
+ mdx={mdx}
206
+ components={components}
207
+ onOccurrences={handleOccurrences}
208
+ />
209
+ );
210
+
211
+ return (
212
+ <ProcessDataContext.Provider value={processData}>
213
+ <FocusContext.Provider value={focusState}>
214
+ <SelectionContext.Provider value={selectionState}>
215
+ <ViewStateContext.Provider value={viewStateActions}>
216
+ {children({
217
+ Prose,
218
+ proseRef,
219
+ model,
220
+ collapse,
221
+ graph,
222
+ profile,
223
+ focus: focusState,
224
+ selection: selectionState,
225
+ viewState: viewStateActions,
226
+ collapsedByDefault,
227
+ })}
228
+ </ViewStateContext.Provider>
229
+ </SelectionContext.Provider>
230
+ </FocusContext.Provider>
231
+ </ProcessDataContext.Provider>
232
+ );
233
+ }
@@ -66,6 +66,26 @@ describe("buildProcessModel — parytet z buildUseCaseModel", () => {
66
66
  expect(m.steps[0].node).toBeUndefined();
67
67
  });
68
68
 
69
+ test("inline declare-node / relation occurrences are ignored (nie wchodzą w kroki)", () => {
70
+ const m = buildProcessModel({
71
+ graph,
72
+ profile: arcProfile,
73
+ occurrences: [
74
+ occ("o1", { command: "cmd" }),
75
+ {
76
+ type: "declare-node",
77
+ occId: "d1",
78
+ node: { id: "eph:x", kind: "ephemeral", label: "X" },
79
+ },
80
+ { type: "relation", occId: "r1", from: "a", to: "b", kind: "rel" },
81
+ occ("o2", { command: "cmd" }),
82
+ ],
83
+ });
84
+ // Tylko dwa kroki `<Arc>` — inline node/rel pomijane przez profil procesowy.
85
+ expect(m.steps.map((s) => s.occId)).toEqual(["o1", "o2"]);
86
+ expect(m.steps.map((s) => s.seq)).toEqual([1, 2]);
87
+ });
88
+
69
89
  test("forward attachments exclude visible; query dep becomes attachment", () => {
70
90
  const m = buildProcessModel({
71
91
  graph,
@@ -269,6 +269,28 @@ describe("collapse kontekstów", () => {
269
269
  expect(rel[0].source.startsWith("grp:ctx_chat")).toBe(true);
270
270
  });
271
271
 
272
+ test("relacja prawo→lewo pominięta; lewo→prawo narysowana", () => {
273
+ // cmd queries v (semantyka "in"): strzałka biegnie v→cmd.
274
+ // Narracja cmd(depth0) → v(depth1): v jest NA PRAWO, strzałka wsteczna → skip.
275
+ const back = run([occ("o1", { command: "cmd" }), occ("o2", { view: "v" })]);
276
+ expect(back.layout.edges.filter((e) => e.id.startsWith("rel:"))).toHaveLength(0);
277
+ // Narracja v(depth0) → cmd(depth1): v na lewo, strzałka lewo→prawo → rysuj.
278
+ const fwd = run([occ("o1", { view: "v" }), occ("o2", { command: "cmd" })]);
279
+ expect(fwd.layout.edges.filter((e) => e.id.startsWith("rel:")).length).toBeGreaterThan(0);
280
+ });
281
+
282
+ test("relacja w tej samej kolumnie (Parallel) pominięta", () => {
283
+ // cmd mutates e (semantyka "out"): oba kroki w Parallel = ta sama kolumna.
284
+ const par = run([
285
+ { ...occ("o1", { command: "cmd" }), path: [{ type: "parallel", id: "P", laneIndex: 0 }] },
286
+ { ...occ("o2", { event: "e" }), path: [{ type: "parallel", id: "P", laneIndex: 1 }] },
287
+ ]);
288
+ const a = nodeById(par.layout.nodes, "o1");
289
+ const b = nodeById(par.layout.nodes, "o2");
290
+ expect(a.x).toBe(b.x); // ta sama kolumna
291
+ expect(par.layout.edges.filter((e) => e.id.startsWith("rel:"))).toHaveLength(0);
292
+ });
293
+
272
294
  test("initialViewState otwiera grupy z expand", () => {
273
295
  const o: StepOccurrence = { ...occ("o1", { command: "cmd" }), expand: ["mutates"] };
274
296
  const model = buildProcessModel({ graph, profile: arcProfile, occurrences: [o] });