AP1.15c: Join/Branch-Topologie mit parallel_group und optional_branch.
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 2m18s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 13s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-11 09:27:56 +02:00
parent 364ad44759
commit d7aefed0de
13 changed files with 321 additions and 43 deletions

View File

@ -1,26 +1,69 @@
"""Join- und Branch-Topologie — Reservierung für AP1.15c+. """Join- und Branch-Topologie — AP1.15c.
Heute blockiert `roadmap_engine.compute_initiative_graph_state` nur über AND-Join: mehrere `requires` auf dasselbe Gate (weiterhin in roadmap_engine).
`requires` und `blocks`. `parallel_group` und `optional_branch` sind im Schema `optional_branch`: blockiert nicht wird aus blocked_by entfernt (defensiv).
vorhanden, werden aber noch nicht für blocked/ready ausgewertet. `parallel_group`: Metadaten für parallele Stränge (group_key); keine Blockade.
OR-Alternativpfade: bewusst nicht implementiert (PO-Deferral).
Erweiterungspunkt: neue Auswertung hier kapseln, Engine ruft dann
`apply_topology_to_blocked_map(...)` auf ohne Router- oder UI-Sonderlogik.
Siehe ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md § Join/Branch deferred.
""" """
from __future__ import annotations from __future__ import annotations
from collections import defaultdict
from typing import Any from typing import Any
# Im Schema/API vorhanden; Engine-Logik folgt in AP1.15c
DEFERRED_TOPOLOGY_EDGE_KINDS = frozenset({"parallel_group", "optional_branch"}) DEFERRED_TOPOLOGY_EDGE_KINDS = frozenset({"parallel_group", "optional_branch"})
# Designer AP1.15c; OR-Pfade nur nach validiertem Use Case + PO-Freigabe
PLANNED_TOPOLOGY_PACKAGES = ("AP1.15c",) PLANNED_TOPOLOGY_PACKAGES = ("AP1.15c",)
def _dep_type(dep: dict[str, Any]) -> str:
return dep.get("dependency_type") or dep.get("edge_kind") or "requires"
def build_topology_metadata(
*,
dependencies: list[dict[str, Any]],
item_by_id: dict[str, dict[str, Any]],
) -> dict[str, Any]:
"""Read-only Topologie-Hinweise für UI/Steuerung."""
parallel_groups: dict[str, list[str]] = defaultdict(list)
optional_edges: list[dict[str, str]] = []
for dep in dependencies:
dep_type = _dep_type(dep)
from_id = str(dep["from_item_id"])
to_id = str(dep["to_item_id"])
if from_id not in item_by_id or to_id not in item_by_id:
continue
if dep_type == "optional_branch":
optional_edges.append({"from_item_id": from_id, "to_item_id": to_id})
elif dep_type == "parallel_group":
group_key = (dep.get("group_key") or "default").strip() or "default"
for item_id in (from_id, to_id):
if item_id not in parallel_groups[group_key]:
parallel_groups[group_key].append(item_id)
return {
"parallel_groups": dict(parallel_groups),
"optional_branches": optional_edges,
}
def _optional_prerequisite_pairs(
dependencies: list[dict[str, Any]],
item_by_id: dict[str, dict[str, Any]],
) -> set[tuple[str, str]]:
pairs: set[tuple[str, str]] = set()
for dep in dependencies:
if _dep_type(dep) != "optional_branch":
continue
from_id = str(dep["from_item_id"])
to_id = str(dep["to_item_id"])
if from_id in item_by_id and to_id in item_by_id:
pairs.add((from_id, to_id))
return pairs
def apply_deferred_topology( def apply_deferred_topology(
*, *,
dependencies: list[dict[str, Any]], dependencies: list[dict[str, Any]],
@ -28,9 +71,37 @@ def apply_deferred_topology(
blocked_by_map: dict[str, list[str]], blocked_by_map: dict[str, list[str]],
) -> dict[str, list[str]]: ) -> dict[str, list[str]]:
""" """
Erweitert blocked_by_map um parallel_group / optional_branch (AP1.15c). Entfernt optionale Voraussetzungen aus blocked_by.
Aktuell: No-Op gibt blocked_by_map unverändert zurück. `parallel_group` ändert blocked_by nicht Join bleibt über `requires`-Kanten.
""" """
_ = (dependencies, item_by_id) optional_pairs = _optional_prerequisite_pairs(dependencies, item_by_id)
return blocked_by_map if not optional_pairs:
return blocked_by_map
adjusted: dict[str, list[str]] = {}
for item_id, blockers in blocked_by_map.items():
filtered = [b for b in blockers if (item_id, b) not in optional_pairs]
adjusted[item_id] = filtered
return adjusted
def topology_hints_for_item(
*,
item_id: str,
topology: dict[str, Any],
) -> dict[str, Any]:
parallel_keys = [
key
for key, members in topology.get("parallel_groups", {}).items()
if item_id in members
]
optional_waiting_on = [
edge["to_item_id"]
for edge in topology.get("optional_branches", [])
if edge["from_item_id"] == item_id
]
return {
"parallel_group_keys": parallel_keys,
"optional_prerequisites": optional_waiting_on,
}

View File

@ -10,7 +10,11 @@ from steering.graph.profiles import (
get_graph_profile, get_graph_profile,
graph_profile_as_dict, graph_profile_as_dict,
) )
from steering.graph.join_branch import apply_deferred_topology from steering.graph.join_branch import (
apply_deferred_topology,
build_topology_metadata,
topology_hints_for_item,
)
TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"}) TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
ACTIVE_STATUSES = frozenset({"planned", "active", "at_risk"}) ACTIVE_STATUSES = frozenset({"planned", "active", "at_risk"})
@ -113,6 +117,11 @@ def compute_initiative_graph_state(
blocked_by_map=blocked_by_map, blocked_by_map=blocked_by_map,
) )
topology = build_topology_metadata(
dependencies=dependencies,
item_by_id=item_by_id,
)
item_states: dict[str, dict[str, Any]] = {} item_states: dict[str, dict[str, Any]] = {}
blocked_items: list[str] = [] blocked_items: list[str] = []
ready_items: list[str] = [] ready_items: list[str] = []
@ -127,12 +136,15 @@ def compute_initiative_graph_state(
) )
ratio = _fulfillment_ratio(criteria_progress.get(item_id)) ratio = _fulfillment_ratio(criteria_progress.get(item_id))
hints = topology_hints_for_item(item_id=item_id, topology=topology)
item_states[item_id] = { item_states[item_id] = {
"status": status, "status": status,
"blocked": blocked, "blocked": blocked,
"ready": ready, "ready": ready,
"blocked_by": blocked_by, "blocked_by": blocked_by,
"fulfillment_ratio": ratio, "fulfillment_ratio": ratio,
"parallel_group_keys": hints["parallel_group_keys"],
"optional_prerequisites": hints["optional_prerequisites"],
} }
if blocked and status in ACTIVE_STATUSES: if blocked and status in ACTIVE_STATUSES:
blocked_items.append(item_id) blocked_items.append(item_id)
@ -143,6 +155,7 @@ def compute_initiative_graph_state(
"items": item_states, "items": item_states,
"blocked_items": blocked_items, "blocked_items": blocked_items,
"ready_items": ready_items, "ready_items": ready_items,
"topology": topology,
} }

View File

@ -0,0 +1,70 @@
"""Tests for join/branch topology — AP1.15c."""
from steering.graph.join_branch import (
apply_deferred_topology,
build_topology_metadata,
)
from steering.graph.roadmap_engine import compute_initiative_graph_state
def _item(item_id: str, status: str = "planned", **kwargs):
return {
"id": item_id,
"status": status,
"sort_order": kwargs.get("sort_order", 0),
"sequencing_mode": kwargs.get("sequencing_mode", "sequential"),
"title": kwargs.get("title", item_id),
}
def test_optional_branch_does_not_block():
items = [_item("join", "planned"), _item("opt", "planned"), _item("req", "planned")]
deps = [
{
"from_item_id": "join",
"to_item_id": "req",
"dependency_type": "requires",
},
{
"from_item_id": "join",
"to_item_id": "opt",
"dependency_type": "optional_branch",
},
]
state = compute_initiative_graph_state(items=items, dependencies=deps)
assert state["items"]["join"]["blocked"] is True
assert state["items"]["join"]["blocked_by"] == ["req"]
assert "opt" not in state["items"]["join"]["blocked_by"]
assert state["items"]["join"]["optional_prerequisites"] == ["opt"]
def test_parallel_group_metadata():
items = [_item("a"), _item("b"), _item("c")]
deps = [
{
"from_item_id": "b",
"to_item_id": "c",
"dependency_type": "parallel_group",
"group_key": "phase-1",
}
]
topology = build_topology_metadata(dependencies=deps, item_by_id={i["id"]: i for i in items})
assert set(topology["parallel_groups"]["phase-1"]) == {"b", "c"}
def test_apply_deferred_topology_strips_optional_blockers():
item_by_id = {"x": _item("x"), "y": _item("y")}
blocked_by_map = {"x": ["y", "z"]}
deps = [
{
"from_item_id": "x",
"to_item_id": "y",
"dependency_type": "optional_branch",
}
]
result = apply_deferred_topology(
dependencies=deps,
item_by_id=item_by_id,
blocked_by_map=blocked_by_map,
)
assert result["x"] == ["z"]

View File

@ -224,6 +224,9 @@ Plan-UI: **Detail-Modal/Route** `/initiatives/:id/plan/items/:itemId` — Checkl
| **AP1.15a** | Designer MVP: Pan/Zoom, Kanten (Klick), Layout localStorage | | **AP1.15a** | Designer MVP: Pan/Zoom, Kanten (Klick), Layout localStorage |
| **AP1.15b** | UX: Drag-Kanten, Gate anlegen, Klick-Panel (ohne Navigation) | | **AP1.15b** | UX: Drag-Kanten, Gate anlegen, Klick-Panel (ohne Navigation) |
| **AP1.15c** | Topologie: `parallel_group`, `optional_branch`, Join-Semantik in Engine + Designer | | **AP1.15c** | Topologie: `parallel_group`, `optional_branch`, Join-Semantik in Engine + Designer |
| **AP1.15d** | (optional) OR-Alternativpfade — nur bei validiertem Use Case |
**Stand AP1.15c (2026-07-11):** AND-Join über `requires`; optional blockiert nicht; parallel als Metadaten; OR ausstehend.
--- ---

View File

@ -105,7 +105,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Abhängigkeiten | ◐ | API; Graph Engine AP1.4d | | Abhängigkeiten | ◐ | API; Graph Engine AP1.4d |
| parallel vs. sequenziell | ◐ | `sequencing_mode` (deprecated); Graph AP1.4d | | parallel vs. sequenziell | ◐ | `sequencing_mode` (deprecated); Graph AP1.4d |
| Plan-Graph vs. Ist-Overlay | ✗ | AP1.13: ein Graph + Status am Knoten — Plan/Ist vermischt | | Plan-Graph vs. Ist-Overlay | ✗ | AP1.13: ein Graph + Status am Knoten — Plan/Ist vermischt |
| Zielzustands-Designer (Joint/Parallel) | ◐ | AP1.15a Designer MVP; Join/Branch → AP1.15c (deferred, vorbereitet) | | Zielzustands-Designer (Joint/Parallel) | ◐ | AP1.15a/b Designer; AP1.15c parallel/optional in Engine+Designer; OR deferred |
| Plan-Revision / Graph-Historie | ◐ | AP1.14 Plan-Snapshots | | Plan-Revision / Graph-Historie | ◐ | AP1.14 Plan-Snapshots |
| Verify vor `reached` | ◐ | Kriterienplan + gate_override | | Verify vor `reached` | ◐ | Kriterienplan + gate_override |
| Reopen nach `reached` | ◐ | `active`, Kriterien bleiben AP1.4b | | Reopen nach `reached` | ◐ | `active`, Kriterien bleiben AP1.4b |

View File

@ -19,11 +19,14 @@ function formatDate(value) {
export function GateDesignerNodePanel({ export function GateDesignerNodePanel({
item, item,
graphState,
criteriaProgress, criteriaProgress,
onClose, onClose,
}) { }) {
if (!item) return null if (!item) return null
const graphItem = graphState?.items?.[item.id]
const progress = criteriaProgress?.[item.id] const progress = criteriaProgress?.[item.id]
const closed = progress?.closed ?? 0 const closed = progress?.closed ?? 0
const total = progress?.total ?? 0 const total = progress?.total ?? 0
@ -69,8 +72,20 @@ export function GateDesignerNodePanel({
)} )}
</dl> </dl>
{item.goal_description && ( {item.goal_description && (
<p className="gate-designer-node-panel__goal muted">{item.goal_description}</p> <p className="gate-designer-node-panel__goal muted">{item.goal_description}</p>
)}
{graphItem?.parallel_group_keys?.length > 0 && (
<p className="gate-designer-node-panel__topology muted">
Parallel: {graphItem.parallel_group_keys.join(', ')}
</p>
)}
{graphItem?.optional_prerequisites?.length > 0 && (
<p className="gate-designer-node-panel__topology muted">
Optional: {graphItem.optional_prerequisites.length} Zweig(e) blockieren nicht
</p>
)} )}
<div className="gate-designer-node-panel__actions"> <div className="gate-designer-node-panel__actions">

View File

@ -15,7 +15,7 @@ import {
loadManualGatePositions, loadManualGatePositions,
storeManualGatePositions, storeManualGatePositions,
} from '../plan/gateGraphDesigner.js' } from '../plan/gateGraphDesigner.js'
import { DESIGNER_EDGE_KINDS, DESIGNER_EDGE_LABELS } from '../plan/gateGraphTopology.js' import { DESIGNER_EDGE_KINDS, DESIGNER_EDGE_LABELS, edgeKindNeedsGroupKey, resolveDependencyEndpoints } from '../plan/gateGraphTopology.js'
import { LoadingState } from './LoadingState.jsx' import { LoadingState } from './LoadingState.jsx'
import { ErrorState } from './ErrorState.jsx' import { ErrorState } from './ErrorState.jsx'
import { EmptyState } from './EmptyState.jsx' import { EmptyState } from './EmptyState.jsx'
@ -62,6 +62,7 @@ export function GateGraphDesigner({
busy: busyProp = false, busy: busyProp = false,
onCreate, onCreate,
onGraphChanged, onGraphChanged,
graphState,
}) { }) {
const viewportRef = useRef(null) const viewportRef = useRef(null)
const [dependencies, setDependencies] = useState([]) const [dependencies, setDependencies] = useState([])
@ -73,6 +74,7 @@ export function GateGraphDesigner({
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [busyLocal, setBusyLocal] = useState(false) const [busyLocal, setBusyLocal] = useState(false)
const [edgeType, setEdgeType] = useState('requires') const [edgeType, setEdgeType] = useState('requires')
const [groupKey, setGroupKey] = useState('phase-1')
const [selectedEdgeId, setSelectedEdgeId] = useState(null) const [selectedEdgeId, setSelectedEdgeId] = useState(null)
const [selectedNodeId, setSelectedNodeId] = useState(null) const [selectedNodeId, setSelectedNodeId] = useState(null)
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
@ -150,21 +152,28 @@ export function GateGraphDesigner({
onGraphChanged?.() onGraphChanged?.()
} }
async function handleCreateEdge(fromId, toId) { async function handleCreateEdge(sourceId, targetId) {
if (!fromId || !toId || fromId === toId) return if (!sourceId || !targetId || sourceId === targetId) return
if (edgeKindNeedsGroupKey(edgeType) && !groupKey.trim()) {
setError('Parallelgruppe benötigt einen Schlüssel (group_key).')
return
}
setBusyLocal(true) setBusyLocal(true)
setError(null) setError(null)
try { try {
let fromItemId = fromId const { from_item_id: fromItemId, to_item_id: toItemId } = resolveDependencyEndpoints(
let toItemId = toId edgeType,
if (edgeType === 'requires') { sourceId,
fromItemId = toId targetId,
toItemId = fromId )
} const body = {
await addRoadmapItemDependency(fromItemId, {
to_item_id: toItemId, to_item_id: toItemId,
dependency_type: edgeType, dependency_type: edgeType,
}) }
if (edgeKindNeedsGroupKey(edgeType)) {
body.group_key = groupKey.trim()
}
await addRoadmapItemDependency(fromItemId, body)
await notifyGraphChanged() await notifyGraphChanged()
} catch (err) { } catch (err) {
setError(err?.message || 'Kante konnte nicht angelegt werden.') setError(err?.message || 'Kante konnte nicht angelegt werden.')
@ -448,6 +457,20 @@ export function GateGraphDesigner({
</label> </label>
)} )}
{canManage && edgeKindNeedsGroupKey(edgeType) && (
<label className="gate-graph-designer__edge-type">
<span className="muted">Gruppenschlüssel</span>
<input
type="text"
value={groupKey}
maxLength={128}
disabled={busy}
placeholder="z. B. phase-1"
onChange={(e) => setGroupKey(e.target.value)}
/>
</label>
)}
<div className="gate-graph-designer__zoom-group" role="group" aria-label="Zoom"> <div className="gate-graph-designer__zoom-group" role="group" aria-label="Zoom">
<button <button
type="button" type="button"
@ -493,8 +516,9 @@ export function GateGraphDesigner({
{canManage && ( {canManage && (
<p className="gate-graph-designer__hint muted"> <p className="gate-graph-designer__hint muted">
Vom unteren Punkt eines Gates zur Zielkante ziehen. Doppelklick auf leere Fläche legt ein Vom unteren Punkt ziehen: Voraussetzung/Block/Bezug/Parallel/Optional. Join-Gates
Gate an. Klick auf Gate öffnet das Info-Panel. verbinden mehrere Pflicht-Voraussetzungen (AND). Doppelklick auf leere Fläche legt ein
Gate an.
</p> </p>
)} )}
@ -627,6 +651,7 @@ export function GateGraphDesigner({
{selectedItem && ( {selectedItem && (
<GateDesignerNodePanel <GateDesignerNodePanel
item={selectedItem} item={selectedItem}
graphState={graphState}
criteriaProgress={criteriaProgress} criteriaProgress={criteriaProgress}
onClose={() => setSelectedNodeId(null)} onClose={() => setSelectedNodeId(null)}
/> />

View File

@ -105,6 +105,14 @@ export function GateMapView({ initiativeId, items, graphState: graphStateProp })
<span className="gate-map-view__legend-line gate-map-view__legend-line--related" /> <span className="gate-map-view__legend-line gate-map-view__legend-line--related" />
Bezug Bezug
</span> </span>
<span className="gate-map-view__legend-item">
<span className="gate-map-view__legend-line gate-map-view__legend-line--parallel" />
Parallel
</span>
<span className="gate-map-view__legend-item">
<span className="gate-map-view__legend-line gate-map-view__legend-line--optional" />
Optional
</span>
</div> </div>
<div className="gate-map-view__canvas-wrap"> <div className="gate-map-view__canvas-wrap">

View File

@ -130,6 +130,7 @@ export function GatesPlanPanel({
busy={busy} busy={busy}
onCreate={canManage ? onCreate : undefined} onCreate={canManage ? onCreate : undefined}
onGraphChanged={refreshGraphState} onGraphChanged={refreshGraphState}
graphState={graphState}
/> />
)} )}
</div> </div>

View File

@ -132,7 +132,11 @@ export function computeGateGraphLayout(items, dependencies) {
y: node.y + node.height / 2, y: node.y + node.height / 2,
}) })
if (dep.dependency_type === 'related') { if (
dep.dependency_type === 'related' ||
dep.dependency_type === 'parallel_group' ||
dep.dependency_type === 'optional_branch'
) {
const from = center(sourceNode) const from = center(sourceNode)
const to = center(targetNode) const to = center(targetNode)
return { return {

View File

@ -1,13 +1,19 @@
/** /**
* Kantentypen für Zielzustands-Graph AP1.15c erweitert um Topologie. * Kantentypen für Zielzustands-Graph AP1.15c Topologie.
* Siehe ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md § Join/Branch deferred. * OR-Alternativpfade bewusst nicht enthalten (PO-Deferral).
*/ */
/** Im Designer AP1.15a/b nutzbar */ /** Basis-Kanten im Designer */
export const DESIGNER_EDGE_KINDS = ['requires', 'blocks', 'related'] export const DESIGNER_EDGE_KINDS = [
'requires',
'blocks',
'related',
'parallel_group',
'optional_branch',
]
/** AP1.15c — parallel_group, optional_branch; ggf. OR später */ /** Erfordert group_key bei Anlage */
export const DEFERRED_TOPOLOGY_EDGE_KINDS = ['parallel_group', 'optional_branch'] export const EDGE_KINDS_REQUIRING_GROUP_KEY = ['parallel_group']
export const DESIGNER_EDGE_LABELS = { export const DESIGNER_EDGE_LABELS = {
requires: 'Voraussetzung', requires: 'Voraussetzung',
@ -16,3 +22,21 @@ export const DESIGNER_EDGE_LABELS = {
parallel_group: 'Parallelgruppe', parallel_group: 'Parallelgruppe',
optional_branch: 'Optional', optional_branch: 'Optional',
} }
/** @deprecated use DESIGNER_EDGE_KINDS */
export const DEFERRED_TOPOLOGY_EDGE_KINDS = ['parallel_group', 'optional_branch']
export function edgeKindNeedsGroupKey(kind) {
return EDGE_KINDS_REQUIRING_GROUP_KEY.includes(kind)
}
/**
* Kanten-Richtung beim Anlegen per Drag (Quelle Ziel).
* requires: Ziel hängt von Quelle ab (from=Ziel, to=Quelle).
*/
export function resolveDependencyEndpoints(edgeKind, sourceId, targetId) {
if (edgeKind === 'requires') {
return { from_item_id: targetId, to_item_id: sourceId }
}
return { from_item_id: sourceId, to_item_id: targetId }
}

View File

@ -2,14 +2,33 @@ import { describe, expect, it } from 'vitest'
import { import {
DEFERRED_TOPOLOGY_EDGE_KINDS, DEFERRED_TOPOLOGY_EDGE_KINDS,
DESIGNER_EDGE_KINDS, DESIGNER_EDGE_KINDS,
edgeKindNeedsGroupKey,
resolveDependencyEndpoints,
} from './gateGraphTopology.js' } from './gateGraphTopology.js'
describe('gateGraphTopology', () => { describe('gateGraphTopology', () => {
it('keeps designer and deferred edge kinds separate', () => { it('includes topology kinds in designer', () => {
expect(DESIGNER_EDGE_KINDS).toContain('requires') expect(DESIGNER_EDGE_KINDS).toContain('requires')
expect(DEFERRED_TOPOLOGY_EDGE_KINDS).toContain('parallel_group') expect(DESIGNER_EDGE_KINDS).toContain('parallel_group')
expect(DESIGNER_EDGE_KINDS).toContain('optional_branch')
for (const kind of DEFERRED_TOPOLOGY_EDGE_KINDS) { for (const kind of DEFERRED_TOPOLOGY_EDGE_KINDS) {
expect(DESIGNER_EDGE_KINDS).not.toContain(kind) expect(DESIGNER_EDGE_KINDS).toContain(kind)
} }
}) })
it('requires group_key for parallel_group', () => {
expect(edgeKindNeedsGroupKey('parallel_group')).toBe(true)
expect(edgeKindNeedsGroupKey('requires')).toBe(false)
})
it('resolves requires edge direction for join semantics', () => {
expect(resolveDependencyEndpoints('requires', 'a', 'b')).toEqual({
from_item_id: 'b',
to_item_id: 'a',
})
expect(resolveDependencyEndpoints('optional_branch', 'a', 'b')).toEqual({
from_item_id: 'a',
to_item_id: 'b',
})
})
}) })

View File

@ -1697,6 +1697,16 @@
border-top-style: dashed; border-top-style: dashed;
} }
.gate-map-view__legend-line--parallel {
border-top-color: #7c3aed;
border-top-style: dashed;
}
.gate-map-view__legend-line--optional {
border-top-color: #059669;
border-top-style: dashed;
}
.gate-map-view__canvas-wrap { .gate-map-view__canvas-wrap {
overflow: auto; overflow: auto;
border: 1px solid var(--jk-border, #dde3ea); border: 1px solid var(--jk-border, #dde3ea);
@ -1727,6 +1737,16 @@
stroke-dasharray: 6 4; stroke-dasharray: 6 4;
} }
.gate-map-view__edge--parallel_group {
stroke: #7c3aed;
stroke-dasharray: 4 3;
}
.gate-map-view__edge--optional_branch {
stroke: #059669;
stroke-dasharray: 8 4;
}
.gate-map-view__arrow--requires { .gate-map-view__arrow--requires {
fill: #2563eb; fill: #2563eb;
} }
@ -1963,6 +1983,11 @@
font-size: 0.875rem; font-size: 0.875rem;
} }
.gate-designer-node-panel__topology {
margin: 0 0 0.75rem;
font-size: 0.8125rem;
}
.gate-designer-node-panel__actions { .gate-designer-node-panel__actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;