From 85291cf5de36056a62c53f080ac4d13536def2ca Mon Sep 17 00:00:00 2001 From: Varun Date: Thu, 2 Apr 2026 13:57:25 +0530 Subject: [PATCH 1/9] feat: enrich mermaid imports with grounded icons --- .../processed/Analytics/databricks.svg | 1 + .../developer/processed/Analytics/flink.svg | 1 + .../developer/processed/Analytics/hadoop.svg | 1 + .../developer/processed/Analytics/hive.svg | 1 + .../developer/processed/Analytics/solr.svg | 1 + .../developer/processed/Analytics/spark.svg | 1 + .../processed/Analytics/superset.svg | 1 + .../developer/processed/Analytics/trino.svg | 1 + .../developer/processed/Backend/dapr.svg | 1 + .../developer/processed/Backend/dotnet.svg | 1 + .../developer/processed/Backend/phoenix.svg | 1 + .../developer/processed/Backend/quarkus.svg | 1 + .../processed/Container/containerd.svg | 1 + .../developer/processed/Container/k3s.svg | 1 + .../developer/processed/Container/lxc.svg | 1 + .../processed/Database/cockroachdb.svg | 1 + .../processed/Database/couchbase.svg | 1 + .../developer/processed/Database/couchdb.svg | 1 + .../developer/processed/Database/druid.svg | 1 + .../developer/processed/Database/duckdb.svg | 1 + .../developer/processed/Database/influxdb.svg | 1 + .../developer/processed/Database/neo4j.svg | 1 + .../developer/processed/Database/scylladb.svg | 1 + .../processed/DevOps-AI-ML/airflow.svg | 1 + .../processed/DevOps-AI-ML/ansible.svg | 1 + .../processed/DevOps-AI-ML/argocd.svg | 1 + .../processed/DevOps-AI-ML/bentoml.svg | 1 + .../developer/processed/DevOps-AI-ML/ceph.svg | 1 + .../developer/processed/DevOps-AI-ML/chef.svg | 1 + .../processed/DevOps-AI-ML/droneci.svg | 1 + .../developer/processed/DevOps-AI-ML/flux.svg | 1 + .../processed/DevOps-AI-ML/harbor.svg | 1 + .../processed/DevOps-AI-ML/jfrog.svg | 1 + .../processed/DevOps-AI-ML/keycloak.svg | 1 + .../processed/DevOps-AI-ML/minio.svg | 1 + .../processed/DevOps-AI-ML/mlflow.svg | 1 + .../processed/DevOps-AI-ML/nomad.svg | 1 + .../processed/DevOps-AI-ML/oauth2.svg | 1 + .../processed/DevOps-AI-ML/prefect.svg | 1 + .../processed/DevOps-AI-ML/puppet.svg | 1 + .../processed/DevOps-AI-ML/teamcity.svg | 1 + .../processed/DevOps-AI-ML/temporal.svg | 1 + .../processed/DevOps-AI-ML/travisci.svg | 1 + .../processed/DevOps-AI-ML/vault.svg | 1 + .../processed/DevOps-AI-ML/wandb.svg | 1 + .../developer/processed/GCP/analytics.svg | 1 + .../developer/processed/GCP/bigquery.svg | 1 + .../processed/GCP/cloud-composer.svg | 1 + .../developer/processed/GCP/cloud-spanner.svg | 1 + .../developer/processed/GCP/cloud-storage.svg | 1 + .../developer/processed/GCP/colab.svg | 1 + .../developer/processed/GCP/dataflow.svg | 1 + .../developer/processed/GCP/dataproc.svg | 1 + .../developer/processed/GCP/docs.svg | 1 + .../developer/processed/GCP/drive.svg | 1 + .../developer/processed/GCP/gemini.svg | 1 + .../developer/processed/GCP/google-ads.svg | 1 + .../developer/processed/GCP/google-cloud.svg | 1 + .../developer/processed/GCP/looker.svg | 1 + .../developer/processed/GCP/maps.svg | 1 + .../developer/processed/GCP/pubsub.svg | 1 + .../developer/processed/GCP/sheets.svg | 1 + .../developer/processed/GCP/slides.svg | 1 + .../developer/processed/Infra/caddy.svg | 1 + .../developer/processed/Infra/consul.svg | 1 + .../developer/processed/Infra/envoy.svg | 1 + .../developer/processed/Infra/etcd.svg | 1 + .../developer/processed/Infra/gunicorn.svg | 1 + .../developer/processed/Infra/istio.svg | 1 + .../developer/processed/Infra/kong.svg | 1 + .../developer/processed/Infra/letsencrypt.svg | 1 + .../developer/processed/Infra/linkerd.svg | 1 + .../developer/processed/Infra/nginx.svg | 1 + .../developer/processed/Infra/tomcat.svg | 1 + .../developer/processed/Infra/traefik.svg | 1 + .../developer/processed/Languages/cpp.svg | 1 + .../developer/processed/Languages/dart.svg | 1 + .../developer/processed/Languages/latex.svg | 1 + .../developer/processed/Languages/lua.svg | 1 + .../developer/processed/Languages/zig.svg | 1 + .../developer/processed/Logging/fluentbit.svg | 1 + .../developer/processed/Logging/fluentd.svg | 1 + .../developer/processed/Logging/graylog.svg | 1 + .../processed/Monitoring/datadog.svg | 1 + .../processed/Monitoring/dynatrace.svg | 1 + .../developer/processed/Monitoring/jaeger.svg | 1 + .../processed/Monitoring/newrelic.svg | 1 + .../processed/Monitoring/prometheus.svg | 1 + .../developer/processed/Monitoring/sentry.svg | 1 + .../developer/processed/Monitoring/splunk.svg | 1 + .../developer/processed/Queue/celery.svg | 1 + .../developer/processed/Queue/nats.svg | 1 + .../developer/processed/Queue/rabbitmq.svg | 1 + .../developer-icons-v1.manifest.json | 932 +++++++++++++++++- .../{google-cloud => gcp}/SOURCE.md | 0 .../{google-cloud => gcp}/processed/.gitkeep | 0 .../gcp/processed/Core/analytics.svg | 1 + .../gcp/processed/Core/bigquery.svg | 1 + .../gcp/processed/Core/cloud-composer.svg | 1 + .../gcp/processed/Core/cloud-spanner.svg | 1 + .../gcp/processed/Core/cloud-storage.svg | 1 + .../gcp/processed/Core/colab.svg | 1 + .../gcp/processed/Core/dataflow.svg | 1 + .../gcp/processed/Core/dataproc.svg | 1 + .../gcp/processed/Core/docs.svg | 1 + .../gcp/processed/Core/drive.svg | 1 + .../gcp/processed/Core/gemini.svg | 1 + .../gcp/processed/Core/google-ads.svg | 1 + .../gcp/processed/Core/google-cloud.svg | 1 + .../gcp/processed/Core/looker.svg | 1 + .../gcp/processed/Core/maps.svg | 1 + .../gcp/processed/Core/pubsub.svg | 1 + .../gcp/processed/Core/sheets.svg | 1 + .../gcp/processed/Core/slides.svg | 1 + .../{google-cloud => gcp}/raw/.gitkeep | 0 scripts/shape-pack/add-missing-devicons.mjs | 221 +++++ .../command-bar/importViewModel.test.ts | 10 +- src/components/command-bar/importViewModel.ts | 25 +- .../flow-canvas/flowCanvasTypes.test.ts | 1 + .../flow-canvas/flowCanvasTypes.tsx | 2 + .../flow-canvas/useFlowCanvasPaste.ts | 285 +++--- src/config/rolloutFlags.ts | 130 ++- src/hooks/ai-generation/requestLifecycle.ts | 82 +- src/hooks/useFlowEditorCallbacks.ts | 310 +++--- src/lib/iconResolver.test.ts | 75 ++ src/lib/iconResolver.ts | 425 ++++++++ src/lib/mermaidEnrichmentPipeline.test.ts | 189 ++++ src/lib/mermaidParser.ts | 546 +++++----- src/lib/mermaidParserHelpers.ts | 473 +++++---- src/lib/nodeEnricher.test.ts | 156 +++ src/lib/nodeEnricher.ts | 118 +++ src/lib/semanticClassifier.test.ts | 92 ++ src/lib/semanticClassifier.ts | 274 +++++ src/lib/types.ts | 26 +- src/services/export/formatting.ts | 4 + .../export/mermaid/architectureMermaid.ts | 4 +- .../export/mermaid/stateDiagramMermaid.ts | 10 +- src/services/export/mermaidBuilder.ts | 4 +- src/services/export/plantumlBuilder.ts | 4 +- src/services/flowpilot/assetGrounding.ts | 142 ++- src/services/geminiSystemInstruction.ts | 271 +---- .../mermaid/detectDiagramType.test.ts | 3 +- src/services/mermaid/detectDiagramType.ts | 3 +- .../mermaid/parseMermaidByType.test.ts | 71 +- src/services/mermaid/parseMermaidByType.ts | 31 +- src/services/mermaidParser.test.ts | 514 +++++----- src/services/shapeLibrary/providerCatalog.ts | 31 +- tsconfig.tsbuildinfo | 2 +- 148 files changed, 4234 insertions(+), 1343 deletions(-) create mode 100644 assets/third-party-icons/developer/processed/Analytics/databricks.svg create mode 100644 assets/third-party-icons/developer/processed/Analytics/flink.svg create mode 100644 assets/third-party-icons/developer/processed/Analytics/hadoop.svg create mode 100644 assets/third-party-icons/developer/processed/Analytics/hive.svg create mode 100644 assets/third-party-icons/developer/processed/Analytics/solr.svg create mode 100644 assets/third-party-icons/developer/processed/Analytics/spark.svg create mode 100644 assets/third-party-icons/developer/processed/Analytics/superset.svg create mode 100644 assets/third-party-icons/developer/processed/Analytics/trino.svg create mode 100644 assets/third-party-icons/developer/processed/Backend/dapr.svg create mode 100644 assets/third-party-icons/developer/processed/Backend/dotnet.svg create mode 100644 assets/third-party-icons/developer/processed/Backend/phoenix.svg create mode 100644 assets/third-party-icons/developer/processed/Backend/quarkus.svg create mode 100644 assets/third-party-icons/developer/processed/Container/containerd.svg create mode 100644 assets/third-party-icons/developer/processed/Container/k3s.svg create mode 100644 assets/third-party-icons/developer/processed/Container/lxc.svg create mode 100644 assets/third-party-icons/developer/processed/Database/cockroachdb.svg create mode 100644 assets/third-party-icons/developer/processed/Database/couchbase.svg create mode 100644 assets/third-party-icons/developer/processed/Database/couchdb.svg create mode 100644 assets/third-party-icons/developer/processed/Database/druid.svg create mode 100644 assets/third-party-icons/developer/processed/Database/duckdb.svg create mode 100644 assets/third-party-icons/developer/processed/Database/influxdb.svg create mode 100644 assets/third-party-icons/developer/processed/Database/neo4j.svg create mode 100644 assets/third-party-icons/developer/processed/Database/scylladb.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/airflow.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/ansible.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/argocd.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/bentoml.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/ceph.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/chef.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/droneci.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/flux.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/harbor.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/jfrog.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/keycloak.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/minio.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/mlflow.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/nomad.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/oauth2.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/prefect.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/puppet.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/teamcity.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/temporal.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/travisci.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/vault.svg create mode 100644 assets/third-party-icons/developer/processed/DevOps-AI-ML/wandb.svg create mode 100644 assets/third-party-icons/developer/processed/GCP/analytics.svg create mode 100644 assets/third-party-icons/developer/processed/GCP/bigquery.svg create mode 100644 assets/third-party-icons/developer/processed/GCP/cloud-composer.svg create mode 100644 assets/third-party-icons/developer/processed/GCP/cloud-spanner.svg create mode 100644 assets/third-party-icons/developer/processed/GCP/cloud-storage.svg create mode 100644 assets/third-party-icons/developer/processed/GCP/colab.svg create mode 100644 assets/third-party-icons/developer/processed/GCP/dataflow.svg create mode 100644 assets/third-party-icons/developer/processed/GCP/dataproc.svg create mode 100644 assets/third-party-icons/developer/processed/GCP/docs.svg create mode 100644 assets/third-party-icons/developer/processed/GCP/drive.svg create mode 100644 assets/third-party-icons/developer/processed/GCP/gemini.svg create mode 100644 assets/third-party-icons/developer/processed/GCP/google-ads.svg create mode 100644 assets/third-party-icons/developer/processed/GCP/google-cloud.svg create mode 100644 assets/third-party-icons/developer/processed/GCP/looker.svg create mode 100644 assets/third-party-icons/developer/processed/GCP/maps.svg create mode 100644 assets/third-party-icons/developer/processed/GCP/pubsub.svg create mode 100644 assets/third-party-icons/developer/processed/GCP/sheets.svg create mode 100644 assets/third-party-icons/developer/processed/GCP/slides.svg create mode 100644 assets/third-party-icons/developer/processed/Infra/caddy.svg create mode 100644 assets/third-party-icons/developer/processed/Infra/consul.svg create mode 100644 assets/third-party-icons/developer/processed/Infra/envoy.svg create mode 100644 assets/third-party-icons/developer/processed/Infra/etcd.svg create mode 100644 assets/third-party-icons/developer/processed/Infra/gunicorn.svg create mode 100644 assets/third-party-icons/developer/processed/Infra/istio.svg create mode 100644 assets/third-party-icons/developer/processed/Infra/kong.svg create mode 100644 assets/third-party-icons/developer/processed/Infra/letsencrypt.svg create mode 100644 assets/third-party-icons/developer/processed/Infra/linkerd.svg create mode 100644 assets/third-party-icons/developer/processed/Infra/nginx.svg create mode 100644 assets/third-party-icons/developer/processed/Infra/tomcat.svg create mode 100644 assets/third-party-icons/developer/processed/Infra/traefik.svg create mode 100644 assets/third-party-icons/developer/processed/Languages/cpp.svg create mode 100644 assets/third-party-icons/developer/processed/Languages/dart.svg create mode 100644 assets/third-party-icons/developer/processed/Languages/latex.svg create mode 100644 assets/third-party-icons/developer/processed/Languages/lua.svg create mode 100644 assets/third-party-icons/developer/processed/Languages/zig.svg create mode 100644 assets/third-party-icons/developer/processed/Logging/fluentbit.svg create mode 100644 assets/third-party-icons/developer/processed/Logging/fluentd.svg create mode 100644 assets/third-party-icons/developer/processed/Logging/graylog.svg create mode 100644 assets/third-party-icons/developer/processed/Monitoring/datadog.svg create mode 100644 assets/third-party-icons/developer/processed/Monitoring/dynatrace.svg create mode 100644 assets/third-party-icons/developer/processed/Monitoring/jaeger.svg create mode 100644 assets/third-party-icons/developer/processed/Monitoring/newrelic.svg create mode 100644 assets/third-party-icons/developer/processed/Monitoring/prometheus.svg create mode 100644 assets/third-party-icons/developer/processed/Monitoring/sentry.svg create mode 100644 assets/third-party-icons/developer/processed/Monitoring/splunk.svg create mode 100644 assets/third-party-icons/developer/processed/Queue/celery.svg create mode 100644 assets/third-party-icons/developer/processed/Queue/nats.svg create mode 100644 assets/third-party-icons/developer/processed/Queue/rabbitmq.svg rename assets/third-party-icons/{google-cloud => gcp}/SOURCE.md (100%) rename assets/third-party-icons/{google-cloud => gcp}/processed/.gitkeep (100%) create mode 100644 assets/third-party-icons/gcp/processed/Core/analytics.svg create mode 100644 assets/third-party-icons/gcp/processed/Core/bigquery.svg create mode 100644 assets/third-party-icons/gcp/processed/Core/cloud-composer.svg create mode 100644 assets/third-party-icons/gcp/processed/Core/cloud-spanner.svg create mode 100644 assets/third-party-icons/gcp/processed/Core/cloud-storage.svg create mode 100644 assets/third-party-icons/gcp/processed/Core/colab.svg create mode 100644 assets/third-party-icons/gcp/processed/Core/dataflow.svg create mode 100644 assets/third-party-icons/gcp/processed/Core/dataproc.svg create mode 100644 assets/third-party-icons/gcp/processed/Core/docs.svg create mode 100644 assets/third-party-icons/gcp/processed/Core/drive.svg create mode 100644 assets/third-party-icons/gcp/processed/Core/gemini.svg create mode 100644 assets/third-party-icons/gcp/processed/Core/google-ads.svg create mode 100644 assets/third-party-icons/gcp/processed/Core/google-cloud.svg create mode 100644 assets/third-party-icons/gcp/processed/Core/looker.svg create mode 100644 assets/third-party-icons/gcp/processed/Core/maps.svg create mode 100644 assets/third-party-icons/gcp/processed/Core/pubsub.svg create mode 100644 assets/third-party-icons/gcp/processed/Core/sheets.svg create mode 100644 assets/third-party-icons/gcp/processed/Core/slides.svg rename assets/third-party-icons/{google-cloud => gcp}/raw/.gitkeep (100%) create mode 100644 scripts/shape-pack/add-missing-devicons.mjs create mode 100644 src/lib/iconResolver.test.ts create mode 100644 src/lib/iconResolver.ts create mode 100644 src/lib/mermaidEnrichmentPipeline.test.ts create mode 100644 src/lib/nodeEnricher.test.ts create mode 100644 src/lib/nodeEnricher.ts create mode 100644 src/lib/semanticClassifier.test.ts create mode 100644 src/lib/semanticClassifier.ts diff --git a/assets/third-party-icons/developer/processed/Analytics/databricks.svg b/assets/third-party-icons/developer/processed/Analytics/databricks.svg new file mode 100644 index 00000000..d67b1705 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Analytics/databricks.svg @@ -0,0 +1 @@ +Databricks \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Analytics/flink.svg b/assets/third-party-icons/developer/processed/Analytics/flink.svg new file mode 100644 index 00000000..254b43f2 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Analytics/flink.svg @@ -0,0 +1 @@ +Apache Flink \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Analytics/hadoop.svg b/assets/third-party-icons/developer/processed/Analytics/hadoop.svg new file mode 100644 index 00000000..a03f6844 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Analytics/hadoop.svg @@ -0,0 +1 @@ +Apache Hadoop \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Analytics/hive.svg b/assets/third-party-icons/developer/processed/Analytics/hive.svg new file mode 100644 index 00000000..a1b23393 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Analytics/hive.svg @@ -0,0 +1 @@ +Apache Hive \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Analytics/solr.svg b/assets/third-party-icons/developer/processed/Analytics/solr.svg new file mode 100644 index 00000000..75600aaa --- /dev/null +++ b/assets/third-party-icons/developer/processed/Analytics/solr.svg @@ -0,0 +1 @@ +Apache Solr \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Analytics/spark.svg b/assets/third-party-icons/developer/processed/Analytics/spark.svg new file mode 100644 index 00000000..106fc42e --- /dev/null +++ b/assets/third-party-icons/developer/processed/Analytics/spark.svg @@ -0,0 +1 @@ +Apache Spark \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Analytics/superset.svg b/assets/third-party-icons/developer/processed/Analytics/superset.svg new file mode 100644 index 00000000..735b4b42 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Analytics/superset.svg @@ -0,0 +1 @@ +Apache Superset \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Analytics/trino.svg b/assets/third-party-icons/developer/processed/Analytics/trino.svg new file mode 100644 index 00000000..2af274c8 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Analytics/trino.svg @@ -0,0 +1 @@ +Trino \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Backend/dapr.svg b/assets/third-party-icons/developer/processed/Backend/dapr.svg new file mode 100644 index 00000000..7ea3d977 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Backend/dapr.svg @@ -0,0 +1 @@ +Dapr \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Backend/dotnet.svg b/assets/third-party-icons/developer/processed/Backend/dotnet.svg new file mode 100644 index 00000000..c4b482ec --- /dev/null +++ b/assets/third-party-icons/developer/processed/Backend/dotnet.svg @@ -0,0 +1 @@ +.NET \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Backend/phoenix.svg b/assets/third-party-icons/developer/processed/Backend/phoenix.svg new file mode 100644 index 00000000..e216b465 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Backend/phoenix.svg @@ -0,0 +1 @@ +Phoenix Framework \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Backend/quarkus.svg b/assets/third-party-icons/developer/processed/Backend/quarkus.svg new file mode 100644 index 00000000..892f6f44 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Backend/quarkus.svg @@ -0,0 +1 @@ +Quarkus \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Container/containerd.svg b/assets/third-party-icons/developer/processed/Container/containerd.svg new file mode 100644 index 00000000..6e87c072 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Container/containerd.svg @@ -0,0 +1 @@ +containerd \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Container/k3s.svg b/assets/third-party-icons/developer/processed/Container/k3s.svg new file mode 100644 index 00000000..c4dbcbe3 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Container/k3s.svg @@ -0,0 +1 @@ +K3s \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Container/lxc.svg b/assets/third-party-icons/developer/processed/Container/lxc.svg new file mode 100644 index 00000000..08d405b4 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Container/lxc.svg @@ -0,0 +1 @@ +Linux Containers \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Database/cockroachdb.svg b/assets/third-party-icons/developer/processed/Database/cockroachdb.svg new file mode 100644 index 00000000..6929bf3f --- /dev/null +++ b/assets/third-party-icons/developer/processed/Database/cockroachdb.svg @@ -0,0 +1 @@ +Cockroach Labs \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Database/couchbase.svg b/assets/third-party-icons/developer/processed/Database/couchbase.svg new file mode 100644 index 00000000..6e57b7e3 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Database/couchbase.svg @@ -0,0 +1 @@ +Couchbase \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Database/couchdb.svg b/assets/third-party-icons/developer/processed/Database/couchdb.svg new file mode 100644 index 00000000..0f6297a9 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Database/couchdb.svg @@ -0,0 +1 @@ +Apache CouchDB \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Database/druid.svg b/assets/third-party-icons/developer/processed/Database/druid.svg new file mode 100644 index 00000000..a3af5cba --- /dev/null +++ b/assets/third-party-icons/developer/processed/Database/druid.svg @@ -0,0 +1 @@ +Apache Druid \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Database/duckdb.svg b/assets/third-party-icons/developer/processed/Database/duckdb.svg new file mode 100644 index 00000000..ac31e6f9 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Database/duckdb.svg @@ -0,0 +1 @@ +DuckDB \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Database/influxdb.svg b/assets/third-party-icons/developer/processed/Database/influxdb.svg new file mode 100644 index 00000000..9ad681b4 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Database/influxdb.svg @@ -0,0 +1 @@ +InfluxDB \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Database/neo4j.svg b/assets/third-party-icons/developer/processed/Database/neo4j.svg new file mode 100644 index 00000000..b4194ce8 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Database/neo4j.svg @@ -0,0 +1 @@ +Neo4j \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Database/scylladb.svg b/assets/third-party-icons/developer/processed/Database/scylladb.svg new file mode 100644 index 00000000..f6da258d --- /dev/null +++ b/assets/third-party-icons/developer/processed/Database/scylladb.svg @@ -0,0 +1 @@ +ScyllaDB \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/airflow.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/airflow.svg new file mode 100644 index 00000000..fd6c19c2 --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/airflow.svg @@ -0,0 +1 @@ +Apache Airflow \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/ansible.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/ansible.svg new file mode 100644 index 00000000..2a121a2a --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/ansible.svg @@ -0,0 +1 @@ +Ansible \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/argocd.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/argocd.svg new file mode 100644 index 00000000..34239b81 --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/argocd.svg @@ -0,0 +1 @@ +Argo \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/bentoml.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/bentoml.svg new file mode 100644 index 00000000..232cc2fb --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/bentoml.svg @@ -0,0 +1 @@ +BentoML \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/ceph.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/ceph.svg new file mode 100644 index 00000000..36810803 --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/ceph.svg @@ -0,0 +1 @@ +Ceph \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/chef.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/chef.svg new file mode 100644 index 00000000..f0fd1b8d --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/chef.svg @@ -0,0 +1 @@ +Chef \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/droneci.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/droneci.svg new file mode 100644 index 00000000..47b8f613 --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/droneci.svg @@ -0,0 +1 @@ +Drone \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/flux.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/flux.svg new file mode 100644 index 00000000..7ba68c5e --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/flux.svg @@ -0,0 +1 @@ +Flux \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/harbor.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/harbor.svg new file mode 100644 index 00000000..8dc9fa9d --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/harbor.svg @@ -0,0 +1 @@ +Harbor \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/jfrog.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/jfrog.svg new file mode 100644 index 00000000..1f7aae01 --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/jfrog.svg @@ -0,0 +1 @@ +JFrog \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/keycloak.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/keycloak.svg new file mode 100644 index 00000000..a2c6a28d --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/keycloak.svg @@ -0,0 +1 @@ +Keycloak \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/minio.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/minio.svg new file mode 100644 index 00000000..12f8ffdd --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/minio.svg @@ -0,0 +1 @@ +MinIO \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/mlflow.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/mlflow.svg new file mode 100644 index 00000000..8d1b11f4 --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/mlflow.svg @@ -0,0 +1 @@ +MLflow \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/nomad.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/nomad.svg new file mode 100644 index 00000000..25dae0d2 --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/nomad.svg @@ -0,0 +1 @@ +Nomad \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/oauth2.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/oauth2.svg new file mode 100644 index 00000000..32a6dc3f --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/oauth2.svg @@ -0,0 +1 @@ +Auth0 \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/prefect.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/prefect.svg new file mode 100644 index 00000000..718c735a --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/prefect.svg @@ -0,0 +1 @@ +Prefect \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/puppet.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/puppet.svg new file mode 100644 index 00000000..313bf0d9 --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/puppet.svg @@ -0,0 +1 @@ +Puppet \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/teamcity.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/teamcity.svg new file mode 100644 index 00000000..3a481926 --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/teamcity.svg @@ -0,0 +1 @@ +TeamCity \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/temporal.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/temporal.svg new file mode 100644 index 00000000..f815f900 --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/temporal.svg @@ -0,0 +1 @@ +Temporal \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/travisci.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/travisci.svg new file mode 100644 index 00000000..723a1b3b --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/travisci.svg @@ -0,0 +1 @@ +Travis CI \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/vault.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/vault.svg new file mode 100644 index 00000000..adfcbdb0 --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/vault.svg @@ -0,0 +1 @@ +Vault \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/DevOps-AI-ML/wandb.svg b/assets/third-party-icons/developer/processed/DevOps-AI-ML/wandb.svg new file mode 100644 index 00000000..b2f5d554 --- /dev/null +++ b/assets/third-party-icons/developer/processed/DevOps-AI-ML/wandb.svg @@ -0,0 +1 @@ +Weights & Biases \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/GCP/analytics.svg b/assets/third-party-icons/developer/processed/GCP/analytics.svg new file mode 100644 index 00000000..8b68c100 --- /dev/null +++ b/assets/third-party-icons/developer/processed/GCP/analytics.svg @@ -0,0 +1 @@ +Google Analytics \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/GCP/bigquery.svg b/assets/third-party-icons/developer/processed/GCP/bigquery.svg new file mode 100644 index 00000000..aa40433b --- /dev/null +++ b/assets/third-party-icons/developer/processed/GCP/bigquery.svg @@ -0,0 +1 @@ +Google BigQuery \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/GCP/cloud-composer.svg b/assets/third-party-icons/developer/processed/GCP/cloud-composer.svg new file mode 100644 index 00000000..4ff2211e --- /dev/null +++ b/assets/third-party-icons/developer/processed/GCP/cloud-composer.svg @@ -0,0 +1 @@ +Google Cloud Composer \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/GCP/cloud-spanner.svg b/assets/third-party-icons/developer/processed/GCP/cloud-spanner.svg new file mode 100644 index 00000000..d3ed6610 --- /dev/null +++ b/assets/third-party-icons/developer/processed/GCP/cloud-spanner.svg @@ -0,0 +1 @@ +Google Cloud Spanner \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/GCP/cloud-storage.svg b/assets/third-party-icons/developer/processed/GCP/cloud-storage.svg new file mode 100644 index 00000000..90978b76 --- /dev/null +++ b/assets/third-party-icons/developer/processed/GCP/cloud-storage.svg @@ -0,0 +1 @@ +Google Cloud Storage \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/GCP/colab.svg b/assets/third-party-icons/developer/processed/GCP/colab.svg new file mode 100644 index 00000000..434396ea --- /dev/null +++ b/assets/third-party-icons/developer/processed/GCP/colab.svg @@ -0,0 +1 @@ +Google Colab \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/GCP/dataflow.svg b/assets/third-party-icons/developer/processed/GCP/dataflow.svg new file mode 100644 index 00000000..46b1e5f7 --- /dev/null +++ b/assets/third-party-icons/developer/processed/GCP/dataflow.svg @@ -0,0 +1 @@ +Google Dataflow \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/GCP/dataproc.svg b/assets/third-party-icons/developer/processed/GCP/dataproc.svg new file mode 100644 index 00000000..1ec18f35 --- /dev/null +++ b/assets/third-party-icons/developer/processed/GCP/dataproc.svg @@ -0,0 +1 @@ +Google Dataproc \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/GCP/docs.svg b/assets/third-party-icons/developer/processed/GCP/docs.svg new file mode 100644 index 00000000..12077819 --- /dev/null +++ b/assets/third-party-icons/developer/processed/GCP/docs.svg @@ -0,0 +1 @@ +Google Docs \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/GCP/drive.svg b/assets/third-party-icons/developer/processed/GCP/drive.svg new file mode 100644 index 00000000..7263ef31 --- /dev/null +++ b/assets/third-party-icons/developer/processed/GCP/drive.svg @@ -0,0 +1 @@ +Google Drive \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/GCP/gemini.svg b/assets/third-party-icons/developer/processed/GCP/gemini.svg new file mode 100644 index 00000000..e15e53ce --- /dev/null +++ b/assets/third-party-icons/developer/processed/GCP/gemini.svg @@ -0,0 +1 @@ +Google Gemini \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/GCP/google-ads.svg b/assets/third-party-icons/developer/processed/GCP/google-ads.svg new file mode 100644 index 00000000..d0ad5413 --- /dev/null +++ b/assets/third-party-icons/developer/processed/GCP/google-ads.svg @@ -0,0 +1 @@ +Google Ads \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/GCP/google-cloud.svg b/assets/third-party-icons/developer/processed/GCP/google-cloud.svg new file mode 100644 index 00000000..9c069578 --- /dev/null +++ b/assets/third-party-icons/developer/processed/GCP/google-cloud.svg @@ -0,0 +1 @@ +Google Cloud \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/GCP/looker.svg b/assets/third-party-icons/developer/processed/GCP/looker.svg new file mode 100644 index 00000000..31db1e5c --- /dev/null +++ b/assets/third-party-icons/developer/processed/GCP/looker.svg @@ -0,0 +1 @@ +Looker \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/GCP/maps.svg b/assets/third-party-icons/developer/processed/GCP/maps.svg new file mode 100644 index 00000000..2c928cd7 --- /dev/null +++ b/assets/third-party-icons/developer/processed/GCP/maps.svg @@ -0,0 +1 @@ +Google Maps \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/GCP/pubsub.svg b/assets/third-party-icons/developer/processed/GCP/pubsub.svg new file mode 100644 index 00000000..1e39cc62 --- /dev/null +++ b/assets/third-party-icons/developer/processed/GCP/pubsub.svg @@ -0,0 +1 @@ +Google Pub/Sub \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/GCP/sheets.svg b/assets/third-party-icons/developer/processed/GCP/sheets.svg new file mode 100644 index 00000000..3dce7189 --- /dev/null +++ b/assets/third-party-icons/developer/processed/GCP/sheets.svg @@ -0,0 +1 @@ +Google Sheets \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/GCP/slides.svg b/assets/third-party-icons/developer/processed/GCP/slides.svg new file mode 100644 index 00000000..398312b2 --- /dev/null +++ b/assets/third-party-icons/developer/processed/GCP/slides.svg @@ -0,0 +1 @@ +Google Slides \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Infra/caddy.svg b/assets/third-party-icons/developer/processed/Infra/caddy.svg new file mode 100644 index 00000000..c9cd0a5e --- /dev/null +++ b/assets/third-party-icons/developer/processed/Infra/caddy.svg @@ -0,0 +1 @@ +Caddy \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Infra/consul.svg b/assets/third-party-icons/developer/processed/Infra/consul.svg new file mode 100644 index 00000000..9d494e06 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Infra/consul.svg @@ -0,0 +1 @@ +Consul \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Infra/envoy.svg b/assets/third-party-icons/developer/processed/Infra/envoy.svg new file mode 100644 index 00000000..c60a821d --- /dev/null +++ b/assets/third-party-icons/developer/processed/Infra/envoy.svg @@ -0,0 +1 @@ +Envoy Proxy \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Infra/etcd.svg b/assets/third-party-icons/developer/processed/Infra/etcd.svg new file mode 100644 index 00000000..cb9f3cee --- /dev/null +++ b/assets/third-party-icons/developer/processed/Infra/etcd.svg @@ -0,0 +1 @@ +etcd \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Infra/gunicorn.svg b/assets/third-party-icons/developer/processed/Infra/gunicorn.svg new file mode 100644 index 00000000..11396b80 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Infra/gunicorn.svg @@ -0,0 +1 @@ +Gunicorn \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Infra/istio.svg b/assets/third-party-icons/developer/processed/Infra/istio.svg new file mode 100644 index 00000000..580b9fd5 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Infra/istio.svg @@ -0,0 +1 @@ +Istio \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Infra/kong.svg b/assets/third-party-icons/developer/processed/Infra/kong.svg new file mode 100644 index 00000000..d323c3a4 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Infra/kong.svg @@ -0,0 +1 @@ +Kong \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Infra/letsencrypt.svg b/assets/third-party-icons/developer/processed/Infra/letsencrypt.svg new file mode 100644 index 00000000..c4bdcf6d --- /dev/null +++ b/assets/third-party-icons/developer/processed/Infra/letsencrypt.svg @@ -0,0 +1 @@ +Let's Encrypt \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Infra/linkerd.svg b/assets/third-party-icons/developer/processed/Infra/linkerd.svg new file mode 100644 index 00000000..512d0d82 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Infra/linkerd.svg @@ -0,0 +1 @@ +Linkerd \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Infra/nginx.svg b/assets/third-party-icons/developer/processed/Infra/nginx.svg new file mode 100644 index 00000000..e52ea9fe --- /dev/null +++ b/assets/third-party-icons/developer/processed/Infra/nginx.svg @@ -0,0 +1 @@ +NGINX \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Infra/tomcat.svg b/assets/third-party-icons/developer/processed/Infra/tomcat.svg new file mode 100644 index 00000000..4de01fca --- /dev/null +++ b/assets/third-party-icons/developer/processed/Infra/tomcat.svg @@ -0,0 +1 @@ +Apache Tomcat \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Infra/traefik.svg b/assets/third-party-icons/developer/processed/Infra/traefik.svg new file mode 100644 index 00000000..0135fda5 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Infra/traefik.svg @@ -0,0 +1 @@ +Traefik Proxy \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Languages/cpp.svg b/assets/third-party-icons/developer/processed/Languages/cpp.svg new file mode 100644 index 00000000..3d1b49e2 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Languages/cpp.svg @@ -0,0 +1 @@ +C++ \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Languages/dart.svg b/assets/third-party-icons/developer/processed/Languages/dart.svg new file mode 100644 index 00000000..990da555 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Languages/dart.svg @@ -0,0 +1 @@ +Dart \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Languages/latex.svg b/assets/third-party-icons/developer/processed/Languages/latex.svg new file mode 100644 index 00000000..5bab7a8b --- /dev/null +++ b/assets/third-party-icons/developer/processed/Languages/latex.svg @@ -0,0 +1 @@ +LaTeX \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Languages/lua.svg b/assets/third-party-icons/developer/processed/Languages/lua.svg new file mode 100644 index 00000000..5f4c6521 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Languages/lua.svg @@ -0,0 +1 @@ +Lua \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Languages/zig.svg b/assets/third-party-icons/developer/processed/Languages/zig.svg new file mode 100644 index 00000000..d8504941 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Languages/zig.svg @@ -0,0 +1 @@ +Zig \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Logging/fluentbit.svg b/assets/third-party-icons/developer/processed/Logging/fluentbit.svg new file mode 100644 index 00000000..cc0c5ffc --- /dev/null +++ b/assets/third-party-icons/developer/processed/Logging/fluentbit.svg @@ -0,0 +1 @@ +Fluent Bit \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Logging/fluentd.svg b/assets/third-party-icons/developer/processed/Logging/fluentd.svg new file mode 100644 index 00000000..38d9169d --- /dev/null +++ b/assets/third-party-icons/developer/processed/Logging/fluentd.svg @@ -0,0 +1 @@ +Fluentd \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Logging/graylog.svg b/assets/third-party-icons/developer/processed/Logging/graylog.svg new file mode 100644 index 00000000..d49fc873 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Logging/graylog.svg @@ -0,0 +1 @@ +Graylog \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Monitoring/datadog.svg b/assets/third-party-icons/developer/processed/Monitoring/datadog.svg new file mode 100644 index 00000000..ab437319 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Monitoring/datadog.svg @@ -0,0 +1 @@ +Datadog \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Monitoring/dynatrace.svg b/assets/third-party-icons/developer/processed/Monitoring/dynatrace.svg new file mode 100644 index 00000000..d83ff94f --- /dev/null +++ b/assets/third-party-icons/developer/processed/Monitoring/dynatrace.svg @@ -0,0 +1 @@ +Dynatrace \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Monitoring/jaeger.svg b/assets/third-party-icons/developer/processed/Monitoring/jaeger.svg new file mode 100644 index 00000000..74155a60 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Monitoring/jaeger.svg @@ -0,0 +1 @@ +Jaeger \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Monitoring/newrelic.svg b/assets/third-party-icons/developer/processed/Monitoring/newrelic.svg new file mode 100644 index 00000000..f535b78b --- /dev/null +++ b/assets/third-party-icons/developer/processed/Monitoring/newrelic.svg @@ -0,0 +1 @@ +New Relic \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Monitoring/prometheus.svg b/assets/third-party-icons/developer/processed/Monitoring/prometheus.svg new file mode 100644 index 00000000..7dd2b46f --- /dev/null +++ b/assets/third-party-icons/developer/processed/Monitoring/prometheus.svg @@ -0,0 +1 @@ +Prometheus \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Monitoring/sentry.svg b/assets/third-party-icons/developer/processed/Monitoring/sentry.svg new file mode 100644 index 00000000..47888975 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Monitoring/sentry.svg @@ -0,0 +1 @@ +Sentry \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Monitoring/splunk.svg b/assets/third-party-icons/developer/processed/Monitoring/splunk.svg new file mode 100644 index 00000000..e84d30db --- /dev/null +++ b/assets/third-party-icons/developer/processed/Monitoring/splunk.svg @@ -0,0 +1 @@ +Splunk \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Queue/celery.svg b/assets/third-party-icons/developer/processed/Queue/celery.svg new file mode 100644 index 00000000..9dd7df66 --- /dev/null +++ b/assets/third-party-icons/developer/processed/Queue/celery.svg @@ -0,0 +1 @@ +Celery \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Queue/nats.svg b/assets/third-party-icons/developer/processed/Queue/nats.svg new file mode 100644 index 00000000..1c31020c --- /dev/null +++ b/assets/third-party-icons/developer/processed/Queue/nats.svg @@ -0,0 +1 @@ +NATS.io \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/Queue/rabbitmq.svg b/assets/third-party-icons/developer/processed/Queue/rabbitmq.svg new file mode 100644 index 00000000..f8b90f5b --- /dev/null +++ b/assets/third-party-icons/developer/processed/Queue/rabbitmq.svg @@ -0,0 +1 @@ +RabbitMQ \ No newline at end of file diff --git a/assets/third-party-icons/developer/processed/developer-icons-v1.manifest.json b/assets/third-party-icons/developer/processed/developer-icons-v1.manifest.json index 69314059..a7f69957 100644 --- a/assets/third-party-icons/developer/processed/developer-icons-v1.manifest.json +++ b/assets/third-party-icons/developer/processed/developer-icons-v1.manifest.json @@ -3193,6 +3193,936 @@ "defaultHeight": 96, "nodeType": "custom", "defaultData": {} + }, + { + "id": "infra-nginx", + "label": "Nginx", + "category": "Infra", + "svgContent": "NGINX", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "infra-traefik", + "label": "Traefik", + "category": "Infra", + "svgContent": "Traefik Proxy", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "infra-envoy", + "label": "Envoy", + "category": "Infra", + "svgContent": "Envoy Proxy", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "infra-istio", + "label": "Istio", + "category": "Infra", + "svgContent": "Istio", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "infra-consul", + "label": "Consul", + "category": "Infra", + "svgContent": "Consul", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "infra-kong", + "label": "Kong", + "category": "Infra", + "svgContent": "Kong", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "infra-etcd", + "label": "etcd", + "category": "Infra", + "svgContent": "etcd", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "infra-linkerd", + "label": "Linkerd", + "category": "Infra", + "svgContent": "Linkerd", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "infra-caddy", + "label": "Caddy", + "category": "Infra", + "svgContent": "Caddy", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "infra-gunicorn", + "label": "Gunicorn", + "category": "Infra", + "svgContent": "Gunicorn", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "infra-tomcat", + "label": "Tomcat", + "category": "Infra", + "svgContent": "Apache Tomcat", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "monitoring-prometheus", + "label": "Prometheus", + "category": "Monitoring", + "svgContent": "Prometheus", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "monitoring-sentry", + "label": "Sentry", + "category": "Monitoring", + "svgContent": "Sentry", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "monitoring-newrelic", + "label": "New Relic", + "category": "Monitoring", + "svgContent": "New Relic", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "monitoring-datadog", + "label": "Datadog", + "category": "Monitoring", + "svgContent": "Datadog", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "monitoring-dynatrace", + "label": "Dynatrace", + "category": "Monitoring", + "svgContent": "Dynatrace", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "monitoring-splunk", + "label": "Splunk", + "category": "Monitoring", + "svgContent": "Splunk", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "database-influxdb", + "label": "InfluxDB", + "category": "Database", + "svgContent": "InfluxDB", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "database-cockroachdb", + "label": "CockroachDB", + "category": "Database", + "svgContent": "Cockroach Labs", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "database-neo4j", + "label": "Neo4j", + "category": "Database", + "svgContent": "Neo4j", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "database-couchdb", + "label": "CouchDB", + "category": "Database", + "svgContent": "Apache CouchDB", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "database-couchbase", + "label": "Couchbase", + "category": "Database", + "svgContent": "Couchbase", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "database-duckdb", + "label": "DuckDB", + "category": "Database", + "svgContent": "DuckDB", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "database-scylla", + "label": "ScyllaDB", + "category": "Database", + "svgContent": "ScyllaDB", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "database-druid", + "label": "Apache Druid", + "category": "Database", + "svgContent": "Apache Druid", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "queue-rabbitmq", + "label": "RabbitMQ", + "category": "Queue", + "svgContent": "RabbitMQ", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "queue-celery", + "label": "Celery", + "category": "Queue", + "svgContent": "Celery", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "container-containerd", + "label": "containerd", + "category": "Container", + "svgContent": "containerd", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "container-k3s", + "label": "K3s", + "category": "Container", + "svgContent": "K3s", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "container-lxc", + "label": "LXC", + "category": "Container", + "svgContent": "Linux Containers", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "logging-fluentbit", + "label": "Fluent Bit", + "category": "Logging", + "svgContent": "Fluent Bit", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "logging-fluentd", + "label": "Fluentd", + "category": "Logging", + "svgContent": "Fluentd", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "logging-graylog", + "label": "Graylog", + "category": "Logging", + "svgContent": "Graylog", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "ci-travisci", + "label": "Travis CI", + "category": "CI-CD", + "svgContent": "Travis CI", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "ci-teamcity", + "label": "TeamCity", + "category": "CI-CD", + "svgContent": "TeamCity", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "ci-droneci", + "label": "Drone CI", + "category": "CI-CD", + "svgContent": "Drone", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gitops-argocd", + "label": "Argo CD", + "category": "GitOps", + "svgContent": "Argo", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gitops-flux", + "label": "Flux", + "category": "GitOps", + "svgContent": "Flux", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "iac-puppet", + "label": "Puppet", + "category": "IaC", + "svgContent": "Puppet", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "iac-ansible", + "label": "Ansible", + "category": "IaC", + "svgContent": "Ansible", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "iac-chef", + "label": "Chef", + "category": "IaC", + "svgContent": "Chef", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "storage-ceph", + "label": "Ceph", + "category": "Storage", + "svgContent": "Ceph", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "storage-minio", + "label": "MinIO", + "category": "Storage", + "svgContent": "MinIO", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "auth-vault", + "label": "Vault", + "category": "Security", + "svgContent": "Vault", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "auth-keycloak", + "label": "Keycloak", + "category": "Security", + "svgContent": "Keycloak", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "auth-oauth2", + "label": "OAuth 2.0", + "category": "Security", + "svgContent": "Auth0", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "infra-letsencrypt", + "label": "Let's Encrypt", + "category": "Infra", + "svgContent": "Let's Encrypt", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "workflow-airflow", + "label": "Apache Airflow", + "category": "Workflow", + "svgContent": "Apache Airflow", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "workflow-prefect", + "label": "Prefect", + "category": "Workflow", + "svgContent": "Prefect", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "workflow-temporal", + "label": "Temporal", + "category": "Workflow", + "svgContent": "Temporal", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "registry-harbor", + "label": "Harbor", + "category": "Registry", + "svgContent": "Harbor", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "registry-jfrog", + "label": "JFrog", + "category": "Registry", + "svgContent": "JFrog", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "analytics-spark", + "label": "Apache Spark", + "category": "Analytics", + "svgContent": "Apache Spark", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "analytics-flink", + "label": "Apache Flink", + "category": "Analytics", + "svgContent": "Apache Flink", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "analytics-hadoop", + "label": "Hadoop", + "category": "Analytics", + "svgContent": "Apache Hadoop", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "analytics-hive", + "label": "Apache Hive", + "category": "Analytics", + "svgContent": "Apache Hive", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "analytics-databricks", + "label": "Databricks", + "category": "Analytics", + "svgContent": "Databricks", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "analytics-superset", + "label": "Superset", + "category": "Analytics", + "svgContent": "Apache Superset", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "analytics-trino", + "label": "Trino", + "category": "Analytics", + "svgContent": "Trino", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "analytics-solr", + "label": "Apache Solr", + "category": "Analytics", + "svgContent": "Apache Solr", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "ml-mlflow", + "label": "MLflow", + "category": "ML", + "svgContent": "MLflow", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "ml-wandb", + "label": "Weights & Biases", + "category": "ML", + "svgContent": "Weights & Biases", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "ml-bentoml", + "label": "BentoML", + "category": "ML", + "svgContent": "BentoML", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "backend-dotnet", + "label": ".NET", + "category": "Backend", + "svgContent": ".NET", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "backend-quarkus", + "label": "Quarkus", + "category": "Backend", + "svgContent": "Quarkus", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "backend-phoenix", + "label": "Phoenix", + "category": "Backend", + "svgContent": "Phoenix Framework", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "backend-dapr", + "label": "Dapr", + "category": "Backend", + "svgContent": "Dapr", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "lang-cpp", + "label": "C++", + "category": "Languages", + "svgContent": "C++", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "lang-latex", + "label": "LaTeX", + "category": "Languages", + "svgContent": "LaTeX", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "lang-zig", + "label": "Zig", + "category": "Languages", + "svgContent": "Zig", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "lang-lua", + "label": "Lua", + "category": "Languages", + "svgContent": "Lua", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "lang-dart", + "label": "Dart", + "category": "Languages", + "svgContent": "Dart", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "monitoring-jaeger", + "label": "Jaeger", + "category": "Monitoring", + "svgContent": "Jaeger", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "queue-nats", + "label": "NATS", + "category": "Queue", + "svgContent": "NATS.io", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "iac-nomad", + "label": "Nomad", + "category": "IaC", + "svgContent": "Nomad", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gcp-googlecloud", + "label": "Google Cloud", + "category": "GCP", + "svgContent": "Google Cloud", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gcp-bigquery", + "label": "BigQuery", + "category": "GCP", + "svgContent": "Google BigQuery", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gcp-cloudstorage", + "label": "Cloud Storage", + "category": "GCP", + "svgContent": "Google Cloud Storage", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gcp-pubsub", + "label": "Cloud Pub/Sub", + "category": "GCP", + "svgContent": "Google Pub/Sub", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gcp-spanner", + "label": "Cloud Spanner", + "category": "GCP", + "svgContent": "Google Cloud Spanner", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gcp-composer", + "label": "Cloud Composer", + "category": "GCP", + "svgContent": "Google Cloud Composer", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gcp-dataflow", + "label": "Dataflow", + "category": "GCP", + "svgContent": "Google Dataflow", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gcp-dataproc", + "label": "Dataproc", + "category": "GCP", + "svgContent": "Google Dataproc", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gcp-gemini", + "label": "Gemini", + "category": "GCP", + "svgContent": "Google Gemini", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gcp-colab", + "label": "Google Colab", + "category": "GCP", + "svgContent": "Google Colab", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gcp-maps", + "label": "Google Maps", + "category": "GCP", + "svgContent": "Google Maps", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gcp-analytics", + "label": "Google Analytics", + "category": "GCP", + "svgContent": "Google Analytics", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gcp-ads", + "label": "Google Ads", + "category": "GCP", + "svgContent": "Google Ads", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gcp-drive", + "label": "Google Drive", + "category": "GCP", + "svgContent": "Google Drive", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gcp-docs", + "label": "Google Docs", + "category": "GCP", + "svgContent": "Google Docs", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gcp-sheets", + "label": "Google Sheets", + "category": "GCP", + "svgContent": "Google Sheets", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gcp-slides", + "label": "Google Slides", + "category": "GCP", + "svgContent": "Google Slides", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} + }, + { + "id": "gcp-looker", + "label": "Looker", + "category": "GCP", + "svgContent": "Looker", + "defaultWidth": 160, + "defaultHeight": 96, + "nodeType": "custom", + "defaultData": {} } ] -} +} \ No newline at end of file diff --git a/assets/third-party-icons/google-cloud/SOURCE.md b/assets/third-party-icons/gcp/SOURCE.md similarity index 100% rename from assets/third-party-icons/google-cloud/SOURCE.md rename to assets/third-party-icons/gcp/SOURCE.md diff --git a/assets/third-party-icons/google-cloud/processed/.gitkeep b/assets/third-party-icons/gcp/processed/.gitkeep similarity index 100% rename from assets/third-party-icons/google-cloud/processed/.gitkeep rename to assets/third-party-icons/gcp/processed/.gitkeep diff --git a/assets/third-party-icons/gcp/processed/Core/analytics.svg b/assets/third-party-icons/gcp/processed/Core/analytics.svg new file mode 100644 index 00000000..8b68c100 --- /dev/null +++ b/assets/third-party-icons/gcp/processed/Core/analytics.svg @@ -0,0 +1 @@ +Google Analytics \ No newline at end of file diff --git a/assets/third-party-icons/gcp/processed/Core/bigquery.svg b/assets/third-party-icons/gcp/processed/Core/bigquery.svg new file mode 100644 index 00000000..aa40433b --- /dev/null +++ b/assets/third-party-icons/gcp/processed/Core/bigquery.svg @@ -0,0 +1 @@ +Google BigQuery \ No newline at end of file diff --git a/assets/third-party-icons/gcp/processed/Core/cloud-composer.svg b/assets/third-party-icons/gcp/processed/Core/cloud-composer.svg new file mode 100644 index 00000000..4ff2211e --- /dev/null +++ b/assets/third-party-icons/gcp/processed/Core/cloud-composer.svg @@ -0,0 +1 @@ +Google Cloud Composer \ No newline at end of file diff --git a/assets/third-party-icons/gcp/processed/Core/cloud-spanner.svg b/assets/third-party-icons/gcp/processed/Core/cloud-spanner.svg new file mode 100644 index 00000000..d3ed6610 --- /dev/null +++ b/assets/third-party-icons/gcp/processed/Core/cloud-spanner.svg @@ -0,0 +1 @@ +Google Cloud Spanner \ No newline at end of file diff --git a/assets/third-party-icons/gcp/processed/Core/cloud-storage.svg b/assets/third-party-icons/gcp/processed/Core/cloud-storage.svg new file mode 100644 index 00000000..90978b76 --- /dev/null +++ b/assets/third-party-icons/gcp/processed/Core/cloud-storage.svg @@ -0,0 +1 @@ +Google Cloud Storage \ No newline at end of file diff --git a/assets/third-party-icons/gcp/processed/Core/colab.svg b/assets/third-party-icons/gcp/processed/Core/colab.svg new file mode 100644 index 00000000..434396ea --- /dev/null +++ b/assets/third-party-icons/gcp/processed/Core/colab.svg @@ -0,0 +1 @@ +Google Colab \ No newline at end of file diff --git a/assets/third-party-icons/gcp/processed/Core/dataflow.svg b/assets/third-party-icons/gcp/processed/Core/dataflow.svg new file mode 100644 index 00000000..46b1e5f7 --- /dev/null +++ b/assets/third-party-icons/gcp/processed/Core/dataflow.svg @@ -0,0 +1 @@ +Google Dataflow \ No newline at end of file diff --git a/assets/third-party-icons/gcp/processed/Core/dataproc.svg b/assets/third-party-icons/gcp/processed/Core/dataproc.svg new file mode 100644 index 00000000..1ec18f35 --- /dev/null +++ b/assets/third-party-icons/gcp/processed/Core/dataproc.svg @@ -0,0 +1 @@ +Google Dataproc \ No newline at end of file diff --git a/assets/third-party-icons/gcp/processed/Core/docs.svg b/assets/third-party-icons/gcp/processed/Core/docs.svg new file mode 100644 index 00000000..12077819 --- /dev/null +++ b/assets/third-party-icons/gcp/processed/Core/docs.svg @@ -0,0 +1 @@ +Google Docs \ No newline at end of file diff --git a/assets/third-party-icons/gcp/processed/Core/drive.svg b/assets/third-party-icons/gcp/processed/Core/drive.svg new file mode 100644 index 00000000..7263ef31 --- /dev/null +++ b/assets/third-party-icons/gcp/processed/Core/drive.svg @@ -0,0 +1 @@ +Google Drive \ No newline at end of file diff --git a/assets/third-party-icons/gcp/processed/Core/gemini.svg b/assets/third-party-icons/gcp/processed/Core/gemini.svg new file mode 100644 index 00000000..e15e53ce --- /dev/null +++ b/assets/third-party-icons/gcp/processed/Core/gemini.svg @@ -0,0 +1 @@ +Google Gemini \ No newline at end of file diff --git a/assets/third-party-icons/gcp/processed/Core/google-ads.svg b/assets/third-party-icons/gcp/processed/Core/google-ads.svg new file mode 100644 index 00000000..d0ad5413 --- /dev/null +++ b/assets/third-party-icons/gcp/processed/Core/google-ads.svg @@ -0,0 +1 @@ +Google Ads \ No newline at end of file diff --git a/assets/third-party-icons/gcp/processed/Core/google-cloud.svg b/assets/third-party-icons/gcp/processed/Core/google-cloud.svg new file mode 100644 index 00000000..9c069578 --- /dev/null +++ b/assets/third-party-icons/gcp/processed/Core/google-cloud.svg @@ -0,0 +1 @@ +Google Cloud \ No newline at end of file diff --git a/assets/third-party-icons/gcp/processed/Core/looker.svg b/assets/third-party-icons/gcp/processed/Core/looker.svg new file mode 100644 index 00000000..31db1e5c --- /dev/null +++ b/assets/third-party-icons/gcp/processed/Core/looker.svg @@ -0,0 +1 @@ +Looker \ No newline at end of file diff --git a/assets/third-party-icons/gcp/processed/Core/maps.svg b/assets/third-party-icons/gcp/processed/Core/maps.svg new file mode 100644 index 00000000..2c928cd7 --- /dev/null +++ b/assets/third-party-icons/gcp/processed/Core/maps.svg @@ -0,0 +1 @@ +Google Maps \ No newline at end of file diff --git a/assets/third-party-icons/gcp/processed/Core/pubsub.svg b/assets/third-party-icons/gcp/processed/Core/pubsub.svg new file mode 100644 index 00000000..1e39cc62 --- /dev/null +++ b/assets/third-party-icons/gcp/processed/Core/pubsub.svg @@ -0,0 +1 @@ +Google Pub/Sub \ No newline at end of file diff --git a/assets/third-party-icons/gcp/processed/Core/sheets.svg b/assets/third-party-icons/gcp/processed/Core/sheets.svg new file mode 100644 index 00000000..3dce7189 --- /dev/null +++ b/assets/third-party-icons/gcp/processed/Core/sheets.svg @@ -0,0 +1 @@ +Google Sheets \ No newline at end of file diff --git a/assets/third-party-icons/gcp/processed/Core/slides.svg b/assets/third-party-icons/gcp/processed/Core/slides.svg new file mode 100644 index 00000000..398312b2 --- /dev/null +++ b/assets/third-party-icons/gcp/processed/Core/slides.svg @@ -0,0 +1 @@ +Google Slides \ No newline at end of file diff --git a/assets/third-party-icons/google-cloud/raw/.gitkeep b/assets/third-party-icons/gcp/raw/.gitkeep similarity index 100% rename from assets/third-party-icons/google-cloud/raw/.gitkeep rename to assets/third-party-icons/gcp/raw/.gitkeep diff --git a/scripts/shape-pack/add-missing-devicons.mjs b/scripts/shape-pack/add-missing-devicons.mjs new file mode 100644 index 00000000..bd6c101a --- /dev/null +++ b/scripts/shape-pack/add-missing-devicons.mjs @@ -0,0 +1,221 @@ +/** + * Fetches missing infrastructure/dev tool icons from Simple Icons CDN + * and appends them to the developer-icons-v1 manifest. + * + * Simple Icons: https://simpleicons.org — CC0 license for most icons. + * Run: node scripts/shape-pack/add-missing-devicons.mjs + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MANIFEST_PATH = path.resolve(__dirname, '../../assets/third-party-icons/developer/processed/developer-icons-v1.manifest.json'); + +// Missing icons: { id suffix, display label, category, simpleicons slug } +const MISSING_ICONS = [ + // Network / Proxy / Service Mesh + { id: 'infra-nginx', label: 'Nginx', category: 'Infra', slug: 'nginx' }, + { id: 'infra-traefik', label: 'Traefik', category: 'Infra', slug: 'traefikproxy' }, + { id: 'infra-haproxy', label: 'HAProxy', category: 'Infra', slug: 'haproxy' }, + { id: 'infra-envoy', label: 'Envoy', category: 'Infra', slug: 'envoyproxy' }, + { id: 'infra-istio', label: 'Istio', category: 'Infra', slug: 'istio' }, + { id: 'infra-consul', label: 'Consul', category: 'Infra', slug: 'consul' }, + { id: 'infra-kong', label: 'Kong', category: 'Infra', slug: 'kong' }, + { id: 'infra-etcd', label: 'etcd', category: 'Infra', slug: 'etcd' }, + { id: 'infra-linkerd', label: 'Linkerd', category: 'Infra', slug: 'linkerd' }, + { id: 'infra-zookeeper', label: 'Zookeeper', category: 'Infra', slug: 'apachezookeeper' }, + { id: 'infra-caddy', label: 'Caddy', category: 'Infra', slug: 'caddy' }, + { id: 'infra-gunicorn', label: 'Gunicorn', category: 'Infra', slug: 'gunicorn' }, + { id: 'infra-tomcat', label: 'Tomcat', category: 'Infra', slug: 'apachetomcat' }, + + // Monitoring / Observability + { id: 'monitoring-prometheus', label: 'Prometheus', category: 'Monitoring', slug: 'prometheus' }, + { id: 'monitoring-sentry', label: 'Sentry', category: 'Monitoring', slug: 'sentry' }, + { id: 'monitoring-newrelic', label: 'New Relic', category: 'Monitoring', slug: 'newrelic' }, + { id: 'monitoring-datadog', label: 'Datadog', category: 'Monitoring', slug: 'datadog' }, + { id: 'monitoring-dynatrace', label: 'Dynatrace', category: 'Monitoring', slug: 'dynatrace' }, + { id: 'monitoring-splunk', label: 'Splunk', category: 'Monitoring', slug: 'splunk' }, + { id: 'monitoring-nagios', label: 'Nagios', category: 'Monitoring', slug: 'nagios' }, + { id: 'monitoring-zabbix', label: 'Zabbix', category: 'Monitoring', slug: 'zabbix' }, + { id: 'monitoring-jaeger', label: 'Jaeger', category: 'Monitoring', slug: 'jaegertracing' }, + + // Databases + { id: 'database-influxdb', label: 'InfluxDB', category: 'Database', slug: 'influxdb' }, + { id: 'database-cockroachdb', label: 'CockroachDB', category: 'Database', slug: 'cockroachlabs' }, + { id: 'database-neo4j', label: 'Neo4j', category: 'Database', slug: 'neo4j' }, + { id: 'database-couchdb', label: 'CouchDB', category: 'Database', slug: 'apachecouchdb' }, + { id: 'database-couchbase', label: 'Couchbase', category: 'Database', slug: 'couchbase' }, + { id: 'database-duckdb', label: 'DuckDB', category: 'Database', slug: 'duckdb' }, + { id: 'database-qdrant', label: 'Qdrant', category: 'Database', slug: 'qdrant' }, + { id: 'database-scylla', label: 'ScyllaDB', category: 'Database', slug: 'scylladb' }, + { id: 'database-druid', label: 'Apache Druid', category: 'Database', slug: 'apachedruid' }, + + // Queues / Messaging + { id: 'queue-rabbitmq', label: 'RabbitMQ', category: 'Queue', slug: 'rabbitmq' }, + { id: 'queue-nats', label: 'NATS', category: 'Queue', slug: 'nats-io' }, + { id: 'queue-activemq', label: 'ActiveMQ', category: 'Queue', slug: 'apacheactivemq' }, + { id: 'queue-celery', label: 'Celery', category: 'Queue', slug: 'celery' }, + { id: 'queue-emqx', label: 'EMQX', category: 'Queue', slug: 'emqx' }, + + // Containers / Runtime + { id: 'container-containerd', label: 'containerd', category: 'Container', slug: 'containerd' }, + { id: 'container-k3s', label: 'K3s', category: 'Container', slug: 'k3s' }, + { id: 'container-lxc', label: 'LXC', category: 'Container', slug: 'linuxcontainers' }, + + // Logging + { id: 'logging-loki', label: 'Loki', category: 'Logging', slug: 'grafanaloki' }, + { id: 'logging-fluentbit', label: 'Fluent Bit', category: 'Logging', slug: 'fluentbit' }, + { id: 'logging-fluentd', label: 'Fluentd', category: 'Logging', slug: 'fluentd' }, + { id: 'logging-graylog', label: 'Graylog', category: 'Logging', slug: 'graylog' }, + + // CI + { id: 'ci-travisci', label: 'Travis CI', category: 'CI-CD', slug: 'travisci' }, + { id: 'ci-teamcity', label: 'TeamCity', category: 'CI-CD', slug: 'teamcity' }, + { id: 'ci-droneci', label: 'Drone CI', category: 'CI-CD', slug: 'drone' }, + + // GitOps + { id: 'gitops-argocd', label: 'Argo CD', category: 'GitOps', slug: 'argo' }, + { id: 'gitops-flux', label: 'Flux', category: 'GitOps', slug: 'flux' }, + + // IaC / Infra Management + { id: 'iac-puppet', label: 'Puppet', category: 'IaC', slug: 'puppet' }, + { id: 'iac-ansible', label: 'Ansible', category: 'IaC', slug: 'ansible' }, + { id: 'iac-chef', label: 'Chef', category: 'IaC', slug: 'chef' }, + { id: 'iac-nomad', label: 'Nomad', category: 'IaC', slug: 'hashicorpnomad' }, + + // Storage + { id: 'storage-ceph', label: 'Ceph', category: 'Storage', slug: 'ceph' }, + { id: 'storage-portworx', label: 'Portworx', category: 'Storage', slug: 'portworx' }, + { id: 'storage-glusterfs', label: 'GlusterFS', category: 'Storage', slug: 'gluster' }, + { id: 'storage-minio', label: 'MinIO', category: 'Storage', slug: 'minio' }, + + // Cache / In-Memory + { id: 'cache-memcached', label: 'Memcached', category: 'Cache', slug: 'memcached' }, + { id: 'cache-hazelcast', label: 'Hazelcast', category: 'Cache', slug: 'hazelcast' }, + + // Auth / Identity + { id: 'auth-vault', label: 'Vault', category: 'Security', slug: 'vault' }, + { id: 'auth-keycloak', label: 'Keycloak', category: 'Security', slug: 'keycloak' }, + { id: 'auth-oauth2', label: 'OAuth 2.0', category: 'Security', slug: 'auth0' }, + + // Certs + { id: 'infra-letsencrypt', label: "Let's Encrypt", category: 'Infra', slug: 'letsencrypt' }, + + // Workflow / Orchestration + { id: 'workflow-airflow', label: 'Apache Airflow', category: 'Workflow', slug: 'apacheairflow' }, + { id: 'workflow-kubeflow', label: 'Kubeflow', category: 'Workflow', slug: 'kubeflow' }, + { id: 'workflow-prefect', label: 'Prefect', category: 'Workflow', slug: 'prefect' }, + { id: 'workflow-temporal', label: 'Temporal', category: 'Workflow', slug: 'temporal' }, + + // Registry + { id: 'registry-harbor', label: 'Harbor', category: 'Registry', slug: 'harbor' }, + { id: 'registry-jfrog', label: 'JFrog', category: 'Registry', slug: 'jfrog' }, + + // Analytics / Data + { id: 'analytics-spark', label: 'Apache Spark', category: 'Analytics', slug: 'apachespark' }, + { id: 'analytics-flink', label: 'Apache Flink', category: 'Analytics', slug: 'apacheflink' }, + { id: 'analytics-hadoop', label: 'Hadoop', category: 'Analytics', slug: 'apachehadoop' }, + { id: 'analytics-hive', label: 'Apache Hive', category: 'Analytics', slug: 'apachehive' }, + { id: 'analytics-databricks', label: 'Databricks', category: 'Analytics', slug: 'databricks' }, + { id: 'analytics-dbt', label: 'dbt', category: 'Analytics', slug: 'dbt' }, + { id: 'analytics-superset', label: 'Superset', category: 'Analytics', slug: 'apachesuperset' }, + { id: 'analytics-tableau', label: 'Tableau', category: 'Analytics', slug: 'tableau' }, + { id: 'analytics-powerbi', label: 'Power BI', category: 'Analytics', slug: 'powerbi' }, + { id: 'analytics-trino', label: 'Trino', category: 'Analytics', slug: 'trino' }, + { id: 'analytics-solr', label: 'Apache Solr', category: 'Analytics', slug: 'apachesolr' }, + + // MLOps + { id: 'ml-mlflow', label: 'MLflow', category: 'ML', slug: 'mlflow' }, + { id: 'ml-wandb', label: 'Weights & Biases', category: 'ML', slug: 'weightsandbiases' }, + { id: 'ml-bentoml', label: 'BentoML', category: 'ML', slug: 'bentoml' }, + + // Frameworks + { id: 'backend-dotnet', label: '.NET', category: 'Backend', slug: 'dotnet' }, + { id: 'backend-quarkus', label: 'Quarkus', category: 'Backend', slug: 'quarkus' }, + { id: 'backend-micronaut', label: 'Micronaut', category: 'Backend', slug: 'micronaut' }, + { id: 'backend-phoenix', label: 'Phoenix', category: 'Backend', slug: 'phoenixframework' }, + { id: 'backend-camel', label: 'Apache Camel', category: 'Backend', slug: 'apachecamel' }, + { id: 'backend-dapr', label: 'Dapr', category: 'Backend', slug: 'dapr' }, + + // Languages + { id: 'lang-cpp', label: 'C++', category: 'Languages', slug: 'cplusplus' }, + { id: 'lang-latex', label: 'LaTeX', category: 'Languages', slug: 'latex' }, + { id: 'lang-matlab', label: 'MATLAB', category: 'Languages', slug: 'matlab' }, + { id: 'lang-zig', label: 'Zig', category: 'Languages', slug: 'zig' }, + { id: 'lang-lua', label: 'Lua', category: 'Languages', slug: 'lua' }, + { id: 'lang-dart', label: 'Dart', category: 'Languages', slug: 'dart' }, +]; + +async function fetchSvg(slug) { + const url = `https://cdn.simpleicons.org/${slug}`; + try { + const res = await fetch(url); + if (!res.ok) return null; + const svg = await res.text(); + // Simple Icons SVGs are black by default — good for our use case + return svg.trim(); + } catch { + return null; + } +} + +async function main() { + console.log(`Reading manifest from ${MANIFEST_PATH}`); + const raw = await fs.readFile(MANIFEST_PATH, 'utf8'); + const manifest = JSON.parse(raw); + + const existingIds = new Set(manifest.shapes.map(s => s.id)); + console.log(`Existing shapes: ${manifest.shapes.length}`); + + let added = 0; + let failed = []; + + for (const icon of MISSING_ICONS) { + if (existingIds.has(icon.id)) { + console.log(` SKIP (exists): ${icon.id}`); + continue; + } + + process.stdout.write(` Fetching ${icon.label} (${icon.slug})... `); + const svgContent = await fetchSvg(icon.slug); + + if (!svgContent) { + console.log(`FAILED`); + failed.push(icon); + continue; + } + + manifest.shapes.push({ + id: icon.id, + label: icon.label, + category: icon.category, + svgContent, + defaultWidth: 160, + defaultHeight: 96, + nodeType: 'custom', + defaultData: {}, + }); + + existingIds.add(icon.id); + added++; + console.log(`OK`); + + // Small delay to be polite to CDN + await new Promise(r => setTimeout(r, 50)); + } + + await fs.writeFile(MANIFEST_PATH, JSON.stringify(manifest, null, 2)); + console.log(`\nDone. Added ${added} icons. Total: ${manifest.shapes.length}`); + + if (failed.length > 0) { + console.log(`\nFailed to fetch (${failed.length}):`); + failed.forEach(f => console.log(` - ${f.label} (slug: ${f.slug})`)); + } +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/src/components/command-bar/importViewModel.test.ts b/src/components/command-bar/importViewModel.test.ts index 07bb9432..3bbd729a 100644 --- a/src/components/command-bar/importViewModel.test.ts +++ b/src/components/command-bar/importViewModel.test.ts @@ -18,13 +18,16 @@ const t = ((key: string, fallback?: string) => fallback ?? key) as TFunction< describe('importViewModel', () => { it('returns translated category labels from the shared definitions', () => { - expect(getImportCategoryLabel(t, 'sql')).toBe('SQL'); - expect(getImportCategoryLabel(t, 'codebase')).toBe('Repo'); + expect(getImportCategoryLabel(t, 'infra')).toBe('Infra'); expect(getImportCategoryDefinition('infra').hasNative).toBe(true); - expect(getImportCategoryDefinition('openapi').hasNative).toBe(false); expect(IMPORT_CATEGORY_DEFINITIONS.some((definition) => definition.id === 'mermaid')).toBe( false ); + // sql, openapi, codebase are hidden behind feature flags (default off) + expect(IMPORT_CATEGORY_DEFINITIONS.some((definition) => definition.id === 'sql')).toBe(false); + expect(IMPORT_CATEGORY_DEFINITIONS.some((definition) => definition.id === 'codebase')).toBe( + false + ); }); it('builds placeholders and options for the import view controls', () => { @@ -38,7 +41,6 @@ describe('importViewModel', () => { 'terraform-state', 'kubernetes', 'docker-compose', - 'terraform-hcl', ]); expect(languageOptions.some((option) => option.value === 'typescript')).toBe(true); }); diff --git a/src/components/command-bar/importViewModel.ts b/src/components/command-bar/importViewModel.ts index cc26aaa8..076c346e 100644 --- a/src/components/command-bar/importViewModel.ts +++ b/src/components/command-bar/importViewModel.ts @@ -2,6 +2,7 @@ import type { TFunction } from 'i18next'; import type { SelectOption } from '@/components/ui/Select'; import { LANGUAGE_LABELS } from '@/hooks/ai-generation/codeToArchitecture'; import type { ImportCategory } from './importDetection'; +import { ROLLOUT_FLAGS, type RolloutFlagKey } from '@/config/rolloutFlags'; export interface ImportCategoryDefinition { id: ImportCategory; @@ -9,15 +10,17 @@ export interface ImportCategoryDefinition { labelKey: string; hasNative: boolean; hasAI: boolean; + featureFlag?: RolloutFlagKey; } -export const IMPORT_CATEGORY_DEFINITIONS: ImportCategoryDefinition[] = [ +const ALL_IMPORT_CATEGORY_DEFINITIONS: ImportCategoryDefinition[] = [ { id: 'sql', fallbackLabel: 'SQL', labelKey: 'commandBar.import.categories.sql', hasNative: true, hasAI: true, + featureFlag: 'importSql', }, { id: 'infra', @@ -32,6 +35,7 @@ export const IMPORT_CATEGORY_DEFINITIONS: ImportCategoryDefinition[] = [ labelKey: 'commandBar.import.categories.openapi', hasNative: false, hasAI: true, + featureFlag: 'importOpenApi', }, { id: 'code', @@ -46,9 +50,15 @@ export const IMPORT_CATEGORY_DEFINITIONS: ImportCategoryDefinition[] = [ labelKey: 'commandBar.import.categories.codebase', hasNative: true, hasAI: true, + featureFlag: 'importCodebase', }, ]; +export const IMPORT_CATEGORY_DEFINITIONS: ImportCategoryDefinition[] = + ALL_IMPORT_CATEGORY_DEFINITIONS.filter( + (cat) => !cat.featureFlag || ROLLOUT_FLAGS[cat.featureFlag] + ); + export function createLanguageOptions(): SelectOption[] { return Object.entries(LANGUAGE_LABELS).map(([value, label]) => ({ value, @@ -76,7 +86,7 @@ export function getImportPlaceholders( } export function getInfraFormatOptions(t: TFunction<'translation', undefined>): SelectOption[] { - return [ + const options: SelectOption[] = [ { value: 'terraform-state', label: t('commandBar.import.infraFormats.terraformState', 'Terraform State (.tfstate)'), @@ -89,11 +99,16 @@ export function getInfraFormatOptions(t: TFunction<'translation', undefined>): S value: 'docker-compose', label: t('commandBar.import.infraFormats.dockerCompose', 'Docker Compose'), }, - { + ]; + + if (ROLLOUT_FLAGS.importInfraTerraformHcl) { + options.push({ value: 'terraform-hcl', label: t('commandBar.import.infraFormats.terraformHcl', 'Terraform HCL (AI)'), - }, - ]; + }); + } + + return options; } export function getImportCategoryLabel( diff --git a/src/components/flow-canvas/flowCanvasTypes.test.ts b/src/components/flow-canvas/flowCanvasTypes.test.ts index f4591361..c7aa6a74 100644 --- a/src/components/flow-canvas/flowCanvasTypes.test.ts +++ b/src/components/flow-canvas/flowCanvasTypes.test.ts @@ -19,6 +19,7 @@ describe('flowCanvasNodeTypes', () => { "mindmap", "mobile", "process", + "section", "sequence_note", "sequence_participant", "start", diff --git a/src/components/flow-canvas/flowCanvasTypes.tsx b/src/components/flow-canvas/flowCanvasTypes.tsx index 3e0fbad8..6ebf0bbf 100644 --- a/src/components/flow-canvas/flowCanvasTypes.tsx +++ b/src/components/flow-canvas/flowCanvasTypes.tsx @@ -20,6 +20,7 @@ import JourneyNode from '@/components/custom-nodes/JourneyNode'; import ArchitectureNode from '@/components/custom-nodes/ArchitectureNode'; import SequenceParticipantNode from '@/components/custom-nodes/SequenceParticipantNode'; import SequenceNoteNode from '@/components/custom-nodes/SequenceNoteNode'; +import SectionNode from '@/components/SectionNode'; export const flowCanvasNodeTypes: NodeTypes = { start: CustomNode, @@ -34,6 +35,7 @@ export const flowCanvasNodeTypes: NodeTypes = { architecture: ArchitectureNode, annotation: AnnotationNode, text: TextNode, + section: SectionNode, swimlane: SwimlaneNode, image: ImageNode, browser: BrowserNode, diff --git a/src/components/flow-canvas/useFlowCanvasPaste.ts b/src/components/flow-canvas/useFlowCanvasPaste.ts index 8479c71b..8f6fb830 100644 --- a/src/components/flow-canvas/useFlowCanvasPaste.ts +++ b/src/components/flow-canvas/useFlowCanvasPaste.ts @@ -2,161 +2,182 @@ import { useCallback } from 'react'; import { useFlowStore } from '@/store'; import type { FlowEdge, FlowNode } from '@/lib/types'; import type { MermaidDiagnosticsSnapshot } from '@/store/types'; -import { createPastedTextNode, isEditablePasteTarget, resolveLayoutDirection } from './pasteHelpers'; +import { + createPastedTextNode, + isEditablePasteTarget, + resolveLayoutDirection, +} from './pasteHelpers'; import { detectMermaidDiagramType } from '@/services/mermaid/detectDiagramType'; import { normalizeParseDiagnostics } from '@/services/mermaid/diagnosticFormatting'; import { parseMermaidByType } from '@/services/mermaid/parseMermaidByType'; +import { enrichNodesWithIcons } from '@/lib/nodeEnricher'; import { assignSmartHandles } from '@/services/smartEdgeRouting'; type SetFlowNodes = (payload: FlowNode[] | ((nodes: FlowNode[]) => FlowNode[])) => void; type SetFlowEdges = (payload: FlowEdge[] | ((edges: FlowEdge[]) => FlowEdge[])) => void; -type AddToast = (message: string, type?: 'success' | 'error' | 'info' | 'warning', duration?: number) => void; +type AddToast = ( + message: string, + type?: 'success' | 'error' | 'info' | 'warning', + duration?: number +) => void; interface UseFlowCanvasPasteParams { - architectureStrictMode: boolean; - activeTabId: string; - fitView: (options?: { duration?: number; padding?: number }) => void; - updateTab: (tabId: string, updates: Partial<{ diagramType: string }>) => void; - recordHistory: () => void; - setNodes: SetFlowNodes; - setEdges: SetFlowEdges; - setSelectedNodeId: (id: string | null) => void; - setMermaidDiagnostics: (payload: MermaidDiagnosticsSnapshot | null) => void; - clearMermaidDiagnostics: () => void; - addToast: AddToast; - strictModePasteBlockedMessage: string; - pasteSelection: (center?: { x: number; y: number }) => void; - getLastInteractionFlowPosition: () => { x: number; y: number } | null; - getCanvasCenterFlowPosition: () => { x: number; y: number }; + architectureStrictMode: boolean; + activeTabId: string; + fitView: (options?: { duration?: number; padding?: number }) => void; + updateTab: (tabId: string, updates: Partial<{ diagramType: string }>) => void; + recordHistory: () => void; + setNodes: SetFlowNodes; + setEdges: SetFlowEdges; + setSelectedNodeId: (id: string | null) => void; + setMermaidDiagnostics: (payload: MermaidDiagnosticsSnapshot | null) => void; + clearMermaidDiagnostics: () => void; + addToast: AddToast; + strictModePasteBlockedMessage: string; + pasteSelection: (center?: { x: number; y: number }) => void; + getLastInteractionFlowPosition: () => { x: number; y: number } | null; + getCanvasCenterFlowPosition: () => { x: number; y: number }; } export function useFlowCanvasPaste({ - architectureStrictMode, - activeTabId, - fitView, - updateTab, - recordHistory, - setNodes, - setEdges, - setSelectedNodeId, - setMermaidDiagnostics, - clearMermaidDiagnostics, - addToast, - strictModePasteBlockedMessage, - pasteSelection, - getLastInteractionFlowPosition, - getCanvasCenterFlowPosition, + architectureStrictMode, + activeTabId, + fitView, + updateTab, + recordHistory, + setNodes, + setEdges, + setSelectedNodeId, + setMermaidDiagnostics, + clearMermaidDiagnostics, + addToast, + strictModePasteBlockedMessage, + pasteSelection, + getLastInteractionFlowPosition, + getCanvasCenterFlowPosition, }: UseFlowCanvasPasteParams) { - const handleCanvasPaste = useCallback(async (event: React.ClipboardEvent): Promise => { - if (isEditablePasteTarget(event.target)) return; + const handleCanvasPaste = useCallback( + async (event: React.ClipboardEvent): Promise => { + if (isEditablePasteTarget(event.target)) return; - const rawText = event.clipboardData.getData('text/plain'); - const pastedText = rawText.trim(); + const rawText = event.clipboardData.getData('text/plain'); + const pastedText = rawText.trim(); - if (!pastedText) { - pasteSelection(getLastInteractionFlowPosition() ?? getCanvasCenterFlowPosition()); - return; - } + if (!pastedText) { + pasteSelection(getLastInteractionFlowPosition() ?? getCanvasCenterFlowPosition()); + return; + } - event.preventDefault(); - - const maybeMermaidType = detectMermaidDiagramType(pastedText); - if (maybeMermaidType) { - const result = parseMermaidByType(pastedText, { architectureStrictMode }); - const diagnostics = normalizeParseDiagnostics(result.diagnostics); - - if (!result.error) { - if (diagnostics.length > 0) { - setMermaidDiagnostics({ - source: 'paste', - diagramType: result.diagramType, - diagnostics, - updatedAt: Date.now(), - }); - addToast(`Imported with ${diagnostics.length} diagnostic warning(s).`, 'warning'); - } else { - clearMermaidDiagnostics(); - } + event.preventDefault(); - recordHistory(); - - if (result.nodes.length > 0) { - try { - const { getElkLayout } = await import('@/services/elkLayout'); - const layoutDirection = resolveLayoutDirection(result); - const { nodes: layoutedNodes, edges: layoutedEdges } = await getElkLayout(result.nodes, result.edges, { - direction: layoutDirection, - algorithm: 'layered', - spacing: 'normal', - }); - const smartEdges = assignSmartHandles(layoutedNodes, layoutedEdges); - setNodes(layoutedNodes); - setEdges(smartEdges); - } catch { - setNodes(result.nodes); - setEdges(result.edges); - } - } else { - setNodes(result.nodes); - setEdges(result.edges); - } + const maybeMermaidType = detectMermaidDiagramType(pastedText); + if (maybeMermaidType) { + const result = parseMermaidByType(pastedText, { architectureStrictMode }); + const diagnostics = normalizeParseDiagnostics(result.diagnostics); - if ('diagramType' in result && result.diagramType) { - updateTab(activeTabId, { diagramType: result.diagramType }); + if (!result.error) { + if (diagnostics.length > 0) { + setMermaidDiagnostics({ + source: 'paste', + diagramType: result.diagramType, + diagnostics, + updatedAt: Date.now(), + }); + addToast(`Imported with ${diagnostics.length} diagnostic warning(s).`, 'warning'); + } else { + clearMermaidDiagnostics(); + } + + recordHistory(); + + if (result.nodes.length > 0) { + const enrichedNodes = await enrichNodesWithIcons(result.nodes); + try { + const { getElkLayout, clearLayoutCache } = await import('@/services/elkLayout'); + clearLayoutCache(); + const layoutDirection = resolveLayoutDirection(result); + const { nodes: layoutedNodes, edges: layoutedEdges } = await getElkLayout( + enrichedNodes, + result.edges, + { + direction: layoutDirection, + algorithm: 'layered', + spacing: 'normal', } - - window.setTimeout(() => fitView({ duration: 600, padding: 0.2 }), 80); - return; + ); + const smartEdges = assignSmartHandles(layoutedNodes, layoutedEdges); + setNodes(layoutedNodes); + setEdges(smartEdges); + } catch { + setNodes(enrichedNodes); + setEdges(result.edges); } + } else { + setNodes(result.nodes); + setEdges(result.edges); + } - setMermaidDiagnostics({ - source: 'paste', - diagramType: result.diagramType ?? maybeMermaidType, - diagnostics, - error: result.error, - updatedAt: Date.now(), - }); + if ('diagramType' in result && result.diagramType) { + updateTab(activeTabId, { diagramType: result.diagramType }); + } - if (maybeMermaidType === 'architecture' && architectureStrictMode && result.error.includes('strict mode rejected')) { - addToast(strictModePasteBlockedMessage, 'error'); - return; - } + window.setTimeout(() => fitView({ duration: 600, padding: 0.2 }), 80); + return; + } - addToast(result.error, 'error'); - return; + setMermaidDiagnostics({ + source: 'paste', + diagramType: result.diagramType ?? maybeMermaidType, + diagnostics, + error: result.error, + updatedAt: Date.now(), + }); + + if ( + maybeMermaidType === 'architecture' && + architectureStrictMode && + result.error.includes('strict mode rejected') + ) { + addToast(strictModePasteBlockedMessage, 'error'); + return; } - const pasteFlowPosition = - getLastInteractionFlowPosition() ?? getCanvasCenterFlowPosition(); - - recordHistory(); - const { activeLayerId } = useFlowStore.getState(); - const newTextNode = createPastedTextNode(pastedText, pasteFlowPosition, activeLayerId); - - setNodes((existingNodes) => [ - ...existingNodes.map((node) => ({ ...node, selected: false })), - { ...newTextNode, selected: true }, - ]); - setSelectedNodeId(newTextNode.id); - }, [ - activeTabId, - addToast, - architectureStrictMode, - clearMermaidDiagnostics, - fitView, - getCanvasCenterFlowPosition, - pasteSelection, - getLastInteractionFlowPosition, - recordHistory, - setEdges, - setMermaidDiagnostics, - setNodes, - setSelectedNodeId, - strictModePasteBlockedMessage, - updateTab, - ]); - - return { - handleCanvasPaste, - }; + addToast(result.error, 'error'); + return; + } + + const pasteFlowPosition = getLastInteractionFlowPosition() ?? getCanvasCenterFlowPosition(); + + recordHistory(); + const { activeLayerId } = useFlowStore.getState(); + const newTextNode = createPastedTextNode(pastedText, pasteFlowPosition, activeLayerId); + + setNodes((existingNodes) => [ + ...existingNodes.map((node) => ({ ...node, selected: false })), + { ...newTextNode, selected: true }, + ]); + setSelectedNodeId(newTextNode.id); + }, + [ + activeTabId, + addToast, + architectureStrictMode, + clearMermaidDiagnostics, + fitView, + getCanvasCenterFlowPosition, + pasteSelection, + getLastInteractionFlowPosition, + recordHistory, + setEdges, + setMermaidDiagnostics, + setNodes, + setSelectedNodeId, + strictModePasteBlockedMessage, + updateTab, + ] + ); + + return { + handleCanvasPaste, + }; } diff --git a/src/config/rolloutFlags.ts b/src/config/rolloutFlags.ts index dd5b2c1a..712388b9 100644 --- a/src/config/rolloutFlags.ts +++ b/src/config/rolloutFlags.ts @@ -1,65 +1,97 @@ export type RolloutFlagKey = - | 'relationSemanticsV1' - | 'documentModelV2' - | 'collaborationEnabled' - | 'architectureLintEnabled'; + | 'relationSemanticsV1' + | 'documentModelV2' + | 'collaborationEnabled' + | 'architectureLintEnabled' + | 'importSql' + | 'importOpenApi' + | 'importInfraTerraformHcl' + | 'importCodebase'; interface RolloutFlagDefinition { - key: RolloutFlagKey; - envVar: string; - defaultEnabled: boolean; - description: string; + key: RolloutFlagKey; + envVar: string; + defaultEnabled: boolean; + description: string; } const ROLLOUT_FLAG_DEFINITIONS: Record = { - relationSemanticsV1: { - key: 'relationSemanticsV1', - envVar: 'VITE_RELATION_SEMANTICS_V1', - defaultEnabled: false, - description: 'Class/ER relation marker and routing semantics rollout', - }, - documentModelV2: { - key: 'documentModelV2', - envVar: 'VITE_DOCUMENT_MODEL_V2', - defaultEnabled: false, - description: 'Extended document metadata for scenes, exports, and bindings', - }, - collaborationEnabled: { - key: 'collaborationEnabled', - envVar: 'VITE_COLLABORATION_ENABLED', - defaultEnabled: true, - description: 'WebRTC peer collaboration (beta)', - }, - architectureLintEnabled: { - key: 'architectureLintEnabled', - envVar: 'VITE_ARCHITECTURE_LINT_ENABLED', - defaultEnabled: true, - description: 'Architecture diagram lint rules panel', - }, + relationSemanticsV1: { + key: 'relationSemanticsV1', + envVar: 'VITE_RELATION_SEMANTICS_V1', + defaultEnabled: false, + description: 'Class/ER relation marker and routing semantics rollout', + }, + documentModelV2: { + key: 'documentModelV2', + envVar: 'VITE_DOCUMENT_MODEL_V2', + defaultEnabled: false, + description: 'Extended document metadata for scenes, exports, and bindings', + }, + collaborationEnabled: { + key: 'collaborationEnabled', + envVar: 'VITE_COLLABORATION_ENABLED', + defaultEnabled: true, + description: 'WebRTC peer collaboration (beta)', + }, + architectureLintEnabled: { + key: 'architectureLintEnabled', + envVar: 'VITE_ARCHITECTURE_LINT_ENABLED', + defaultEnabled: true, + description: 'Architecture diagram lint rules panel', + }, + importSql: { + key: 'importSql', + envVar: 'VITE_IMPORT_SQL', + defaultEnabled: false, + description: 'SQL DDL importer (hidden — unreliable for complex schemas)', + }, + importOpenApi: { + key: 'importOpenApi', + envVar: 'VITE_IMPORT_OPENAPI', + defaultEnabled: false, + description: 'OpenAPI/Swagger importer (hidden — JSON-only, no YAML)', + }, + importInfraTerraformHcl: { + key: 'importInfraTerraformHcl', + envVar: 'VITE_IMPORT_INFRA_TERRAFORM_HCL', + defaultEnabled: false, + description: 'Terraform HCL importer (hidden — AI-only, hallucination-prone)', + }, + importCodebase: { + key: 'importCodebase', + envVar: 'VITE_IMPORT_CODEBASE', + defaultEnabled: false, + description: 'Repo/codebase analyzer importer (hidden — niche, heavy)', + }, }; function readBooleanEnvFlag(envValue: string | undefined, defaultEnabled: boolean): boolean { - if (envValue === '1') { - return true; - } - if (envValue === '0') { - return false; - } - return defaultEnabled; + if (envValue === '1') { + return true; + } + if (envValue === '0') { + return false; + } + return defaultEnabled; } export function isRolloutFlagEnabled(key: RolloutFlagKey): boolean { - const definition = ROLLOUT_FLAG_DEFINITIONS[key]; - if (!definition.envVar) { - return definition.defaultEnabled; - } - const envValue = import.meta.env[definition.envVar as keyof ImportMetaEnv] as string | undefined; - return readBooleanEnvFlag(envValue, definition.defaultEnabled); + const definition = ROLLOUT_FLAG_DEFINITIONS[key]; + if (!definition.envVar) { + return definition.defaultEnabled; + } + const envValue = import.meta.env[definition.envVar as keyof ImportMetaEnv] as string | undefined; + return readBooleanEnvFlag(envValue, definition.defaultEnabled); } export const ROLLOUT_FLAGS: Record = { - relationSemanticsV1: isRolloutFlagEnabled('relationSemanticsV1'), - documentModelV2: isRolloutFlagEnabled('documentModelV2'), - collaborationEnabled: isRolloutFlagEnabled('collaborationEnabled'), - architectureLintEnabled: isRolloutFlagEnabled('architectureLintEnabled'), + relationSemanticsV1: isRolloutFlagEnabled('relationSemanticsV1'), + documentModelV2: isRolloutFlagEnabled('documentModelV2'), + collaborationEnabled: isRolloutFlagEnabled('collaborationEnabled'), + architectureLintEnabled: isRolloutFlagEnabled('architectureLintEnabled'), + importSql: isRolloutFlagEnabled('importSql'), + importOpenApi: isRolloutFlagEnabled('importOpenApi'), + importInfraTerraformHcl: isRolloutFlagEnabled('importInfraTerraformHcl'), + importCodebase: isRolloutFlagEnabled('importCodebase'), }; diff --git a/src/hooks/ai-generation/requestLifecycle.ts b/src/hooks/ai-generation/requestLifecycle.ts index 3a6cab81..e374c271 100644 --- a/src/hooks/ai-generation/requestLifecycle.ts +++ b/src/hooks/ai-generation/requestLifecycle.ts @@ -3,13 +3,13 @@ import { serializeCanvasContextForAI } from '@/services/ai/contextSerializer'; import { generateDiagramFromChat, type ChatMessage } from '@/services/aiService'; import type { FlowEdge, FlowNode, GlobalEdgeOptions } from '@/lib/types'; import type { AISettings } from '@/store/types'; +import { buildIdMap, parseDslOrThrow, toFinalEdges, toFinalNodes } from './graphComposer'; import { - buildIdMap, - parseDslOrThrow, - toFinalEdges, - toFinalNodes, -} from './graphComposer'; -import { applyAIResultToCanvas, positionNewNodesSmartly, restoreExistingPositions } from './positionPreservingApply'; + applyAIResultToCanvas, + positionNewNodesSmartly, + restoreExistingPositions, +} from './positionPreservingApply'; +import { enrichNodesWithIcons } from '@/lib/nodeEnricher'; interface GenerateAIFlowResultParams { chatMessages: ChatMessage[]; @@ -34,7 +34,12 @@ function isRetryableError(error: unknown): boolean { if (error instanceof Error) { const msg = error.message.toLowerCase(); // Retry on rate-limit and network errors, not on auth or parse errors - return msg.includes('429') || msg.includes('rate') || msg.includes('network') || msg.includes('fetch'); + return ( + msg.includes('429') || + msg.includes('rate') || + msg.includes('network') || + msg.includes('fetch') + ); } return false; } @@ -42,7 +47,7 @@ function isRetryableError(error: unknown): boolean { async function withRetry( fn: () => Promise, signal: AbortSignal | undefined, - onRetry?: (attempt: number) => void, + onRetry?: (attempt: number) => void ): Promise { for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); @@ -69,9 +74,11 @@ export interface GenerateAIFlowResult { export function buildUserChatMessage(prompt: string, imageBase64?: string): ChatMessage { return { role: 'user', - parts: [{ - text: imageBase64 ? `${prompt} [Image Attached]` : prompt, - }], + parts: [ + { + text: imageBase64 ? `${prompt} [Image Attached]` : prompt, + }, + ], }; } @@ -82,11 +89,7 @@ export function appendChatExchange( editMode = false ): ChatMessage[] { const modelText = editMode ? '[Diagram updated]' : dslText; - return [ - ...previousMessages, - userMessage, - { role: 'model', parts: [{ text: modelText }] }, - ]; + return [...previousMessages, userMessage, { role: 'model', parts: [{ text: modelText }] }]; } function buildSelectionPromptSuffix(selectedNodeIds: string[], nodes: FlowNode[]): string { @@ -128,22 +131,23 @@ export async function generateAIFlowResult({ for (let attempt = 0; attempt <= 1; attempt++) { dslText = await withRetry( - () => generateDiagramFromChat( - chatMessages, - activePrompt, - currentGraph, - imageBase64, - aiSettings.apiKey, - aiSettings.model, - aiSettings.provider || 'gemini', - aiSettings.customBaseUrl, - isEditMode, - onChunk, - signal, - aiSettings.temperature, - ), + () => + generateDiagramFromChat( + chatMessages, + activePrompt, + currentGraph, + imageBase64, + aiSettings.apiKey, + aiSettings.model, + aiSettings.provider || 'gemini', + aiSettings.customBaseUrl, + isEditMode, + onChunk, + signal, + aiSettings.temperature + ), signal, - onRetry, + onRetry ); try { parsed = parseDslOrThrow(dslText); @@ -157,7 +161,7 @@ export async function generateAIFlowResult({ } parsed = parsed!; const idMap = buildIdMap(parsed.nodes, nodes); - const finalNodes = toFinalNodes(parsed.nodes, idMap); + const finalNodes = await enrichNodesWithIcons(toFinalNodes(parsed.nodes, idMap)); const finalEdges = toFinalEdges(parsed.edges, idMap, globalEdgeOptions); const isEmptyCanvas = nodes.length === 0; @@ -167,7 +171,12 @@ export async function generateAIFlowResult({ finalEdges, { direction: 'TB', algorithm: 'mrtree', spacing: 'loose' } ); - return { dslText, userMessage: buildUserChatMessage(prompt, imageBase64), layoutedNodes, layoutedEdges }; + return { + dslText, + userMessage: buildUserChatMessage(prompt, imageBase64), + layoutedNodes, + layoutedEdges, + }; } // Position-preserving apply: matched nodes keep their positions, new nodes get ELK positions @@ -188,7 +197,12 @@ export async function generateAIFlowResult({ } // Smart placement: position new nodes near their existing neighbors - const smartPositioned = positionNewNodesSmartly(mergedNodes, mergedEdges, newNodeIds, existingById); + const smartPositioned = positionNewNodesSmartly( + mergedNodes, + mergedEdges, + newNodeIds, + existingById + ); const unplacedIds = [...newNodeIds].filter((id) => { const node = smartPositioned.find((n) => n.id === id); return !node?.position || (node.position.x === 0 && node.position.y === 0); diff --git a/src/hooks/useFlowEditorCallbacks.ts b/src/hooks/useFlowEditorCallbacks.ts index def03d94..0903a26c 100644 --- a/src/hooks/useFlowEditorCallbacks.ts +++ b/src/hooks/useFlowEditorCallbacks.ts @@ -4,152 +4,182 @@ import { useFlowStore } from '@/store'; import { composeDiagramForDisplay } from '@/services/composeDiagramForDisplay'; interface UseFlowEditorCallbacksParams { - addPage: () => string; - closePage: (pageId: string) => void; - reorderPage: (draggedPageId: string, targetPageId: string) => void; - updatePage: (pageId: string, update: Partial<{ name: string }>) => void; - navigate: (path: string) => void; - pagesLength: number; - cannotCloseLastTabMessage: string; - setNodes: (nodes: FlowNode[] | ((nodes: FlowNode[]) => FlowNode[])) => void; - setEdges: (edges: FlowEdge[] | ((edges: FlowEdge[]) => FlowEdge[])) => void; - restoreSnapshot: (snapshot: FlowSnapshot, setNodes: UseFlowEditorCallbacksParams['setNodes'], setEdges: UseFlowEditorCallbacksParams['setEdges']) => void; - recordHistory: () => void; - fitView: (options?: { duration?: number; padding?: number }) => void; - screenToFlowPosition: (position: { x: number; y: number }) => { x: number; y: number }; + addPage: () => string; + closePage: (pageId: string) => void; + reorderPage: (draggedPageId: string, targetPageId: string) => void; + updatePage: (pageId: string, update: Partial<{ name: string }>) => void; + navigate: (path: string) => void; + pagesLength: number; + cannotCloseLastTabMessage: string; + setNodes: (nodes: FlowNode[] | ((nodes: FlowNode[]) => FlowNode[])) => void; + setEdges: (edges: FlowEdge[] | ((edges: FlowEdge[]) => FlowEdge[])) => void; + restoreSnapshot: ( + snapshot: FlowSnapshot, + setNodes: UseFlowEditorCallbacksParams['setNodes'], + setEdges: UseFlowEditorCallbacksParams['setEdges'] + ) => void; + recordHistory: () => void; + fitView: (options?: { duration?: number; padding?: number }) => void; + screenToFlowPosition: (position: { x: number; y: number }) => { x: number; y: number }; } interface UseFlowEditorCallbacksResult { - getCenter: () => { x: number; y: number }; - handleSwitchPage: (pageId: string) => void; - handleAddPage: () => void; - handleClosePage: (pageId: string) => void; - handleRenamePage: (pageId: string, newName: string) => void; - handleReorderPage: (draggedPageId: string, targetPageId: string) => void; - selectAll: () => void; - handleRestoreSnapshot: (snapshot: FlowSnapshot) => void; - handleCommandBarApply: (newNodes: FlowNode[], newEdges: FlowEdge[]) => void; + getCenter: () => { x: number; y: number }; + handleSwitchPage: (pageId: string) => void; + handleAddPage: () => void; + handleClosePage: (pageId: string) => void; + handleRenamePage: (pageId: string, newName: string) => void; + handleReorderPage: (draggedPageId: string, targetPageId: string) => void; + selectAll: () => void; + handleRestoreSnapshot: (snapshot: FlowSnapshot) => void; + handleCommandBarApply: (newNodes: FlowNode[], newEdges: FlowEdge[]) => void; } export function useFlowEditorCallbacks({ - addPage, - closePage, - reorderPage, - updatePage, - navigate, - pagesLength, - cannotCloseLastTabMessage, - setNodes, - setEdges, - restoreSnapshot, - recordHistory, - fitView, - screenToFlowPosition, + addPage, + closePage, + reorderPage, + updatePage, + navigate, + pagesLength, + cannotCloseLastTabMessage, + setNodes, + setEdges, + restoreSnapshot, + recordHistory, + fitView, + screenToFlowPosition, }: UseFlowEditorCallbacksParams): UseFlowEditorCallbacksResult { - const stabilizationRunIdRef = useRef(0); - - const getCenter = useCallback(() => { - const centerX = window.innerWidth / 2; - const centerY = window.innerHeight / 2; - return screenToFlowPosition({ x: centerX, y: centerY }); - }, [screenToFlowPosition]); - - const handleSwitchPage = useCallback((pageId: string) => { - navigate(`/flow/${pageId}`); - }, [navigate]); - - const handleAddPage = useCallback(() => { - const newId = addPage(); - navigate(`/flow/${newId}`); - }, [addPage, navigate]); - - const handleClosePage = useCallback((pageId: string) => { - if (pagesLength === 1) { - alert(cannotCloseLastTabMessage); + const stabilizationRunIdRef = useRef(0); + + const getCenter = useCallback(() => { + const centerX = window.innerWidth / 2; + const centerY = window.innerHeight / 2; + return screenToFlowPosition({ x: centerX, y: centerY }); + }, [screenToFlowPosition]); + + const handleSwitchPage = useCallback( + (pageId: string) => { + navigate(`/flow/${pageId}`); + }, + [navigate] + ); + + const handleAddPage = useCallback(() => { + const newId = addPage(); + navigate(`/flow/${newId}`); + }, [addPage, navigate]); + + const handleClosePage = useCallback( + (pageId: string) => { + if (pagesLength === 1) { + alert(cannotCloseLastTabMessage); + return; + } + closePage(pageId); + }, + [cannotCloseLastTabMessage, closePage, pagesLength] + ); + + const handleRenamePage = useCallback( + (pageId: string, newName: string) => { + updatePage(pageId, { name: newName }); + }, + [updatePage] + ); + + const handleReorderPage = useCallback( + (draggedPageId: string, targetPageId: string) => { + reorderPage(draggedPageId, targetPageId); + }, + [reorderPage] + ); + + const selectAll = useCallback(() => { + setNodes((nodes) => nodes.map((node) => ({ ...node, selected: true }))); + setEdges((edges) => edges.map((edge) => ({ ...edge, selected: true }))); + }, [setEdges, setNodes]); + + const handleRestoreSnapshot = useCallback( + (snapshot: FlowSnapshot) => { + restoreSnapshot(snapshot, setNodes, setEdges); + recordHistory(); + }, + [recordHistory, restoreSnapshot, setEdges, setNodes] + ); + + const handleCommandBarApply = useCallback( + async (newNodes: FlowNode[], newEdges: FlowEdge[]) => { + const { enrichNodesWithIcons } = await import('@/lib/nodeEnricher'); + const enrichedNodes = await enrichNodesWithIcons(newNodes); + recordHistory(); + startTransition(() => { + setNodes( + enrichedNodes.map((node, index) => ({ + ...node, + data: { ...node.data, freshlyAdded: true, animateDelay: Math.min(index * 20, 400) }, + })) + ); + setEdges(newEdges); + }); + setTimeout(() => fitView({ duration: 800, padding: 0.2 }), 100); + + const runId = stabilizationRunIdRef.current + 1; + stabilizationRunIdRef.current = runId; + + window.setTimeout(() => { + void (async () => { + if (stabilizationRunIdRef.current !== runId) { return; - } - closePage(pageId); - }, [cannotCloseLastTabMessage, closePage, pagesLength]); - - const handleRenamePage = useCallback((pageId: string, newName: string) => { - updatePage(pageId, { name: newName }); - }, [updatePage]); - - const handleReorderPage = useCallback((draggedPageId: string, targetPageId: string) => { - reorderPage(draggedPageId, targetPageId); - }, [reorderPage]); - - const selectAll = useCallback(() => { - setNodes((nodes) => nodes.map((node) => ({ ...node, selected: true }))); - setEdges((edges) => edges.map((edge) => ({ ...edge, selected: true }))); - }, [setEdges, setNodes]); - - const handleRestoreSnapshot = useCallback((snapshot: FlowSnapshot) => { - restoreSnapshot(snapshot, setNodes, setEdges); - recordHistory(); - }, [recordHistory, restoreSnapshot, setEdges, setNodes]); - - const handleCommandBarApply = useCallback((newNodes: FlowNode[], newEdges: FlowEdge[]) => { - recordHistory(); - startTransition(() => { - setNodes(newNodes.map((node, index) => ({ - ...node, - data: { ...node.data, freshlyAdded: true, animateDelay: Math.min(index * 20, 400) }, - }))); - setEdges(newEdges); - }); - setTimeout(() => fitView({ duration: 800, padding: 0.2 }), 100); - - const runId = stabilizationRunIdRef.current + 1; - stabilizationRunIdRef.current = runId; - - window.setTimeout(() => { - void (async () => { - if (stabilizationRunIdRef.current !== runId) { - return; - } - - const state = useFlowStore.getState(); - const measuredNodes = state.nodes; - const measuredEdges = state.edges; - const hasMeasuredDimensions = measuredNodes.some((node) => { - const measured = (node as FlowNode & { - measured?: { width?: number; height?: number }; - }).measured; - return typeof measured?.width === 'number' && typeof measured?.height === 'number'; - }); - - if (!hasMeasuredDimensions) { - return; - } - - const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId); - const { nodes: stabilizedNodes, edges: stabilizedEdges } = await composeDiagramForDisplay( - measuredNodes, - measuredEdges, - { diagramType: activeTab?.diagramType } - ); - - if (stabilizationRunIdRef.current !== runId) { - return; - } - - setNodes(stabilizedNodes); - setEdges(stabilizedEdges); - fitView({ duration: 500, padding: 0.2 }); - })(); - }, 180); - }, [fitView, recordHistory, setEdges, setNodes]); - - return { - getCenter, - handleSwitchPage, - handleAddPage, - handleClosePage, - handleRenamePage, - handleReorderPage, - selectAll, - handleRestoreSnapshot, - handleCommandBarApply, - }; + } + + const state = useFlowStore.getState(); + const measuredNodes = state.nodes; + const measuredEdges = state.edges; + const hasMeasuredDimensions = measuredNodes.some((node) => { + const measured = ( + node as FlowNode & { + measured?: { width?: number; height?: number }; + } + ).measured; + return typeof measured?.width === 'number' && typeof measured?.height === 'number'; + }); + + if (!hasMeasuredDimensions) { + return; + } + + const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId); + const { clearLayoutCache } = await import('@/services/elkLayout'); + clearLayoutCache(); + const { nodes: stabilizedNodes, edges: stabilizedEdges } = await composeDiagramForDisplay( + measuredNodes, + measuredEdges, + { diagramType: activeTab?.diagramType } + ); + + if (stabilizationRunIdRef.current !== runId) { + return; + } + + setNodes(stabilizedNodes); + setEdges(stabilizedEdges); + fitView({ duration: 500, padding: 0.2 }); + })(); + }, 180); + }, + [fitView, recordHistory, setEdges, setNodes] + ); + + return { + getCenter, + handleSwitchPage, + handleAddPage, + handleClosePage, + handleRenamePage, + handleReorderPage, + selectAll, + handleRestoreSnapshot, + handleCommandBarApply, + }; } diff --git a/src/lib/iconResolver.test.ts b/src/lib/iconResolver.test.ts new file mode 100644 index 00000000..61d13fe5 --- /dev/null +++ b/src/lib/iconResolver.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { resolveIconSync, resolveLucideFallback } from './iconResolver'; + +describe('resolveIconSync', () => { + it('resolves PostgreSQL alias', () => { + const result = resolveIconSync('PostgreSQL'); + expect(result.found).toBe(true); + expect(result.iconSearch).toBe('postgresql'); + expect(result.catalog).toBe('developer'); + expect(result.confidence).toBeGreaterThan(0.9); + }); + + it('resolves shorthand aliases', () => { + expect(resolveIconSync('postgres').iconSearch).toBe('postgresql'); + expect(resolveIconSync('pg').iconSearch).toBe('postgresql'); + expect(resolveIconSync('mongo').iconSearch).toBe('mongodb'); + expect(resolveIconSync('k8s').iconSearch).toBe('kubernetes'); + }); + + it('resolves framework aliases', () => { + expect(resolveIconSync('React').catalog).toBe('developer'); + expect(resolveIconSync('Next.js').iconSearch).toBe('nextjs'); + expect(resolveIconSync('Express').iconSearch).toBe('express'); + expect(resolveIconSync('Django').iconSearch).toBe('django'); + expect(resolveIconSync('FastAPI').iconSearch).toBe('fastapi'); + }); + + it('resolves infrastructure aliases', () => { + expect(resolveIconSync('Docker').catalog).toBe('developer'); + expect(resolveIconSync('Kubernetes').catalog).toBe('cncf'); + expect(resolveIconSync('nginx').iconSearch).toBe('nginx'); + expect(resolveIconSync('RabbitMQ').iconSearch).toBe('rabbitmq'); + expect(resolveIconSync('Kafka').iconSearch).toBe('apachekafka'); + }); + + it('resolves cloud service aliases', () => { + expect(resolveIconSync('S3').catalog).toBe('aws'); + expect(resolveIconSync('Lambda').catalog).toBe('aws'); + expect(resolveIconSync('Cloud Run').catalog).toBe('gcp'); + expect(resolveIconSync('Azure Functions').catalog).toBe('azure'); + }); + + it('returns not found for unknown queries', () => { + const result = resolveIconSync('RandomThing123'); + expect(result.found).toBe(false); + expect(result.confidence).toBe(0); + }); + + it('uses category fallback when alias not found', () => { + const result = resolveIconSync('MyCustomDB', 'database'); + expect(result.found).toBe(true); + expect(result.lucideIcon).toBe('database'); + expect(result.confidence).toBe(0.5); + }); + + it('handles empty query', () => { + expect(resolveIconSync('').found).toBe(false); + expect(resolveIconSync(' ').found).toBe(false); + }); +}); + +describe('resolveLucideFallback', () => { + it('returns correct fallback icons', () => { + expect(resolveLucideFallback('database')).toBe('database'); + expect(resolveLucideFallback('cache')).toBe('hard-drive'); + expect(resolveLucideFallback('service')).toBe('server'); + expect(resolveLucideFallback('frontend')).toBe('monitor'); + expect(resolveLucideFallback('user')).toBe('user'); + expect(resolveLucideFallback('gateway')).toBe('shield'); + }); + + it('returns box for unknown categories', () => { + expect(resolveLucideFallback('unknown')).toBe('box'); + }); +}); diff --git a/src/lib/iconResolver.ts b/src/lib/iconResolver.ts new file mode 100644 index 00000000..35fb684f --- /dev/null +++ b/src/lib/iconResolver.ts @@ -0,0 +1,425 @@ +import type { DomainLibraryCategory } from '@/services/domainLibrary'; + +export interface IconResolution { + found: boolean; + archIconPackId?: string; + archIconShapeId?: string; + iconSearch?: string; + catalog?: DomainLibraryCategory; + lucideIcon?: string; + label?: string; + category?: string; + confidence: number; +} + +interface AliasEntry { + patterns: RegExp[]; + iconSearch: string; + catalog: DomainLibraryCategory; + lucideFallback: string; +} + +const ALIAS_TABLE: AliasEntry[] = [ + // Databases + { + patterns: [/^postgres(?:ql)?$/i, /^pg$/i], + iconSearch: 'postgresql', + catalog: 'developer', + lucideFallback: 'database', + }, + { patterns: [/^mysql$/i], iconSearch: 'mysql', catalog: 'developer', lucideFallback: 'database' }, + { + patterns: [/^mongo(?:db)?$/i], + iconSearch: 'mongodb', + catalog: 'developer', + lucideFallback: 'database', + }, + { + patterns: [/^redis$/i], + iconSearch: 'redis', + catalog: 'developer', + lucideFallback: 'hard-drive', + }, + { + patterns: [/^elastic(?:search)?$/i], + iconSearch: 'elasticsearch', + catalog: 'developer', + lucideFallback: 'search', + }, + { patterns: [/^dynamodb$/i], iconSearch: 'dynamodb', catalog: 'aws', lucideFallback: 'database' }, + { patterns: [/^aurora$/i], iconSearch: 'aurora', catalog: 'aws', lucideFallback: 'database' }, + { + patterns: [/^sqlite$/i], + iconSearch: 'sqlite', + catalog: 'developer', + lucideFallback: 'database', + }, + { + patterns: [/^mariadb$/i], + iconSearch: 'mariadb', + catalog: 'developer', + lucideFallback: 'database', + }, + { + patterns: [/^cassandra$/i], + iconSearch: 'cassandra', + catalog: 'developer', + lucideFallback: 'database', + }, + { patterns: [/^neo4j$/i], iconSearch: 'neo4j', catalog: 'developer', lucideFallback: 'database' }, + { + patterns: [/^supabase$/i], + iconSearch: 'supabase', + catalog: 'developer', + lucideFallback: 'database', + }, + { + patterns: [/^planetscale$/i], + iconSearch: 'planetscale', + catalog: 'developer', + lucideFallback: 'database', + }, + { patterns: [/^neon\b/i], iconSearch: 'neon', catalog: 'developer', lucideFallback: 'database' }, + + // Frameworks + { + patterns: [/^express(?:\.?js)?$/i], + iconSearch: 'express', + catalog: 'developer', + lucideFallback: 'server', + }, + { + patterns: [/^node(?:\.?js)?$/i], + iconSearch: 'nodejs', + catalog: 'developer', + lucideFallback: 'server', + }, + { + patterns: [/^react(?:\.?js)?$/i], + iconSearch: 'react', + catalog: 'developer', + lucideFallback: 'monitor', + }, + { + patterns: [/^vue(?:\.?js)?$/i], + iconSearch: 'vue', + catalog: 'developer', + lucideFallback: 'monitor', + }, + { + patterns: [/^angular$/i], + iconSearch: 'angular', + catalog: 'developer', + lucideFallback: 'monitor', + }, + { + patterns: [/^svelte$/i], + iconSearch: 'svelte', + catalog: 'developer', + lucideFallback: 'monitor', + }, + { + patterns: [/^next(?:\.?js)?$/i], + iconSearch: 'nextjs', + catalog: 'developer', + lucideFallback: 'monitor', + }, + { patterns: [/^nuxt$/i], iconSearch: 'nuxt', catalog: 'developer', lucideFallback: 'monitor' }, + { patterns: [/^django$/i], iconSearch: 'django', catalog: 'developer', lucideFallback: 'server' }, + { patterns: [/^flask$/i], iconSearch: 'flask', catalog: 'developer', lucideFallback: 'server' }, + { + patterns: [/^fastapi$/i], + iconSearch: 'fastapi', + catalog: 'developer', + lucideFallback: 'server', + }, + { + patterns: [/^spring(?:\s*boot)?$/i], + iconSearch: 'spring', + catalog: 'developer', + lucideFallback: 'server', + }, + { + patterns: [/^rails$/i, /^ruby$/i], + iconSearch: 'rails', + catalog: 'developer', + lucideFallback: 'server', + }, + { + patterns: [/^laravel$/i], + iconSearch: 'laravel', + catalog: 'developer', + lucideFallback: 'server', + }, + { + patterns: [/^nest(?:\.?js)?$/i], + iconSearch: 'nestjs', + catalog: 'developer', + lucideFallback: 'server', + }, + { patterns: [/^gin$/i], iconSearch: 'gin', catalog: 'developer', lucideFallback: 'server' }, + { + patterns: [/^go$/i, /^golang$/i], + iconSearch: 'go', + catalog: 'developer', + lucideFallback: 'server', + }, + { patterns: [/^rust$/i], iconSearch: 'rust', catalog: 'developer', lucideFallback: 'server' }, + { patterns: [/^deno$/i], iconSearch: 'deno', catalog: 'developer', lucideFallback: 'server' }, + { patterns: [/^bun$/i], iconSearch: 'bun', catalog: 'developer', lucideFallback: 'server' }, + + // Infrastructure + { + patterns: [/^docker$/i], + iconSearch: 'docker', + catalog: 'developer', + lucideFallback: 'container', + }, + { + patterns: [/^kubernetes$/i, /^k8s$/i], + iconSearch: 'kubernetes', + catalog: 'cncf', + lucideFallback: 'container', + }, + { patterns: [/^nginx$/i], iconSearch: 'nginx', catalog: 'developer', lucideFallback: 'shield' }, + { + patterns: [/^rabbitmq$/i], + iconSearch: 'rabbitmq', + catalog: 'developer', + lucideFallback: 'layers', + }, + { + patterns: [/^kafka$/i, /^apache\s*kafka$/i], + iconSearch: 'apachekafka', + catalog: 'developer', + lucideFallback: 'layers', + }, + { + patterns: [/^consul$/i], + iconSearch: 'consul', + catalog: 'developer', + lucideFallback: 'map-pin', + }, + { patterns: [/^vault$/i], iconSearch: 'vault', catalog: 'developer', lucideFallback: 'lock' }, + { + patterns: [/^terraform$/i], + iconSearch: 'terraform', + catalog: 'developer', + lucideFallback: 'layers', + }, + { + patterns: [/^ansible$/i], + iconSearch: 'ansible', + catalog: 'developer', + lucideFallback: 'settings', + }, + { + patterns: [/^prometheus$/i], + iconSearch: 'prometheus', + catalog: 'developer', + lucideFallback: 'activity', + }, + { + patterns: [/^grafana$/i], + iconSearch: 'grafana', + catalog: 'developer', + lucideFallback: 'bar-chart', + }, + { + patterns: [/^jenkins$/i], + iconSearch: 'jenkins', + catalog: 'developer', + lucideFallback: 'settings', + }, + { + patterns: [/^gitlab$/i], + iconSearch: 'gitlab', + catalog: 'developer', + lucideFallback: 'git-branch', + }, + { + patterns: [/^github$/i], + iconSearch: 'github', + catalog: 'developer', + lucideFallback: 'git-branch', + }, + { patterns: [/^helm$/i], iconSearch: 'helm', catalog: 'cncf', lucideFallback: 'package' }, + { patterns: [/^istio$/i], iconSearch: 'istio', catalog: 'cncf', lucideFallback: 'network' }, + { patterns: [/^envoy$/i], iconSearch: 'envoy', catalog: 'cncf', lucideFallback: 'network' }, + { + patterns: [/^grafana\s*tempo$/i, /^tempo$/i], + iconSearch: 'grafana-tempo', + catalog: 'developer', + lucideFallback: 'activity', + }, + + // Cloud services + { patterns: [/^s3$/i], iconSearch: 's3', catalog: 'aws', lucideFallback: 'folder' }, + { patterns: [/^lambda$/i], iconSearch: 'lambda', catalog: 'aws', lucideFallback: 'zap' }, + { patterns: [/^ec2$/i], iconSearch: 'ec2', catalog: 'aws', lucideFallback: 'server' }, + { patterns: [/^ecs$/i], iconSearch: 'ecs', catalog: 'aws', lucideFallback: 'container' }, + { patterns: [/^eks$/i], iconSearch: 'eks', catalog: 'aws', lucideFallback: 'container' }, + { patterns: [/^rds$/i], iconSearch: 'rds', catalog: 'aws', lucideFallback: 'database' }, + { + patterns: [/^api\s*gateway$/i], + iconSearch: 'api-gateway', + catalog: 'aws', + lucideFallback: 'shield', + }, + { + patterns: [/^cloudfront$/i], + iconSearch: 'cloudfront', + catalog: 'aws', + lucideFallback: 'globe', + }, + { patterns: [/^sqs$/i], iconSearch: 'sqs', catalog: 'aws', lucideFallback: 'layers' }, + { patterns: [/^sns$/i], iconSearch: 'sns', catalog: 'aws', lucideFallback: 'bell' }, + { patterns: [/^cognito$/i], iconSearch: 'cognito', catalog: 'aws', lucideFallback: 'key' }, + { + patterns: [/^cloud\s*run$/i], + iconSearch: 'cloud-run', + catalog: 'gcp', + lucideFallback: 'container', + }, + { + patterns: [/^cloud\s*functions$/i], + iconSearch: 'cloud-functions', + catalog: 'gcp', + lucideFallback: 'zap', + }, + { patterns: [/^bigquery$/i], iconSearch: 'bigquery', catalog: 'gcp', lucideFallback: 'database' }, + { + patterns: [/^azure\s*functions$/i], + iconSearch: 'azure-functions', + catalog: 'azure', + lucideFallback: 'zap', + }, + { + patterns: [/^azure\s*sql$/i], + iconSearch: 'azure-sql', + catalog: 'azure', + lucideFallback: 'database', + }, + + // Messaging / Streaming + { patterns: [/^pulsar$/i], iconSearch: 'pulsar', catalog: 'developer', lucideFallback: 'layers' }, + { patterns: [/^nats$/i], iconSearch: 'nats', catalog: 'developer', lucideFallback: 'layers' }, + { + patterns: [/^zeromq$/i, /^0mq$/i], + iconSearch: 'zeromq', + catalog: 'developer', + lucideFallback: 'layers', + }, + + // Auth + { patterns: [/^auth0$/i], iconSearch: 'auth0', catalog: 'developer', lucideFallback: 'key' }, + { + patterns: [/^keycloak$/i], + iconSearch: 'keycloak', + catalog: 'developer', + lucideFallback: 'key', + }, + { + patterns: [/^firebase$/i], + iconSearch: 'firebase', + catalog: 'developer', + lucideFallback: 'flame', + }, + { + patterns: [/^supertokens$/i, /^super\s*tokens$/i], + iconSearch: 'supertokens', + catalog: 'developer', + lucideFallback: 'key', + }, + + // Payments / SaaS + { + patterns: [/^stripe$/i], + iconSearch: 'stripe', + catalog: 'developer', + lucideFallback: 'credit-card', + }, + { patterns: [/^twilio$/i], iconSearch: 'twilio', catalog: 'developer', lucideFallback: 'phone' }, + { + patterns: [/^sendgrid$/i], + iconSearch: 'sendgrid', + catalog: 'developer', + lucideFallback: 'mail', + }, + { + patterns: [/^mailchimp$/i], + iconSearch: 'mailchimp', + catalog: 'developer', + lucideFallback: 'mail', + }, + { + patterns: [/^cloudflare$/i], + iconSearch: 'cloudflare', + catalog: 'developer', + lucideFallback: 'cloud', + }, + { + patterns: [/^vercel$/i], + iconSearch: 'vercel', + catalog: 'developer', + lucideFallback: 'triangle', + }, + { + patterns: [/^netlify$/i], + iconSearch: 'netlify', + catalog: 'developer', + lucideFallback: 'globe', + }, +]; + +const LUCIDE_FALLBACK_MAP: Record = { + database: 'database', + cache: 'hard-drive', + queue: 'layers', + service: 'server', + frontend: 'monitor', + gateway: 'shield', + auth: 'key-round', + storage: 'folder', + user: 'user', + start: 'play', + end: 'check-circle', + decision: 'help-circle', + action: 'zap', + process: 'box', +}; + +export function resolveIconSync(query: string, categoryHint?: string): IconResolution { + const trimmed = query.trim(); + if (!trimmed) { + return { found: false, confidence: 0 }; + } + + for (const entry of ALIAS_TABLE) { + if (entry.patterns.some((p) => p.test(trimmed))) { + return { + found: true, + iconSearch: entry.iconSearch, + catalog: entry.catalog, + lucideIcon: entry.lucideFallback, + label: trimmed, + confidence: 0.95, + }; + } + } + + if (categoryHint && LUCIDE_FALLBACK_MAP[categoryHint]) { + return { + found: true, + lucideIcon: LUCIDE_FALLBACK_MAP[categoryHint], + label: trimmed, + confidence: 0.5, + }; + } + + return { found: false, confidence: 0 }; +} + +export function resolveLucideFallback(category: string): string { + return LUCIDE_FALLBACK_MAP[category] ?? 'box'; +} diff --git a/src/lib/mermaidEnrichmentPipeline.test.ts b/src/lib/mermaidEnrichmentPipeline.test.ts new file mode 100644 index 00000000..e5be9d29 --- /dev/null +++ b/src/lib/mermaidEnrichmentPipeline.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest'; +import { parseMermaidByType } from '@/services/mermaid/parseMermaidByType'; +import { enrichNodesWithIcons } from '@/lib/nodeEnricher'; + +describe('Mermaid → Enrichment Pipeline (E2E)', () => { + it('flowchart: assigns colors and icons to all node types', async () => { + const mermaid = ` + flowchart TD + S([Start]) --> login[Login Form] + login --> valid{Credentials Valid?} + valid -->|Yes| db[(PostgreSQL)] + valid -->|No| fail((Access Denied)) + db --> redis[Redis Cache] + redis --> done((Dashboard)) + `; + + const parsed = parseMermaidByType(mermaid); + expect(parsed.error).toBeUndefined(); + expect(parsed.nodes.length).toBeGreaterThan(0); + + const enriched = await enrichNodesWithIcons(parsed.nodes); + + const startNode = enriched.find((n) => n.id === 'S'); + expect(startNode?.data.color).toBe('emerald'); + expect(startNode?.data.icon).toBe('play'); + + const endNode = enriched.find((n) => n.id === 'fail'); + expect(endNode?.data.color).toBe('red'); + expect(endNode?.data.icon).toBe('check-circle'); + + const decisionNode = enriched.find((n) => n.id === 'valid'); + expect(decisionNode?.data.color).toBe('amber'); + expect(decisionNode?.data.icon).toBe('help-circle'); + + const dbNode = enriched.find((n) => n.id === 'db'); + expect(dbNode?.data.color).toBe('violet'); + expect(dbNode?.data.icon).toBe('database'); + + const redisNode = enriched.find((n) => n.id === 'redis'); + expect(redisNode?.data.color).toBe('red'); + expect(redisNode?.data.icon).toBe('hard-drive'); + }); + + it('flowchart with subgraphs: creates section nodes with proper hierarchy', async () => { + const mermaid = ` + flowchart TD + subgraph Backend + API[Express API] + DB[(PostgreSQL)] + end + subgraph Frontend + UI[React App] + end + UI --> API + API --> DB + `; + + const parsed = parseMermaidByType(mermaid); + expect(parsed.error).toBeUndefined(); + + const enriched = await enrichNodesWithIcons(parsed.nodes); + + const sectionNodes = enriched.filter((n) => n.type === 'section'); + expect(sectionNodes).toHaveLength(2); + + const backendSection = sectionNodes.find((n) => n.data.label === 'Backend'); + expect(backendSection).toBeDefined(); + + const apiNode = enriched.find((n) => n.id === 'API'); + expect(apiNode?.parentId).toBe(backendSection?.id); + expect(apiNode?.data.color).toBe('blue'); + + const dbNode = enriched.find((n) => n.id === 'DB'); + expect(dbNode?.parentId).toBe(backendSection?.id); + expect(dbNode?.data.color).toBe('violet'); + }); + + it('sequence diagram: parses participants and messages', async () => { + const mermaid = ` + sequenceDiagram + participant Client + participant Server + participant Database + Client->>Server: HTTP Request + Server->>Database: SQL Query + Database-->>Server: Results + Server-->>Client: JSON Response + `; + + const parsed = parseMermaidByType(mermaid); + expect(parsed.error).toBeUndefined(); + expect(parsed.diagramType).toBe('sequence'); + expect(parsed.nodes.length).toBeGreaterThanOrEqual(3); + expect(parsed.edges.length).toBeGreaterThanOrEqual(4); + }); + + it('sequence diagram: handles fragments (alt/loop) and activations', async () => { + const mermaid = ` + sequenceDiagram + participant A + participant B + A->>B: Request + activate B + alt success + B-->>A: 200 OK + else failure + B-->>A: 500 Error + end + loop every minute + A->>B: Heartbeat + end + deactivate B + `; + + const parsed = parseMermaidByType(mermaid); + expect(parsed.error).toBeUndefined(); + expect(parsed.diagramType).toBe('sequence'); + expect(parsed.nodes.length).toBeGreaterThanOrEqual(2); + }); + + it('sequence diagram: handles notes over participants', async () => { + const mermaid = ` + sequenceDiagram + participant A + participant B + Note over A,B: This is a note + A->>B: Message + `; + + const parsed = parseMermaidByType(mermaid); + expect(parsed.error).toBeUndefined(); + expect(parsed.nodes.length).toBeGreaterThanOrEqual(2); + }); + + it('architecture diagram: preserves archIconPackId when set by AI', async () => { + // Simulate AI-generated nodes with provider icons already set + const aiGeneratedNodes = [ + { + id: 'api_gw', + type: 'architecture' as const, + position: { x: 0, y: 0 }, + data: { + label: 'API Gateway', + subLabel: '', + color: 'violet', + archIconPackId: 'aws-official-starter-v1', + archIconShapeId: 'api-gateway', + assetPresentation: 'icon' as const, + }, + }, + ]; + + const enriched = await enrichNodesWithIcons(aiGeneratedNodes); + + // Enricher should preserve existing archIconPackId + expect(enriched[0].data.archIconPackId).toBe('aws-official-starter-v1'); + expect(enriched[0].data.archIconShapeId).toBe('api-gateway'); + expect(enriched[0].data.color).toBe('violet'); + }); + + it('does not modify section nodes', async () => { + const mermaid = ` + flowchart TD + subgraph Group A + A[Node A] + end + `; + + const parsed = parseMermaidByType(mermaid); + const enriched = await enrichNodesWithIcons(parsed.nodes); + + const sectionNode = enriched.find((n) => n.type === 'section'); + expect(sectionNode?.data.icon).toBeUndefined(); + expect(sectionNode?.data.archIconPackId).toBeUndefined(); + }); + + it('edge labels are preserved through parse+enrich', async () => { + const mermaid = ` + flowchart TD + A[Start] -->|Yes| B[Process] + A -->|No| C[End] + `; + + const parsed = parseMermaidByType(mermaid); + expect(parsed.edges).toHaveLength(2); + expect(parsed.edges[0].label).toBe('Yes'); + expect(parsed.edges[1].label).toBe('No'); + }); +}); diff --git a/src/lib/mermaidParser.ts b/src/lib/mermaidParser.ts index 3b74e02b..ee31fcbc 100644 --- a/src/lib/mermaidParser.ts +++ b/src/lib/mermaidParser.ts @@ -9,341 +9,377 @@ import { type MermaidParseModel, } from './mermaidParserModel'; import { - ARROW_PATTERNS, - CLASS_DEF_RE, - parseEdgeLine, - parseLinkStyleLine, - parseNodeDeclaration, - parseStyleString, - SKIP_PATTERNS, - STYLE_RE, - normalizeEdgeLabels, - normalizeMultilineStrings, + ARROW_PATTERNS, + CLASS_DEF_RE, + parseEdgeLine, + parseLinkStyleLine, + parseNodeDeclaration, + parseStyleString, + SKIP_PATTERNS, + STYLE_RE, + normalizeEdgeLabels, + normalizeMultilineStrings, } from './mermaidParserHelpers'; import type { FlowEdge, FlowNode } from './types'; const NODE_TYPE_DEFAULTS: Record = { - start: 'emerald', - end: 'red', - decision: 'amber', - custom: 'violet', - process: 'slate', + start: 'emerald', + end: 'red', + decision: 'amber', + custom: 'violet', + process: 'slate', }; function getDefaultColor(type: string): string { - return NODE_TYPE_DEFAULTS[type] || 'slate'; + return NODE_TYPE_DEFAULTS[type] || 'slate'; } export interface ParseResult { - nodes: FlowNode[]; - edges: FlowEdge[]; - error?: string; - direction?: MermaidDirection; + nodes: FlowNode[]; + edges: FlowEdge[]; + error?: string; + direction?: MermaidDirection; } function preprocessMermaidInput(input: string): string[] { - const processed = normalizeEdgeLabels(normalizeMultilineStrings(input.replace(/\r\n/g, '\n'))); - return processed.split('\n'); + const processed = normalizeEdgeLabels(normalizeMultilineStrings(input.replace(/\r\n/g, '\n'))); + return processed.split('\n'); } function isSkippableLine(line: string): boolean { - return !line || SKIP_PATTERNS.some((pattern) => pattern.test(line)); + return !line || SKIP_PATTERNS.some((pattern) => pattern.test(line)); } function parseFlowchartDeclaration(line: string): MermaidDirection | null { - const flowchartMatch = line.match(/^(?:flowchart|graph)\s+(TD|TB|LR|RL|BT)/i); - if (!flowchartMatch) { - return null; - } - - return (flowchartMatch[1].toUpperCase() === 'TD' - ? 'TB' - : flowchartMatch[1].toUpperCase()) as MermaidDirection; + const flowchartMatch = line.match(/^(?:flowchart|graph)\s+(TD|TB|LR|RL|BT)/i); + if (!flowchartMatch) { + return null; + } + + return ( + flowchartMatch[1].toUpperCase() === 'TD' ? 'TB' : flowchartMatch[1].toUpperCase() + ) as MermaidDirection; } function parseStateDiagramDirection(nextLine: string | undefined): MermaidDirection { - const dirMatch = nextLine?.trim().match(/^direction\s+(LR|TB)/i); - return (dirMatch?.[1].toUpperCase() ?? 'TB') as MermaidDirection; + const dirMatch = nextLine?.trim().match(/^direction\s+(LR|TB)/i); + return (dirMatch?.[1].toUpperCase() ?? 'TB') as MermaidDirection; } function registerSectionNode( - state: ReturnType, - line: string + state: ReturnType, + line: string ): boolean { - const subgraphMatch = line.match(/^subgraph\s+(.+)$/i); - const stateGroupMatch = - line.match(/^state\s+"([^"]+)"\s+as\s+(\w+)\s+\{/i) || - line.match(/^state\s+(\w+)\s+\{/i); + const subgraphMatch = line.match(/^subgraph\s+(.+)$/i); + const stateGroupMatch = + line.match(/^state\s+"([^"]+)"\s+as\s+(\w+)\s+\{/i) || line.match(/^state\s+(\w+)\s+\{/i); - if (!subgraphMatch && !stateGroupMatch) { - return false; - } - void state; - return true; + if (!subgraphMatch && !stateGroupMatch) { + return false; + } + + let sectionId: string; + let sectionLabel: string; + + if (subgraphMatch) { + sectionLabel = subgraphMatch[1].trim(); + sectionId = `subgraph_${sectionLabel.replace(/[^a-zA-Z0-9_]/g, '_')}`; + } else if (stateGroupMatch) { + sectionId = stateGroupMatch[2] ?? stateGroupMatch[1]; + sectionLabel = stateGroupMatch[1] ?? stateGroupMatch[2]; + } else { + return false; + } + + let attempts = 0; + let finalId = sectionId; + while (state.nodesMap.has(finalId)) { + finalId = `${sectionId}_${++attempts}`; + } + + const parentId = state.parentStack[state.parentStack.length - 1]; + state.nodesMap.set(finalId, { + id: finalId, + label: sectionLabel, + type: 'section', + parentId, + }); + state.parentStack.push(finalId); + + return true; } function applyNodeStyleDirective( - state: ReturnType, - line: string + state: ReturnType, + line: string ): boolean { - const styleMatch = line.match(STYLE_RE); - if (!styleMatch) { - return false; - } - - const [, id, styleStr] = styleMatch; - const styles = parseStyleString(styleStr); - const node = state.nodesMap.get(id); - if (node) { - node.styles = { ...node.styles, ...styles }; - } else { - registerMermaidNode(state, id); - const registeredNode = state.nodesMap.get(id); - if (registeredNode) { - registeredNode.styles = styles; - } + const styleMatch = line.match(STYLE_RE); + if (!styleMatch) { + return false; + } + + const [, id, styleStr] = styleMatch; + const styles = parseStyleString(styleStr); + const node = state.nodesMap.get(id); + if (node) { + node.styles = { ...node.styles, ...styles }; + } else { + registerMermaidNode(state, id); + const registeredNode = state.nodesMap.get(id); + if (registeredNode) { + registeredNode.styles = styles; } + } - return true; + return true; } function parseEdgeDeclaration( - state: ReturnType, - line: string + state: ReturnType, + line: string ): boolean { - if (!ARROW_PATTERNS.some((arrow) => line.includes(arrow))) { - return false; + if (!ARROW_PATTERNS.some((arrow) => line.includes(arrow))) { + return false; + } + + const edgesFound = parseEdgeLine(line); + edgesFound.forEach((edge) => { + const type = state.diagramType === 'stateDiagram' ? 'state' : 'process'; + const sourceId = registerMermaidNode(state, edge.sourceRaw, type); + const targetId = registerMermaidNode(state, edge.targetRaw, type); + + if (sourceId && targetId) { + state.rawEdges.push({ + source: sourceId, + target: targetId, + label: edge.label, + arrowType: edge.arrowType, + }); } + }); - const edgesFound = parseEdgeLine(line); - edgesFound.forEach((edge) => { - const type = state.diagramType === 'stateDiagram' ? 'state' : 'process'; - const sourceId = registerMermaidNode(state, edge.sourceRaw, type); - const targetId = registerMermaidNode(state, edge.targetRaw, type); - - if (sourceId && targetId) { - state.rawEdges.push({ - source: sourceId, - target: targetId, - label: edge.label, - arrowType: edge.arrowType, - }); - } - }); - - return true; + return true; } function parseStateDiagramNodeDeclaration( - state: ReturnType, - line: string + state: ReturnType, + line: string ): boolean { - if (state.diagramType !== 'stateDiagram') { - return false; - } + if (state.diagramType !== 'stateDiagram') { + return false; + } - const stateDefMatch = line.match(/^state\s+"([^"]+)"\s+as\s+(\w+)/i); - if (stateDefMatch) { - registerMermaidNode(state, stateDefMatch[2], 'state', stateDefMatch[1]); - return true; - } + const stateDefMatch = line.match(/^state\s+"([^"]+)"\s+as\s+(\w+)/i); + if (stateDefMatch) { + registerMermaidNode(state, stateDefMatch[2], 'state', stateDefMatch[1]); + return true; + } - const stateDescMatch = line.match(/^(\w+)\s*:\s*(.+)/); - if (stateDescMatch) { - registerMermaidNode(state, stateDescMatch[1], 'state', stateDescMatch[2]); - return true; - } + const stateDescMatch = line.match(/^(\w+)\s*:\s*(.+)/); + if (stateDescMatch) { + registerMermaidNode(state, stateDescMatch[1], 'state', stateDescMatch[2]); + return true; + } - return false; + return false; } function buildMermaidParseModel(lines: string[]): MermaidParseModel { - const state = createMermaidParseState(); + const state = createMermaidParseState(); - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (isSkippableLine(line)) { - continue; - } + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (isSkippableLine(line)) { + continue; + } - const flowchartDirection = parseFlowchartDeclaration(line); - if (flowchartDirection) { - state.diagramType = 'flowchart'; - state.direction = flowchartDirection; - continue; - } + const flowchartDirection = parseFlowchartDeclaration(line); + if (flowchartDirection) { + state.diagramType = 'flowchart'; + state.direction = flowchartDirection; + continue; + } - if (line.match(/^stateDiagram(?:-v2)?/i)) { - state.diagramType = 'stateDiagram'; - state.direction = parseStateDiagramDirection(lines[i + 1]); - continue; - } + if (line.match(/^stateDiagram(?:-v2)?/i)) { + state.diagramType = 'stateDiagram'; + state.direction = parseStateDiagramDirection(lines[i + 1]); + continue; + } - if (line.match(/^end\s*$/i) || line === '}') { - if (state.parentStack.length > 0) { - state.parentStack.pop(); - } - continue; - } + if (line.match(/^end\s*$/i) || line === '}') { + if (state.parentStack.length > 0) { + state.parentStack.pop(); + } + continue; + } - if (registerSectionNode(state, line)) { - continue; - } + if (registerSectionNode(state, line)) { + continue; + } - const classDefMatch = line.match(CLASS_DEF_RE); - if (classDefMatch) { - state.classDefs.set(classDefMatch[1], parseStyleString(classDefMatch[2])); - continue; - } + const classDefMatch = line.match(CLASS_DEF_RE); + if (classDefMatch) { + state.classDefs.set(classDefMatch[1], parseStyleString(classDefMatch[2])); + continue; + } - if (applyNodeStyleDirective(state, line)) { - continue; - } + if (applyNodeStyleDirective(state, line)) { + continue; + } - const linkStyleMatch = parseLinkStyleLine(line); - if (linkStyleMatch) { - linkStyleMatch.indices.forEach((index) => state.linkStyles.set(index, linkStyleMatch.style)); - continue; - } + const linkStyleMatch = parseLinkStyleLine(line); + if (linkStyleMatch) { + linkStyleMatch.indices.forEach((index) => state.linkStyles.set(index, linkStyleMatch.style)); + continue; + } - if (parseEdgeDeclaration(state, line)) { - continue; - } + if (parseEdgeDeclaration(state, line)) { + continue; + } - if (parseStateDiagramNodeDeclaration(state, line)) { - continue; - } + if (parseStateDiagramNodeDeclaration(state, line)) { + continue; + } - const standalone = parseNodeDeclaration(line); - if (standalone) { - registerMermaidNode(state, line); - } + const standalone = parseNodeDeclaration(line); + if (standalone) { + registerMermaidNode(state, line); } + } - return toMermaidParseModel(state); + return toMermaidParseModel(state); } function createFlowNodes(model: MermaidParseModel): FlowNode[] { - return Array.from(model.nodesMap.values()).map((node, index) => { - let flowNode: FlowNode = { - id: node.id, - type: node.type, - position: { x: (index % 4) * 200, y: Math.floor(index / 4) * 150 }, - data: { - label: node.label, - subLabel: '', - color: getDefaultColor(node.type), - ...(node.shape ? { shape: node.shape } : {}), - }, - }; + return Array.from(model.nodesMap.values()).map((node, index) => { + let flowNode: FlowNode = { + id: node.id, + type: node.type, + position: { x: (index % 4) * 200, y: Math.floor(index / 4) * 150 }, + data: { + label: node.label, + subLabel: '', + color: getDefaultColor(node.type), + ...(node.shape ? { shape: node.shape } : {}), + }, + ...(node.type === 'section' + ? { + style: { width: 400, height: 300 }, + } + : {}), + }; - if (node.parentId) { - flowNode = setNodeParent(flowNode, node.parentId); - } + if (node.parentId) { + flowNode = setNodeParent(flowNode, node.parentId); + } - if (node.classes) { - node.classes.forEach((cls) => { - const styles = model.classDefs.get(cls); - if (!styles) { - return; - } - if (styles.fill) { - flowNode.style = { ...flowNode.style, backgroundColor: styles.fill }; - } - if (styles.stroke) { - flowNode.style = { ...flowNode.style, borderColor: styles.stroke }; - } - if (styles.color) { - flowNode.style = { ...flowNode.style, color: styles.color }; - } - }); + if (node.classes) { + node.classes.forEach((cls) => { + const styles = model.classDefs.get(cls); + if (!styles) { + return; } - - if (node.styles) { - if (node.styles.fill) { - flowNode.style = { ...flowNode.style, backgroundColor: node.styles.fill }; - } - if (node.styles.stroke) { - flowNode.style = { ...flowNode.style, borderColor: node.styles.stroke }; - } - if (node.styles.color) { - flowNode.style = { ...flowNode.style, color: node.styles.color }; - } + if (styles.fill) { + flowNode.style = { ...flowNode.style, backgroundColor: styles.fill }; } - - if (model.diagramType === 'stateDiagram') { - if (node.type === 'start') { - flowNode.style = { - ...flowNode.style, - width: 20, - height: 20, - borderRadius: '50%', - backgroundColor: '#000', - }; - flowNode.data.label = ''; - } - if (node.type === 'state') { - flowNode.data.shape = 'rounded'; - } + if (styles.stroke) { + flowNode.style = { ...flowNode.style, borderColor: styles.stroke }; } + if (styles.color) { + flowNode.style = { ...flowNode.style, color: styles.color }; + } + }); + } + + if (node.styles) { + if (node.styles.fill) { + flowNode.style = { ...flowNode.style, backgroundColor: node.styles.fill }; + } + if (node.styles.stroke) { + flowNode.style = { ...flowNode.style, borderColor: node.styles.stroke }; + } + if (node.styles.color) { + flowNode.style = { ...flowNode.style, color: node.styles.color }; + } + } + + if (model.diagramType === 'stateDiagram') { + if (node.type === 'start') { + flowNode.style = { + ...flowNode.style, + width: 20, + height: 20, + borderRadius: '50%', + backgroundColor: '#000', + }; + flowNode.data.label = ''; + } + if (node.type === 'state') { + flowNode.data.shape = 'rounded'; + } + } - return flowNode; - }); + return flowNode; + }); } function createFlowEdges(model: MermaidParseModel): FlowEdge[] { - return model.rawEdges.map((edge, index) => { - const flowEdge = createDefaultEdge( - edge.source, - edge.target, - edge.label || undefined, - `e-mermaid-${index}` - ); - - if (edge.arrowType.includes('-.') || edge.arrowType.includes('-.-')) { - flowEdge.style = { ...flowEdge.style, strokeDasharray: '5 3' }; - } - if (edge.arrowType.includes('==')) { - flowEdge.style = { ...flowEdge.style, strokeWidth: 4 }; - } - if (edge.arrowType.startsWith('<')) { - flowEdge.markerStart = { type: MarkerType.ArrowClosed }; - } - if (!edge.arrowType.includes('>')) { - flowEdge.markerEnd = undefined; - } + return model.rawEdges.map((edge, index) => { + const flowEdge = createDefaultEdge( + edge.source, + edge.target, + edge.label || undefined, + `e-mermaid-${index}` + ); + + if (edge.arrowType.includes('-.') || edge.arrowType.includes('-.-')) { + flowEdge.style = { ...flowEdge.style, strokeDasharray: '5 3' }; + } + if (edge.arrowType.includes('==')) { + flowEdge.style = { ...flowEdge.style, strokeWidth: 4 }; + } + if (edge.arrowType.startsWith('<')) { + flowEdge.markerStart = { type: MarkerType.ArrowClosed }; + } + if (!edge.arrowType.includes('>')) { + flowEdge.markerEnd = undefined; + } - const style = model.linkStyles.get(index); - if (style) { - if (style.stroke) { - flowEdge.style = { ...flowEdge.style, stroke: style.stroke }; - } - if (style['stroke-width']) { - flowEdge.style = { - ...flowEdge.style, - strokeWidth: parseInt(style['stroke-width'], 10) || 2, - }; - } - } + const style = model.linkStyles.get(index); + if (style) { + if (style.stroke) { + flowEdge.style = { ...flowEdge.style, stroke: style.stroke }; + } + if (style['stroke-width']) { + flowEdge.style = { + ...flowEdge.style, + strokeWidth: parseInt(style['stroke-width'], 10) || 2, + }; + } + } - return flowEdge; - }); + return flowEdge; + }); } export function parseMermaid(input: string): ParseResult { - const model = buildMermaidParseModel(preprocessMermaidInput(input)); - - if (model.diagramType === 'unknown') { - return { nodes: [], edges: [], error: 'Missing chart type declaration. Start with "flowchart TD" or related.' }; - } - - if (model.nodesMap.size === 0) { - return { nodes: [], edges: [], error: 'No valid nodes found.' }; - } + const model = buildMermaidParseModel(preprocessMermaidInput(input)); + if (model.diagramType === 'unknown') { return { - nodes: createFlowNodes(model), - edges: createFlowEdges(model), - direction: model.direction, + nodes: [], + edges: [], + error: 'Missing chart type declaration. Start with "flowchart TD" or related.', }; + } + + if (model.nodesMap.size === 0) { + return { nodes: [], edges: [], error: 'No valid nodes found.' }; + } + + return { + nodes: createFlowNodes(model), + edges: createFlowEdges(model), + direction: model.direction, + }; } diff --git a/src/lib/mermaidParserHelpers.ts b/src/lib/mermaidParserHelpers.ts index 4ee7bdc6..a0a73ccd 100644 --- a/src/lib/mermaidParserHelpers.ts +++ b/src/lib/mermaidParserHelpers.ts @@ -1,23 +1,28 @@ import type { NodeData } from './types'; -export const SHAPE_OPENERS: Array<{ open: string; close: string; type: string; shape: NodeData['shape'] }> = [ - { open: '([', close: '])', type: 'start', shape: 'capsule' }, - { open: '((', close: '))', type: 'end', shape: 'circle' }, - { open: '{{', close: '}}', type: 'custom', shape: 'hexagon' }, - { open: '[(', close: ')]', type: 'process', shape: 'cylinder' }, - { open: '{', close: '}', type: 'decision', shape: 'diamond' }, - { open: '[', close: ']', type: 'process', shape: 'rounded' }, - { open: '(', close: ')', type: 'process', shape: 'rounded' }, - { open: '>', close: ']', type: 'process', shape: 'parallelogram' }, +export const SHAPE_OPENERS: Array<{ + open: string; + close: string; + type: string; + shape: NodeData['shape']; +}> = [ + { open: '([', close: '])', type: 'start', shape: 'capsule' }, + { open: '((', close: '))', type: 'end', shape: 'circle' }, + { open: '{{', close: '}}', type: 'custom', shape: 'hexagon' }, + { open: '[(', close: ')]', type: 'process', shape: 'cylinder' }, + { open: '{', close: '}', type: 'decision', shape: 'diamond' }, + { open: '[', close: ']', type: 'process', shape: 'rounded' }, + { open: '(', close: ')', type: 'process', shape: 'rounded' }, + { open: '>', close: ']', type: 'process', shape: 'parallelogram' }, ]; export const SKIP_PATTERNS = [ - /^%%/, - /^class\s/i, - /^click\s/i, - /^direction\s/i, - /^accTitle\s/i, - /^accDescr\s/i, + /^%%/, + /^class\s/i, + /^click\s/i, + /^direction\s/i, + /^accTitle\s/i, + /^accDescr\s/i, ]; const LINK_STYLE_RE = /^linkStyle\s+([\d,\s]+)\s+(.+)$/i; @@ -26,232 +31,310 @@ const STYLE_RE = /^style\s+(\w+)\s+(.+)$/i; export { CLASS_DEF_RE, STYLE_RE }; -export function parseLinkStyleLine(line: string): { indices: number[]; style: Record } | null { - const match = line.match(LINK_STYLE_RE); - if (!match) return null; +export function parseLinkStyleLine( + line: string +): { indices: number[]; style: Record } | null { + const match = line.match(LINK_STYLE_RE); + if (!match) return null; - const indices = match[1] - .split(',') - .map((s) => parseInt(s.trim(), 10)) - .filter((n) => !Number.isNaN(n)); + const indices = match[1] + .split(',') + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => !Number.isNaN(n)); - const styleParts = match[2].replace(/;$/, '').split(','); - const style: Record = {}; + const styleParts = match[2].replace(/;$/, '').split(','); + const style: Record = {}; - for (const part of styleParts) { - const [key, value] = part.split(':').map((s) => s.trim()); - if (key && value) { - style[key] = value; - } + for (const part of styleParts) { + const [key, value] = part.split(':').map((s) => s.trim()); + if (key && value) { + style[key] = value; } + } - return { indices, style }; + return { indices, style }; } export function normalizeMultilineStrings(input: string): string { - let result = ''; - let inQuote = false; - - for (let i = 0; i < input.length; i++) { - const char = input[i]; - if (char === '"' && input[i - 1] !== '\\') { - inQuote = !inQuote; - } - - if (inQuote && char === '\n') { - result += '\\n'; - let nextIndex = i + 1; - while (nextIndex < input.length && (input[nextIndex] === ' ' || input[nextIndex] === '\t')) { - nextIndex++; - } - i = nextIndex - 1; - } else { - result += char; - } + let result = ''; + let inQuote = false; + + for (let i = 0; i < input.length; i++) { + const char = input[i]; + if (char === '"' && input[i - 1] !== '\\') { + inQuote = !inQuote; + } + + if (inQuote && char === '\n') { + result += '\\n'; + let nextIndex = i + 1; + while (nextIndex < input.length && (input[nextIndex] === ' ' || input[nextIndex] === '\t')) { + nextIndex++; + } + i = nextIndex - 1; + } else { + result += char; } + } - return result; + return result; } export function normalizeEdgeLabels(input: string): string { - let result = input; - result = result.replace(/==(?![>])\s*(.+?)\s*==>/g, ' ==>|$1|'); - result = result.replace(/--(?![>-])\s*(.+?)\s*-->/g, ' -->|$1|'); - result = result.replace(/-\.\s*(.+?)\s*\.->/g, ' -.->|$1|'); - result = result.replace(/--(?![>-])\s*(.+?)\s*---/g, ' ---|$1|'); - return result; + let result = input; + result = result.replace(/==(?![>])\s*(.+?)\s*==>/g, ' ==>|$1|'); + result = result.replace(/--(?![>-])\s*(.+?)\s*-->/g, ' -->|$1|'); + result = result.replace(/-\.\s*(.+?)\s*\.->/g, ' -.->|$1|'); + result = result.replace(/--(?![>-])\s*(.+?)\s*---/g, ' ---|$1|'); + return result; } export interface RawNode { - id: string; - label: string; - type: string; - shape?: NodeData['shape']; - parentId?: string; - styles?: Record; - classes?: string[]; + id: string; + label: string; + type: string; + shape?: NodeData['shape']; + parentId?: string; + styles?: Record; + classes?: string[]; +} + +const MODERN_SHAPE_MAP: Record = { + cyl: { type: 'process', shape: 'cylinder' }, + cylinder: { type: 'process', shape: 'cylinder' }, + circle: { type: 'end', shape: 'circle' }, + circle2: { type: 'end', shape: 'circle' }, + cloud: { type: 'process', shape: 'rounded' }, + diamond: { type: 'decision', shape: 'diamond' }, + hexagon: { type: 'custom', shape: 'hexagon' }, + 'lean-r': { type: 'process', shape: 'parallelogram' }, + 'lean-l': { type: 'process', shape: 'parallelogram' }, + stadium: { type: 'start', shape: 'capsule' }, + rounded: { type: 'process', shape: 'rounded' }, + rect: { type: 'process', shape: 'rounded' }, + square: { type: 'process', shape: 'rounded' }, + doublecircle: { type: 'end', shape: 'circle' }, +}; + +interface ModernShapeAnnotation { + shapeKey?: string; + labelOverride?: string; + cleanInput: string; +} + +function extractModernAnnotation(input: string): ModernShapeAnnotation { + const match = input.match(/^(\w+)@\{([^}]+)\}/); + if (!match) return { cleanInput: input }; + + const id = match[1]; + const attrs = match[2]; + const rest = input.substring(match[0].length); + + const shapeMatch = attrs.match(/\bshape:\s*(\w+)/); + const labelMatch = attrs.match(/\blabel:\s*"([^"]+)"/); + + return { + shapeKey: shapeMatch?.[1]?.toLowerCase(), + labelOverride: labelMatch?.[1], + cleanInput: `${id}${rest}`, + }; +} + +function stripMarkdown(label: string): string { + return label + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/_(.+?)_/g, '$1') + .replace(/~~(.+?)~~/g, '$1') + .replace(/`(.+?)`/g, '$1'); } function stripFaIcons(label: string): string { - const stripped = label.replace(/fa:fa-[\w-]+\s*/g, '').trim(); - if (stripped) return stripped; - const iconMatch = label.match(/fa:fa-([\w-]+)/); - return iconMatch ? iconMatch[1].replace(/-/g, ' ') : label; + const stripped = label.replace(/fa:fa-[\w-]+\s*/g, '').trim(); + if (stripped) return stripped; + const iconMatch = label.match(/fa:fa-([\w-]+)/); + return iconMatch ? iconMatch[1].replace(/-/g, ' ') : label; } function tryParseWithShape( - input: string, - shape: { open: string; close: string; type: string; shape: NodeData['shape'] } + input: string, + shape: { open: string; close: string; type: string; shape: NodeData['shape'] } ): RawNode | null { - const openIndex = input.indexOf(shape.open); - if (openIndex < 1) return null; - if (openIndex > 0 && input[openIndex - 1] === shape.open[0]) return null; - - const id = input.substring(0, openIndex).trim(); - if (!/^[a-zA-Z0-9_][\w-]*$/.test(id)) return null; - - const afterOpen = input.substring(openIndex + shape.open.length); - const closeIndex = afterOpen.lastIndexOf(shape.close); - if (closeIndex < 0) return null; - - const afterClose = afterOpen.substring(closeIndex + shape.close.length).trim(); - let classes: string[] = []; - if (afterClose.startsWith(':::')) { - classes = afterClose.substring(3).split(/,\s*/); - } else if (afterClose) { - return null; - } - - let label = afterOpen.substring(0, closeIndex).trim(); - if ((label.startsWith('"') && label.endsWith('"')) || (label.startsWith("'") && label.endsWith("'"))) { - label = label.slice(1, -1); - } - label = label.replace(/\\n/g, '\n'); - label = stripFaIcons(label); - if (!label) label = id; - - return { id, label, type: shape.type, shape: shape.shape, classes: classes.length ? classes : undefined }; + const openIndex = input.indexOf(shape.open); + if (openIndex < 1) return null; + if (openIndex > 0 && input[openIndex - 1] === shape.open[0]) return null; + + const id = input.substring(0, openIndex).trim(); + if (!/^[a-zA-Z0-9_][\w-]*$/.test(id)) return null; + + const afterOpen = input.substring(openIndex + shape.open.length); + const closeIndex = afterOpen.lastIndexOf(shape.close); + if (closeIndex < 0) return null; + + const afterClose = afterOpen.substring(closeIndex + shape.close.length).trim(); + let classes: string[] = []; + if (afterClose.startsWith(':::')) { + classes = afterClose.substring(3).split(/,\s*/); + } else if (afterClose) { + return null; + } + + let label = afterOpen.substring(0, closeIndex).trim(); + if ( + (label.startsWith('"') && label.endsWith('"')) || + (label.startsWith("'") && label.endsWith("'")) + ) { + label = label.slice(1, -1); + } + label = label.replace(/\\n/g, '\n'); + label = stripFaIcons(label); + label = stripMarkdown(label); + if (!label) label = id; + + return { + id, + label, + type: shape.type, + shape: shape.shape, + classes: classes.length ? classes : undefined, + }; } export function parseNodeDeclaration(raw: string): RawNode | null { - const trimmed = raw.trim(); - if (!trimmed) return null; - - for (const shape of SHAPE_OPENERS) { - const result = tryParseWithShape(trimmed, shape); - if (result) return result; + const trimmed = raw.trim(); + if (!trimmed) return null; + + const annotation = extractModernAnnotation(trimmed); + const input = annotation.cleanInput; + + for (const shape of SHAPE_OPENERS) { + const result = tryParseWithShape(input, shape); + if (result) { + if (annotation.shapeKey && MODERN_SHAPE_MAP[annotation.shapeKey]) { + const override = MODERN_SHAPE_MAP[annotation.shapeKey]; + result.type = override.type; + result.shape = override.shape; + } + if (annotation.labelOverride) { + result.label = annotation.labelOverride; + } + result.label = stripMarkdown(result.label); + return result; } + } - let id = trimmed; - let classes: string[] = []; - if (id.includes(':::')) { - const parts = id.split(':::'); - id = parts[0]; - classes = parts[1].split(/,\s*/); - } + let id = input; + let classes: string[] = []; + if (id.includes(':::')) { + const parts = id.split(':::'); + id = parts[0]; + classes = parts[1].split(/,\s*/); + } - if (/^[a-zA-Z0-9_][\w-]*$/.test(id)) { - return { id, label: id, type: 'process', classes: classes.length ? classes : undefined }; - } + if (/^[a-zA-Z0-9_][\w-]*$/.test(id)) { + return { id, label: id, type: 'process', classes: classes.length ? classes : undefined }; + } - return null; + return null; } export const ARROW_PATTERNS = [ - '<==>', - '<-.->', - '<-->', - '<==', - '<-.', - '<--', - '===>', - '-.->', - '--->', - '-->', - '===', - '---', - '==>', - '-.-', - '--', + '<==>', + '<-.->', + '<-->', + '<==', + '<-.', + '<--', + '===>', + '-.->', + '--->', + '-->', + '===', + '---', + '==>', + '-.-', + '--', ]; function findArrowInLine(line: string): { arrow: string; before: string; after: string } | null { - for (const arrow of ARROW_PATTERNS) { - const index = line.indexOf(arrow); - if (index >= 0) { - return { - arrow, - before: line.substring(0, index).trim(), - after: line.substring(index + arrow.length).trim(), - }; - } + for (const arrow of ARROW_PATTERNS) { + const index = line.indexOf(arrow); + if (index >= 0) { + return { + arrow, + before: line.substring(0, index).trim(), + after: line.substring(index + arrow.length).trim(), + }; } - return null; + } + return null; } export function parseEdgeLine(line: string): Array<{ - sourceRaw: string; - targetRaw: string; - label: string; - arrowType: string; + sourceRaw: string; + targetRaw: string; + label: string; + arrowType: string; }> { - const edges: Array<{ sourceRaw: string; targetRaw: string; label: string; arrowType: string }> = []; - let remaining = line; - let lastNodeRaw: string | null = null; - - while (remaining.trim()) { - const arrowMatch = findArrowInLine(remaining); - if (!arrowMatch) break; - - const { arrow, before, after } = arrowMatch; - const sourceRaw = lastNodeRaw || before; - let label = ''; - let targetAndRest = after; - - const labelMatch = targetAndRest.match(/^\|"?([^"|]*)"?\|\s*/); - if (labelMatch) { - label = labelMatch[1].trim(); - targetAndRest = targetAndRest.substring(labelMatch[0].length); - } - - const nextArrowMatch = findArrowInLine(targetAndRest); - let targetRaw: string; - - if (nextArrowMatch) { - targetRaw = nextArrowMatch.before; - remaining = targetAndRest; - } else { - targetRaw = targetAndRest; - remaining = ''; - } - - let source = sourceRaw.trim(); - let target = targetRaw.trim(); - - if (source.includes(':::')) source = source.split(':::')[0]; - if (target.includes(':::')) target = target.split(':::')[0]; - - if (source && target) { - edges.push({ sourceRaw: source, targetRaw: target, label, arrowType: arrow }); - } - - lastNodeRaw = targetRaw.trim(); - if (!nextArrowMatch) break; + const edges: Array<{ sourceRaw: string; targetRaw: string; label: string; arrowType: string }> = + []; + let remaining = line; + let lastNodeRaw: string | null = null; + + while (remaining.trim()) { + const arrowMatch = findArrowInLine(remaining); + if (!arrowMatch) break; + + const { arrow, before, after } = arrowMatch; + const sourceRaw = lastNodeRaw || before; + let label = ''; + let targetAndRest = after; + + const labelMatch = targetAndRest.match(/^\|"?([^"|]*)"?\|\s*/); + if (labelMatch) { + label = labelMatch[1].trim(); + targetAndRest = targetAndRest.substring(labelMatch[0].length); } - return edges; + const nextArrowMatch = findArrowInLine(targetAndRest); + let targetRaw: string; + + if (nextArrowMatch) { + targetRaw = nextArrowMatch.before; + remaining = targetAndRest; + } else { + targetRaw = targetAndRest; + remaining = ''; + } + + let source = sourceRaw.trim(); + let target = targetRaw.trim(); + + if (source.includes(':::')) source = source.split(':::')[0]; + if (target.includes(':::')) target = target.split(':::')[0]; + + if (source && target) { + edges.push({ sourceRaw: source, targetRaw: target, label, arrowType: arrow }); + } + + lastNodeRaw = targetRaw.trim(); + if (!nextArrowMatch) break; + } + + return edges; } export function parseStyleString(styleStr: string): Record { - const styles: Record = {}; - const parts = styleStr.split(','); - - for (const part of parts) { - const [key, value] = part.split(':').map((s) => s.trim()); - if (key && value) { - styles[key] = value.replace(/;$/, ''); - } + const styles: Record = {}; + const parts = styleStr.split(','); + + for (const part of parts) { + const [key, value] = part.split(':').map((s) => s.trim()); + if (key && value) { + styles[key] = value.replace(/;$/, ''); } + } - return styles; + return styles; } diff --git a/src/lib/nodeEnricher.test.ts b/src/lib/nodeEnricher.test.ts new file mode 100644 index 00000000..ee31c3a2 --- /dev/null +++ b/src/lib/nodeEnricher.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; +import { enrichNodesWithIcons } from './nodeEnricher'; +import type { FlowNode } from './types'; + +function makeNode(id: string, label: string, overrides?: Partial): FlowNode { + return { + id, + type: 'process', + position: { x: 0, y: 0 }, + data: { label, color: 'slate' }, + ...overrides, + } as FlowNode; +} + +describe('enrichNodesWithIcons', () => { + it('assigns color based on semantic classification', async () => { + const nodes = [ + makeNode('start', 'Start'), + makeNode('end', 'End'), + makeNode('db', 'PostgreSQL'), + makeNode('check', 'Is Valid?', { + data: { label: 'Is Valid?', color: 'slate', shape: 'diamond' }, + }), + ]; + + const enriched = await enrichNodesWithIcons(nodes); + + expect(enriched[0].data.color).toBe('emerald'); + expect(enriched[1].data.color).toBe('red'); + expect(enriched[2].data.color).toBe('violet'); + expect(enriched[3].data.color).toBe('amber'); + }); + + it('assigns icons for known technologies', async () => { + const nodes = [ + makeNode('db', 'PostgreSQL'), + makeNode('cache', 'Redis Cache'), + makeNode('api', 'Express API'), + ]; + + const enriched = await enrichNodesWithIcons(nodes); + + // PostgreSQL should get a provider icon (developer catalog) + const pgNode = enriched[0]; + if (pgNode.data.archIconPackId) { + expect(pgNode.data.archIconPackId).toBe('developer-icons-v1'); + expect(pgNode.data.archIconShapeId).toContain('postgresql'); + } else { + expect(pgNode.data.icon).toBe('database'); + } + + // Redis should get a provider icon (developer catalog) + const redisNode = enriched[1]; + if (redisNode.data.archIconPackId) { + expect(redisNode.data.archIconPackId).toBe('developer-icons-v1'); + expect(redisNode.data.archIconShapeId).toContain('redis'); + } else { + expect(redisNode.data.icon).toBe('hard-drive'); + } + + // Express should get a provider icon or Lucide fallback + const expressNode = enriched[2]; + if (expressNode.data.archIconPackId) { + expect(expressNode.data.archIconPackId).toBe('developer-icons-v1'); + } else { + expect(expressNode.data.icon).toBe('server'); + } + }); + + it('skips section and group nodes', async () => { + const nodes = [ + { ...makeNode('grp', 'Group'), type: 'section' as const }, + makeNode('x', 'Something'), + ]; + + const enriched = await enrichNodesWithIcons(nodes); + + expect(enriched[0].data.color).toBe('slate'); + expect(enriched[0].data.icon).toBeUndefined(); + }); + + it('preserves existing non-slate colors', async () => { + const nodes = [ + { + id: 'a', + type: 'process', + position: { x: 0, y: 0 }, + data: { label: 'Start', color: 'pink' }, + }, + ]; + + const enriched = await enrichNodesWithIcons(nodes as FlowNode[]); + + expect(enriched[0].data.color).toBe('pink'); + }); + + it('preserves existing icons', async () => { + const nodes = [ + { + id: 'a', + type: 'process', + position: { x: 0, y: 0 }, + data: { label: 'PostgreSQL', color: 'violet', icon: 'my-icon' }, + }, + ]; + + const enriched = await enrichNodesWithIcons(nodes as FlowNode[]); + + expect(enriched[0].data.icon).toBe('my-icon'); + }); + + it('handles empty node array', async () => { + const enriched = await enrichNodesWithIcons([]); + expect(enriched).toEqual([]); + }); + + it('preserves nodes with no changes', async () => { + const nodes = [ + { + id: 'a', + type: 'process', + position: { x: 0, y: 0 }, + data: { label: 'Something Random', color: 'blue', icon: 'Box' }, + }, + ]; + + const enriched = await enrichNodesWithIcons(nodes as FlowNode[]); + expect(enriched[0]).toEqual(nodes[0]); + }); + + it('classifies decision shape correctly', async () => { + const nodes = [ + makeNode('check', 'Validate?', { + data: { label: 'Validate?', color: 'slate', shape: 'diamond' }, + }), + ]; + + const enriched = await enrichNodesWithIcons(nodes); + expect(enriched[0].data.color).toBe('amber'); + }); + + it('classifies cylinder shape as database', async () => { + const nodes = [ + makeNode('pg', 'PostgreSQL DB', { + data: { label: 'PostgreSQL DB', color: 'slate', shape: 'cylinder' }, + }), + ]; + + const enriched = await enrichNodesWithIcons(nodes); + expect(enriched[0].data.color).toBe('violet'); + // Cylinder + PostgreSQL gets a provider icon or Lucide fallback + if (!enriched[0].data.archIconPackId) { + expect(enriched[0].data.icon).toBe('database'); + } + }); +}); diff --git a/src/lib/nodeEnricher.ts b/src/lib/nodeEnricher.ts new file mode 100644 index 00000000..95f5c205 --- /dev/null +++ b/src/lib/nodeEnricher.ts @@ -0,0 +1,118 @@ +import type { FlowNode } from '@/lib/types'; +import { classifyNode } from '@/lib/semanticClassifier'; +import { resolveIconSync } from '@/lib/iconResolver'; +import { loadDomainAssetSuggestions } from '@/services/assetCatalog'; +import type { DomainLibraryCategory } from '@/services/domainLibrary'; + +export async function enrichNodesWithIcons(nodes: FlowNode[]): Promise { + const enriched = await Promise.all(nodes.map(enrichSingleNode)); + return enriched; +} + +async function enrichSingleNode(node: FlowNode): Promise { + if (node.type === 'section' || node.type === 'group' || node.type === 'swimlane') { + return node; + } + + const label = node.data?.label ?? ''; + const hasExplicitColor = node.data?.color && node.data.color !== 'slate'; + const hasExplicitIcon = Boolean(node.data?.icon); + + if (hasExplicitColor && hasExplicitIcon) { + return node; + } + + const hint = classifyNode({ id: node.id, label, shape: node.data?.shape }); + const dataUpdates: Record = {}; + + if (!hasExplicitColor) { + // Use parser-assigned node type to override classifier if it's more specific + if (node.type === 'start') { + dataUpdates.color = 'emerald'; + } else if (node.type === 'end') { + dataUpdates.color = 'red'; + } else if (node.type === 'decision') { + dataUpdates.color = 'amber'; + } else { + dataUpdates.color = hint.color; + } + } + + if (!hasExplicitIcon) { + const classifierIcon = hint.lucideFallback; + + if (hint.iconQuery) { + const resolved = resolveIconSync(hint.iconQuery, hint.category); + if (resolved.found && resolved.catalog && resolved.iconSearch) { + const catalogResult = await searchCatalogForIcon(resolved.catalog, resolved.iconSearch); + if (catalogResult?.archIconPackId && catalogResult.archIconShapeId) { + dataUpdates.archIconPackId = catalogResult.archIconPackId; + dataUpdates.archIconShapeId = catalogResult.archIconShapeId; + dataUpdates.assetPresentation = 'icon'; + } + } + } + + // Use classifier icon if specific (not generic 'box'), otherwise use node type defaults + if (classifierIcon && classifierIcon !== 'box') { + dataUpdates.icon = classifierIcon; + } else if (node.type === 'start') { + dataUpdates.icon = 'play'; + } else if (node.type === 'end') { + dataUpdates.icon = 'check-circle'; + } else if (node.type === 'decision') { + dataUpdates.icon = 'help-circle'; + } + } + + if (Object.keys(dataUpdates).length === 0) { + return node; + } + + return { + ...node, + data: { + ...node.data, + ...dataUpdates, + }, + }; +} + +const catalogCache = new Map< + string, + { icon?: string; archIconPackId?: string; archIconShapeId?: string } | null +>(); + +async function searchCatalogForIcon( + catalog: DomainLibraryCategory, + query: string +): Promise<{ icon?: string; archIconPackId?: string; archIconShapeId?: string } | null> { + const cacheKey = `${catalog}:${query}`; + if (catalogCache.has(cacheKey)) { + return catalogCache.get(cacheKey)!; + } + + try { + const results = await loadDomainAssetSuggestions(catalog, { query, limit: 1 }); + if (results.length > 0) { + const best = results[0]; + const labelMatch = best.label.toLowerCase().includes(query.toLowerCase()); + const descMatch = best.description.toLowerCase().includes(query.toLowerCase()); + + if (labelMatch || descMatch) { + const match = { + icon: best.icon, + archIconPackId: best.archIconPackId, + archIconShapeId: best.archIconShapeId, + }; + catalogCache.set(cacheKey, match); + return match; + } + } + } catch { + // Catalog search failed — fall back to Lucide + } + + catalogCache.set(cacheKey, null); + return null; +} diff --git a/src/lib/semanticClassifier.test.ts b/src/lib/semanticClassifier.test.ts new file mode 100644 index 00000000..b77d9bbb --- /dev/null +++ b/src/lib/semanticClassifier.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { classifyNode } from './semanticClassifier'; + +describe('classifyNode', () => { + it('classifies start nodes', () => { + expect(classifyNode({ id: 'start', label: 'Start' }).category).toBe('start'); + expect(classifyNode({ id: 'begin', label: 'Begin' }).category).toBe('start'); + expect(classifyNode({ id: 'entry', label: 'Entry Point' }).category).toBe('start'); + expect(classifyNode({ id: 'x', label: 'Order Start' }).category).toBe('start'); + }); + + it('classifies end nodes', () => { + expect(classifyNode({ id: 'end', label: 'End' }).category).toBe('end'); + expect(classifyNode({ id: 'done', label: 'Done' }).category).toBe('end'); + expect(classifyNode({ id: 'finish', label: 'Complete' }).category).toBe('end'); + }); + + it('classifies decision nodes by shape', () => { + const hint = classifyNode({ id: 'check', label: 'Is Valid?', shape: 'diamond' }); + expect(hint.category).toBe('decision'); + expect(hint.color).toBe('amber'); + }); + + it('classifies database nodes', () => { + const pg = classifyNode({ id: 'db', label: 'PostgreSQL' }); + expect(pg.category).toBe('database'); + expect(pg.color).toBe('violet'); + expect(pg.iconQuery).toMatch(/postgres/i); + + const mongo = classifyNode({ id: 'db', label: 'MongoDB' }); + expect(mongo.category).toBe('database'); + expect(mongo.iconQuery).toMatch(/mongo/i); + }); + + it('classifies cylinder shape as database', () => { + const hint = classifyNode({ id: 'db', label: 'Users DB', shape: 'cylinder' }); + expect(hint.category).toBe('database'); + expect(hint.color).toBe('violet'); + }); + + it('classifies cache nodes', () => { + const hint = classifyNode({ id: 'cache', label: 'Redis Cache' }); + expect(hint.category).toBe('cache'); + expect(hint.iconQuery).toMatch(/redis/i); + }); + + it('classifies queue nodes', () => { + const hint = classifyNode({ id: 'mq', label: 'RabbitMQ' }); + expect(hint.category).toBe('queue'); + expect(hint.iconQuery).toMatch(/rabbitmq/i); + }); + + it('classifies user nodes', () => { + const hint = classifyNode({ id: 'user', label: 'User' }); + expect(hint.category).toBe('user'); + expect(hint.color).toBe('blue'); + }); + + it('classifies gateway nodes', () => { + const hint = classifyNode({ id: 'gw', label: 'API Gateway' }); + expect(hint.category).toBe('gateway'); + + const nginx = classifyNode({ id: 'proxy', label: 'Nginx' }); + expect(nginx.category).toBe('gateway'); + }); + + it('classifies frontend nodes', () => { + const hint = classifyNode({ id: 'fe', label: 'React App' }); + expect(hint.category).toBe('frontend'); + expect(hint.iconQuery).toMatch(/react/i); + }); + + it('classifies service nodes', () => { + const hint = classifyNode({ id: 'api', label: 'Express API' }); + expect(hint.category).toBe('service'); + expect(hint.iconQuery).toMatch(/express/i); + + const node = classifyNode({ id: 'be', label: 'Node.js Backend' }); + expect(node.category).toBe('service'); + }); + + it('classifies auth nodes', () => { + const hint = classifyNode({ id: 'auth', label: 'OAuth Login' }); + expect(hint.category).toBe('auth'); + }); + + it('returns process as default', () => { + const hint = classifyNode({ id: 'x', label: 'Something Random' }); + expect(hint.category).toBe('process'); + expect(hint.color).toBe('slate'); + }); +}); diff --git a/src/lib/semanticClassifier.ts b/src/lib/semanticClassifier.ts new file mode 100644 index 00000000..4d3f04eb --- /dev/null +++ b/src/lib/semanticClassifier.ts @@ -0,0 +1,274 @@ +import type { NodeColorKey } from '@/theme'; + +export type SemanticCategory = + | 'start' + | 'end' + | 'decision' + | 'database' + | 'cache' + | 'queue' + | 'service' + | 'frontend' + | 'user' + | 'action' + | 'gateway' + | 'auth' + | 'storage' + | 'process'; + +export interface SemanticHint { + category: SemanticCategory; + color: NodeColorKey; + iconQuery: string; + lucideFallback: string; +} + +interface ClassifierRule { + patterns: RegExp[]; + category: SemanticCategory; + color: NodeColorKey; + lucideFallback: string; + extractQuery?: (text: string, id: string) => string; +} + +const RULES: ClassifierRule[] = [ + { + patterns: [/\bstart\b/i, /\bbegin\b/i, /\binit\b/i, /\bentry\b/i, /\blaunch\b/i], + category: 'start', + color: 'emerald', + lucideFallback: 'play', + }, + { + patterns: [/\bend\b/i, /\bfinish\b/i, /\bdone\b/i, /\bcomplete\b/i, /\bstop\b/i, /\bexit\b/i], + category: 'end', + color: 'red', + lucideFallback: 'check-circle', + }, + { + patterns: [ + /\bdb\b/i, + /\bdatabase\b/i, + /\bsql\b/i, + /\bpostgres/i, + /\bmysql\b/i, + /\bmongo/i, + /\bdynamodb\b/i, + /\baurora\b/i, + /\bsqlite\b/i, + /\bmariadb\b/i, + /\bcockroach\b/i, + /\bsupabase\b/i, + ], + category: 'database', + color: 'violet', + lucideFallback: 'database', + extractQuery: (text) => { + const m = text.match( + /(postgres(?:ql)?|mysql|mongo(?:db)?|dynamodb|aurora|sqlite|mariadb|cockroach|supabase)/i + ); + return m ? m[1] : text.split(/\s+/)[0]; + }, + }, + { + patterns: [/\bredis\b/i, /\bmemcache/i, /\bcache\b/i, /\belasticache\b/i], + category: 'cache', + color: 'red', + lucideFallback: 'hard-drive', + extractQuery: (text) => { + const m = text.match(/(redis|memcache(?:d)?|elasticache)/i); + return m ? m[1] : 'cache'; + }, + }, + { + patterns: [ + /\bkafka\b/i, + /\brabbitmq\b/i, + /\bsqs\b/i, + /\bpulsar\b/i, + /\bnats\b/i, + /\bqueue\b/i, + /\bbus\b/i, + ], + category: 'queue', + color: 'amber', + lucideFallback: 'layers', + extractQuery: (text) => { + const m = text.match(/(kafka|rabbitmq|sqs|pulsar|nats)/i); + return m ? m[1] : 'queue'; + }, + }, + { + patterns: [ + /\buser\b/i, + /\bactor\b/i, + /\bcustomer\b/i, + /\badmin\b/i, + /\bclient\b/i, + /\bperson\b/i, + /\bviewer\b/i, + ], + category: 'user', + color: 'blue', + lucideFallback: 'user', + }, + { + patterns: [ + /\bapi[- ]?gateway\b/i, + /\bgateway\b/i, + /\bload[- ]?balancer\b/i, + /\bnginx\b/i, + /\bhaproxy\b/i, + /\balb\b/i, + /\bcloudfront\b/i, + /\bingress\b/i, + /\benvoy\b/i, + ], + category: 'gateway', + color: 'slate', + lucideFallback: 'shield', + extractQuery: (text) => { + const m = text.match(/(api[- ]?gateway|nginx|haproxy|alb|cloudfront|ingress|envoy)/i); + return m ? m[1] : 'gateway'; + }, + }, + { + patterns: [ + /\bauth\b/i, + /\blogin\b/i, + /\bsign[- ]?in\b/i, + /\boauth\b/i, + /\bjwt\b/i, + /\bsso\b/i, + /\bcognito\b/i, + /\bidentity\b/i, + ], + category: 'auth', + color: 'amber', + lucideFallback: 'key-round', + }, + { + patterns: [/\bs3\b/i, /\bblob\b/i, /\bstorage\b/i, /\buploads?\b/i, /\bcdn\b/i], + category: 'storage', + color: 'yellow', + lucideFallback: 'folder', + }, + { + patterns: [ + /\breact\b/i, + /\bvue\b/i, + /\bangular\b/i, + /\bsvelte\b/i, + /\bnext\.?js\b/i, + /\bnuxt\b/i, + /\bfrontend\b/i, + /\bui\b/i, + /\bweb\s*app\b/i, + /\bclient[- ]?app\b/i, + /\bhtml\b/i, + /\bcss\b/i, + ], + category: 'frontend', + color: 'blue', + lucideFallback: 'monitor', + extractQuery: (text) => { + const m = text.match(/(react|vue|angular|svelte|next(?:\.?js)?|nuxt)/i); + return m ? m[1] : 'frontend'; + }, + }, + { + patterns: [ + /\bexpress\b/i, + /\bnode\.?js\b/i, + /\bdjango\b/i, + /\bflask\b/i, + /\bfastapi\b/i, + /\bspring\b/i, + /\brails\b/i, + /\blaravel\b/i, + /\bgin\b/i, + /\bactix\b/i, + /\bnest\.?js\b/i, + /\bapi\b/i, + /\bservice\b/i, + /\bbackend\b/i, + /\bserver\b/i, + /\bmicroservice\b/i, + ], + category: 'service', + color: 'blue', + lucideFallback: 'server', + extractQuery: (text) => { + const m = text.match( + /(express|node\.?js|django|flask|fastapi|spring|rails|laravel|gin|actix|nest\.?js)/i + ); + return m ? m[1] : text.split(/\s+/)[0]; + }, + }, + { + patterns: [ + /\bdocker\b/i, + /\bkubernetes\b/i, + /\bk8s\b/i, + /\becs\b/i, + /\beks\b/i, + /\bcloud\s*run\b/i, + /\bcontainer\b/i, + ], + category: 'service', + color: 'blue', + lucideFallback: 'container', + extractQuery: (text) => { + const m = text.match(/(docker|kubernetes|k8s|ecs|eks|cloud\s*run)/i); + return m ? m[1] : 'container'; + }, + }, +]; + +const DEFAULT_HINT: SemanticHint = { + category: 'process', + color: 'slate', + iconQuery: '', + lucideFallback: 'box', +}; + +export function classifyNode(node: { id: string; label: string; shape?: string }): SemanticHint { + if (node.shape === 'diamond') { + return { category: 'decision', color: 'amber', iconQuery: '', lucideFallback: 'help-circle' }; + } + + if (node.shape === 'cylinder') { + const text = `${node.id} ${node.label}`; + const m = text.match(/(postgres(?:ql)?|mysql|mongo(?:db)?|redis|dynamodb|aurora)/i); + return { + category: 'database', + color: 'violet', + iconQuery: m ? m[1] : node.label, + lucideFallback: 'database', + }; + } + + const text = `${node.id} ${node.label}`; + + for (const rule of RULES) { + if (rule.patterns.some((p) => p.test(text))) { + return { + category: rule.category, + color: rule.color, + iconQuery: rule.extractQuery ? rule.extractQuery(text, node.id) : node.label, + lucideFallback: rule.lucideFallback, + }; + } + } + + return DEFAULT_HINT; +} + +export function classifyNodes( + nodes: Array<{ id: string; label: string; shape?: string }> +): Map { + const results = new Map(); + for (const node of nodes) { + results.set(node.id, classifyNode(node)); + } + return results; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index ff67ae7e..8626c8ab 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -17,7 +17,6 @@ export const DIAGRAM_TYPES = [ 'stateDiagram', 'classDiagram', 'erDiagram', - 'gitGraph', 'mindmap', 'journey', 'architecture', @@ -165,18 +164,19 @@ export interface SectionNodeData { sectionCollapsed?: boolean; } -export interface NodeData extends - NodeLabelData, - NodeIconData, - NodeVisualStyleData, - NodeCanvasMetadata, - ClassNodeData, - EntityNodeData, - JourneyNodeData, - MindmapNodeData, - ArchitectureNodeData, - SequenceNodeData, - SectionNodeData { +export interface NodeData + extends + NodeLabelData, + NodeIconData, + NodeVisualStyleData, + NodeCanvasMetadata, + ClassNodeData, + EntityNodeData, + JourneyNodeData, + MindmapNodeData, + ArchitectureNodeData, + SequenceNodeData, + SectionNodeData { [key: string]: unknown; } diff --git a/src/services/export/formatting.ts b/src/services/export/formatting.ts index 667d504b..dde106ab 100644 --- a/src/services/export/formatting.ts +++ b/src/services/export/formatting.ts @@ -2,6 +2,10 @@ export function sanitizeLabel(label: string): string { return label.replace(/['"()]/g, '').trim() || 'Node'; } +export function sanitizeEdgeLabel(label: string): string { + return label.replace(/['"{}]/g, '').trim(); +} + export function sanitizeId(id: string): string { return id.replace(/[-]/g, '_'); } diff --git a/src/services/export/mermaid/architectureMermaid.ts b/src/services/export/mermaid/architectureMermaid.ts index 1d9906ad..90a0d656 100644 --- a/src/services/export/mermaid/architectureMermaid.ts +++ b/src/services/export/mermaid/architectureMermaid.ts @@ -1,6 +1,6 @@ import type { FlowEdge, FlowNode } from '@/lib/types'; import { handleIdToSide as handleIdToFlowSide } from '@/lib/nodeHandles'; -import { sanitizeId, sanitizeLabel } from '../formatting'; +import { sanitizeId, sanitizeLabel, sanitizeEdgeLabel } from '../formatting'; function normalizeArchitectureDirection(direction: string | undefined): '-->' | '<--' | '<-->' { if (direction === '<--' || direction === '<-->') return direction; @@ -66,7 +66,7 @@ export function toArchitectureMermaid(nodes: FlowNode[], edges: FlowEdge[]): str | undefined; const protocol = edgeData?.archProtocol; const port = edgeData?.archPort; - const label = edge.label ? sanitizeLabel(String(edge.label)) : undefined; + const label = edge.label ? sanitizeEdgeLabel(String(edge.label)) : undefined; const sourceSide = normalizeArchitectureSide(edgeData?.archSourceSide) || handleIdToSide(edge.sourceHandle); const targetSide = diff --git a/src/services/export/mermaid/stateDiagramMermaid.ts b/src/services/export/mermaid/stateDiagramMermaid.ts index 3cc9e7e6..54130e8b 100644 --- a/src/services/export/mermaid/stateDiagramMermaid.ts +++ b/src/services/export/mermaid/stateDiagramMermaid.ts @@ -14,7 +14,7 @@ function escapeStateLabel(label: string): string { } function isStateDiagramNodeType(type: string | undefined): boolean { - return type === 'state' || type === 'start' || type === 'process'; + return type === 'state' || type === 'start' || type === 'process' || type === 'section'; } export function looksLikeStateDiagram(nodes: FlowNode[]): boolean { @@ -120,7 +120,8 @@ export function toStateDiagramMermaid(nodes: FlowNode[], edges: FlowEdge[]): str const sourceParentId = sourceNode ? getNodeParentId(sourceNode) : ''; const targetParentId = targetNode ? getNodeParentId(targetNode) : ''; const shouldEmitInsideParent = - (sourceParentId === node.id && (targetParentId === node.id || edge.target.startsWith('state_start_'))) || + (sourceParentId === node.id && + (targetParentId === node.id || edge.target.startsWith('state_start_'))) || (targetParentId === node.id && edge.source.startsWith('state_start_')); if (!shouldEmitInsideParent) { @@ -146,7 +147,10 @@ export function toStateDiagramMermaid(nodes: FlowNode[], edges: FlowEdge[]): str const targetNode = nodeById.get(edge.target); if (!sourceNode || !targetNode) return; - if (getNodeParentId(sourceNode) && getNodeParentId(sourceNode) === getNodeParentId(targetNode)) { + if ( + getNodeParentId(sourceNode) && + getNodeParentId(sourceNode) === getNodeParentId(targetNode) + ) { return; } diff --git a/src/services/export/mermaidBuilder.ts b/src/services/export/mermaidBuilder.ts index 300a5519..9b21ed3a 100644 --- a/src/services/export/mermaidBuilder.ts +++ b/src/services/export/mermaidBuilder.ts @@ -1,5 +1,5 @@ import type { FlowEdge, FlowNode } from '@/lib/types'; -import { sanitizeId, sanitizeLabel } from './formatting'; +import { sanitizeId, sanitizeLabel, sanitizeEdgeLabel } from './formatting'; import { toArchitectureMermaid } from './mermaid/architectureMermaid'; import { toMindmapMermaid } from './mermaid/mindmapMermaid'; import { toJourneyMermaid } from './mermaid/journeyMermaid'; @@ -94,7 +94,7 @@ function toFlowchartMermaid(nodes: FlowNode[], edges: FlowEdge[]): string { const target = sanitizeId(edge.target); const connector = resolveFlowchartConnector(edge); if (edge.label) { - const label = sanitizeLabel(edge.label as string); + const label = sanitizeEdgeLabel(edge.label as string); mermaid += ` ${source} ${connector}|"${label}"| ${target}\n`; } else { mermaid += ` ${source} ${connector} ${target}\n`; diff --git a/src/services/export/plantumlBuilder.ts b/src/services/export/plantumlBuilder.ts index 9e636ec4..cb5b33b0 100644 --- a/src/services/export/plantumlBuilder.ts +++ b/src/services/export/plantumlBuilder.ts @@ -1,5 +1,5 @@ import type { FlowEdge, FlowNode } from '@/lib/types'; -import { sanitizeId, sanitizeLabel } from './formatting'; +import { sanitizeId, sanitizeLabel, sanitizeEdgeLabel } from './formatting'; export function toPlantUML(nodes: FlowNode[], edges: FlowEdge[]): string { let plantuml = '@startuml\n\n'; @@ -28,7 +28,7 @@ export function toPlantUML(nodes: FlowNode[], edges: FlowEdge[]): string { edges.forEach((edge) => { const source = sanitizeId(edge.source); const target = sanitizeId(edge.target); - const label = edge.label ? ` : ${sanitizeLabel(edge.label as string)}` : ''; + const label = edge.label ? ` : ${sanitizeEdgeLabel(edge.label as string)}` : ''; plantuml += `${source} --> ${target}${label}\n`; }); diff --git a/src/services/flowpilot/assetGrounding.ts b/src/services/flowpilot/assetGrounding.ts index 98877e7f..5f1d0f5e 100644 --- a/src/services/flowpilot/assetGrounding.ts +++ b/src/services/flowpilot/assetGrounding.ts @@ -12,25 +12,155 @@ const ALL_GROUNDING_CATEGORIES: DomainLibraryCategory[] = [ ]; const SERVICE_ALIASES: Array<{ query: string; categories?: DomainLibraryCategory[] }> = [ + // AWS Services { query: 'API Gateway', categories: ['aws'] }, { query: 'Lambda', categories: ['aws'] }, { query: 'S3', categories: ['aws'] }, { query: 'RDS', categories: ['aws'] }, { query: 'ElastiCache', categories: ['aws'] }, { query: 'Cognito', categories: ['aws'] }, + { query: 'DynamoDB', categories: ['aws'] }, + { query: 'Aurora', categories: ['aws'] }, + { query: 'EC2', categories: ['aws'] }, + { query: 'ECS', categories: ['aws'] }, + { query: 'EKS', categories: ['aws'] }, + { query: 'SQS', categories: ['aws'] }, + { query: 'SNS', categories: ['aws'] }, + { query: 'CloudFront', categories: ['aws'] }, + { query: 'ALB', categories: ['aws'] }, + { query: 'EventBridge', categories: ['aws'] }, + { query: 'Step Functions', categories: ['aws'] }, + { query: 'CloudWatch', categories: ['aws'] }, + { query: 'Secrets Manager', categories: ['aws'] }, + { query: 'Kinesis', categories: ['aws'] }, + { query: 'Redshift', categories: ['aws'] }, + { query: 'Glue', categories: ['aws'] }, + { query: 'SageMaker', categories: ['aws'] }, + + // Azure Services { query: 'Azure Functions', categories: ['azure'] }, { query: 'Azure SQL', categories: ['azure'] }, { query: 'Storage Account', categories: ['azure'] }, { query: 'API Management', categories: ['azure'] }, + { query: 'Service Bus', categories: ['azure'] }, + { query: 'Event Hubs', categories: ['azure'] }, + { query: 'Cosmos DB', categories: ['azure'] }, + { query: 'Front Door', categories: ['azure'] }, + { query: 'Key Vault', categories: ['azure'] }, + { query: 'Azure Monitor', categories: ['azure'] }, + { query: 'Azure Kubernetes', categories: ['azure'] }, + { query: 'App Service', categories: ['azure'] }, + { query: 'Azure Cache', categories: ['azure'] }, + + // GCP Services { query: 'Cloud Run', categories: ['gcp'] }, { query: 'Cloud SQL', categories: ['gcp'] }, { query: 'Cloud Storage', categories: ['gcp'] }, + { query: 'Cloud Functions', categories: ['gcp'] }, + { query: 'BigQuery', categories: ['gcp'] }, + { query: 'Pub/Sub', categories: ['gcp'] }, + { query: 'Cloud CDN', categories: ['gcp'] }, + { query: 'Firestore', categories: ['gcp'] }, + { query: 'Cloud Build', categories: ['gcp'] }, + { query: 'Vertex AI', categories: ['gcp'] }, + { query: 'Memorystore', categories: ['gcp'] }, + { query: 'GKE', categories: ['gcp'] }, + { query: 'Cloud Armor', categories: ['gcp'] }, + + // CNCF / Kubernetes { query: 'Kubernetes', categories: ['cncf'] }, { query: 'Ingress', categories: ['cncf'] }, - { query: 'Redis' }, - { query: 'Postgres' }, + { query: 'Envoy', categories: ['cncf'] }, + { query: 'Istio', categories: ['cncf'] }, + { query: 'Helm', categories: ['cncf'] }, + { query: 'Prometheus', categories: ['cncf'] }, + { query: 'Containerd', categories: ['cncf'] }, + { query: 'Fluentd', categories: ['cncf'] }, + { query: 'CoreDNS', categories: ['cncf'] }, + { query: 'etcd', categories: ['cncf'] }, + { query: 'Argo', categories: ['cncf'] }, + { query: 'Linkerd', categories: ['cncf'] }, + + // Databases (developer catalog) + { query: 'PostgreSQL', categories: ['developer'] }, + { query: 'Postgres', categories: ['developer'] }, + { query: 'MySQL', categories: ['developer'] }, + { query: 'MongoDB', categories: ['developer'] }, + { query: 'Redis', categories: ['developer'] }, + { query: 'Elasticsearch', categories: ['developer'] }, + { query: 'SQLite', categories: ['developer'] }, + { query: 'MariaDB', categories: ['developer'] }, + { query: 'Cassandra', categories: ['developer'] }, + { query: 'Neo4j', categories: ['developer'] }, + { query: 'Supabase', categories: ['developer'] }, + { query: 'PlanetScale', categories: ['developer'] }, + + // Frameworks & Runtimes + { query: 'Express', categories: ['developer'] }, + { query: 'Node.js', categories: ['developer'] }, + { query: 'React', categories: ['developer'] }, + { query: 'Vue', categories: ['developer'] }, + { query: 'Angular', categories: ['developer'] }, + { query: 'Svelte', categories: ['developer'] }, + { query: 'Next.js', categories: ['developer'] }, + { query: 'Nuxt', categories: ['developer'] }, + { query: 'Django', categories: ['developer'] }, + { query: 'Flask', categories: ['developer'] }, + { query: 'FastAPI', categories: ['developer'] }, + { query: 'Spring', categories: ['developer'] }, + { query: 'Rails', categories: ['developer'] }, + { query: 'Laravel', categories: ['developer'] }, + { query: 'NestJS', categories: ['developer'] }, + { query: 'Deno', categories: ['developer'] }, + { query: 'Bun', categories: ['developer'] }, + { query: 'Go', categories: ['developer'] }, + { query: 'Rust', categories: ['developer'] }, + { query: 'Python', categories: ['developer'] }, + { query: 'TypeScript', categories: ['developer'] }, + + // Infrastructure & DevOps + { query: 'Docker', categories: ['developer'] }, + { query: 'Nginx', categories: ['developer'] }, + { query: 'RabbitMQ', categories: ['developer'] }, + { query: 'Kafka', categories: ['developer'] }, + { query: 'Terraform', categories: ['developer'] }, + { query: 'Ansible', categories: ['developer'] }, + { query: 'Jenkins', categories: ['developer'] }, + { query: 'GitHub', categories: ['developer'] }, + { query: 'GitLab', categories: ['developer'] }, + { query: 'Grafana', categories: ['developer'] }, + { query: 'Consul', categories: ['developer'] }, + { query: 'Vault', categories: ['developer'] }, + { query: 'Pulsar', categories: ['developer'] }, + { query: 'NATS', categories: ['developer'] }, + + // Auth & Payments + { query: 'Auth0', categories: ['developer'] }, + { query: 'Keycloak', categories: ['developer'] }, + { query: 'Firebase', categories: ['developer'] }, + { query: 'Stripe', categories: ['developer'] }, + { query: 'Twilio', categories: ['developer'] }, + { query: 'SendGrid', categories: ['developer'] }, + { query: 'Cloudflare', categories: ['developer'] }, + { query: 'Vercel', categories: ['developer'] }, + { query: 'Netlify', categories: ['developer'] }, + + // Generic terms (search all categories) { query: 'Queue' }, { query: 'Database' }, + { query: 'Cache' }, + { query: 'Load Balancer' }, + { query: 'CDN' }, + { query: 'Storage' }, + { query: 'Auth' }, + { query: 'API' }, + { query: 'Gateway' }, + { query: 'Monitoring' }, + { query: 'Logging' }, + { query: 'Search' }, + { query: 'Analytics' }, + { query: 'ML' }, + { query: 'AI' }, ]; function scoreMatch(item: DomainLibraryItem, query: string): number { @@ -72,8 +202,12 @@ function toGroundingMatch(item: DomainLibraryItem, query: string): AssetGroundin }; } -function inferQueriesFromPrompt(prompt: string): Array<{ query: string; categories?: DomainLibraryCategory[] }> { - const matches = SERVICE_ALIASES.filter((entry) => prompt.toLowerCase().includes(entry.query.toLowerCase())); +function inferQueriesFromPrompt( + prompt: string +): Array<{ query: string; categories?: DomainLibraryCategory[] }> { + const matches = SERVICE_ALIASES.filter((entry) => + prompt.toLowerCase().includes(entry.query.toLowerCase()) + ); if (matches.length > 0) { return matches; } diff --git a/src/services/geminiSystemInstruction.ts b/src/services/geminiSystemInstruction.ts index 39df5e56..86e396a0 100644 --- a/src/services/geminiSystemInstruction.ts +++ b/src/services/geminiSystemInstruction.ts @@ -3,8 +3,8 @@ const EDIT_MODE_PREAMBLE = ` A CURRENT DIAGRAM block will be provided in OpenFlow DSL. You MUST: 1. Output the COMPLETE updated diagram in OpenFlow DSL — not just the changed parts -2. Preserve every node that should remain — copy its id, type, label, icon, color, and all attributes EXACTLY as they appear in CURRENT DIAGRAM -3. Use the EXACT same node id for every unchanged node (e.g. if CURRENT DIAGRAM has \`node-abc123: Login Service\`, your output must also use \`node-abc123\`) +2. Preserve every node that should remain — copy its id, type, label, and all attributes EXACTLY as they appear in CURRENT DIAGRAM +3. Use the EXACT same node id for every unchanged node 4. Only change what the user explicitly requested 5. New nodes should have short descriptive IDs (e.g. \`redis_cache\`, \`auth_v2\`) 6. Do NOT re-layout or restructure nodes not affected by the change @@ -17,193 +17,86 @@ A CURRENT DIAGRAM block will be provided in OpenFlow DSL. You MUST: const BASE_SYSTEM_INSTRUCTION = ` # OpenFlow DSL Generation System -You are an expert diagram assistant that converts plain language into **OpenFlow DSL**. - -Your job: -- Read any description of a process, system, or flow — casual or technical. -- Use conversation history for context and refinements. -- If an image is provided, convert the diagram/sketch into OpenFlow DSL. -- Infer obvious missing steps. -- Always output **only valid OpenFlow DSL** — no prose, no explanations, no markdown wrappers. +You convert plain language into **OpenFlow DSL** diagrams. Output ONLY valid OpenFlow DSL — no prose, no markdown wrappers. --- -## Structure Rules - -1. Start every diagram with a header: - \`\`\` - flow: Title Here - direction: TB - \`\`\` - - Default to \`TB\` (top-to-bottom) for most diagrams. - - Use \`LR\` (left-to-right) for pipelines, timelines, stages, workflows, or CI/CD. - -2. Define all **Nodes first**, then all **Edges**. Never mix them. - - INVALID: \`[start] A -> [end] B\` - - VALID: define nodes, then \`A -> B\` +## Structure -3. Node ID rules: - - Short labels → use label as ID: \`[process] Login { icon: "LogIn" }\` - - Long labels → use ID prefix: \`[process] login_step: User enters credentials { icon: "LogIn" }\` +1. Header: \`flow: Title\` + \`direction: TB\` (default) or \`LR\` (pipelines, CI/CD). +2. Define ALL nodes first, then ALL edges. +3. Node IDs: simple labels can be the ID. Long labels need a prefix: \`[process] login_step: User enters credentials\` --- ## Node Types -| Type | When to use | +| Type | Use for | |---|---| | \`[start]\` | Entry point | -| \`[end]\` | Terminal state (success or failure) | -| \`[process]\` | Any action, step, or task | +| \`[end]\` | Terminal state | +| \`[process]\` | Action, step, task | | \`[decision]\` | Branch / conditional | -| \`[system]\` | Application-level backend service, internal API, business logic component | -| \`[architecture]\` | Cloud or infrastructure resource such as AWS, Azure, GCP, Kubernetes, network, or security components | -| \`[browser]\` | Web page / frontend screen | +| \`[system]\` | Backend service, internal API, business logic | +| \`[architecture]\` | Cloud/infra resource (AWS, Azure, GCP, K8s) | +| \`[browser]\` | Web page / frontend | | \`[mobile]\` | Mobile screen | | \`[note]\` | Callout / annotation | --- -## Edge Styles — use these semantically +## Edges -| Syntax | Style | When to use | -|---|---|---| -| \`->\` | Normal arrow | Default connection | -| \`->|label|\` | Labeled arrow | Decision branches — ALWAYS label Yes/No, Pass/Fail etc. | -| \`==>\` | **Thick** | Primary happy path / critical route | -| \`-->\` | Curved | Soft / secondary flow | -| \`..>\` | Dashed | Optional, error path, alternative, async | +| Syntax | When | +|---|---| +| \`->\` | Default | +| \`->|label|\` | Decision branches (Yes/No, Pass/Fail) | +| \`==>\` | Primary/critical path | +| \`-->\` | Secondary/soft flow | +| \`..\` | Async, error, optional | --- -## Node Attributes — ALWAYS add \`icon\` and \`color\` to every non-start/end node - -Syntax: \`[type] id: Label { icon: "IconName", color: "color", subLabel: "optional subtitle" }\` +## Attributes -For \`[architecture]\` nodes use: -\`[architecture] id: Label { archProvider: "aws", archResourceType: "service", archIconPackId: "aws-official-starter-v1", color: "violet" }\` +Syntax: \`[type] id: Label { icon: "IconName", color: "color", subLabel: "subtitle" }\` -- Required attributes for \`[architecture]\`: \`archProvider\`, \`archResourceType\` -- Optional attributes for \`[architecture]\`: \`archIconPackId\`, \`archIconShapeId\`, \`color\`, \`subLabel\` -- Prefer \`[architecture]\` over \`[system]\` for cloud services, infrastructure, managed data stores, queues, gateways, network, and security resources -- Prefer \`[system]\` for application services, internal APIs, controllers, workers, and business logic that belong to the product itself +For \`[architecture]\` nodes: \`[architecture] id: Label { archProvider: "aws", archResourceType: "lambda", color: "violet" }\` -6. **subLabel** — add a short subtitle for context on complex nodes: - \`\`\` - [process] auth: Authenticate { icon: "Lock", color: "blue", subLabel: "OAuth 2.0 + JWT" } - [system] api: Payment API { icon: "CreditCard", color: "violet", subLabel: "Stripe v3" } - \`\`\` +Colors: \`blue\` (frontend), \`violet\` (backend), \`emerald\` (data), \`amber\` (decisions/queues), \`red\` (errors/end), \`slate\` (generic), \`pink\` (third-party), \`yellow\` (cache). -7. **Annotations** — use \`[note]\` to add callouts for constraints, caveats, or SLAs. Connect with a dashed edge \`..>\`: - \`\`\` - [note] sla: 99.9% Uptime required { color: "slate" } - api ..> sla - \`\`\` - -8. **No container nodes** — do not use \`[section]\` nodes or \`group {}\` blocks. Keep related nodes near each other and use labels or subtitles to imply layers such as frontend, backend, or data. +Icons are optional — the system auto-assigns them. Include an \`icon\` only when you want a specific Lucide icon name. --- -9. **Curated icon list** — pick the MOST semantically appropriate icon from this list: - - Actions: \`Play\`, \`Pause\`, \`Stop\`, \`Check\`, \`X\`, \`Plus\`, \`Trash2\`, \`Edit3\`, \`Send\`, \`Upload\`, \`Download\`, \`Search\`, \`Filter\`, \`RefreshCw\`, \`LogIn\`, \`LogOut\` - - Data & Dev: \`Database\`, \`Server\`, \`Code2\`, \`Terminal\`, \`GitBranch\`, \`Zap\`, \`Settings\`, \`Key\`, \`Lock\`, \`Unlock\`, \`ShieldCheck\`, \`AlertTriangle\` - - People: \`User\`, \`Users\`, \`UserCheck\`, \`UserPlus\`, \`Bell\`, \`Mail\`, \`Phone\`, \`MessageSquare\`, \`Contact\` - - Commerce: \`ShoppingCart\`, \`CreditCard\`, \`Package\`, \`Store\`, \`Tag\`, \`Receipt\`, \`Truck\` - - Content: \`File\`, \`FileText\`, \`Folder\`, \`Image\`, \`Link\`, \`Globe\`, \`Rss\` - - Infrastructure: \`Cloud\`, \`Wifi\`, \`Smartphone\`, \`Monitor\`, \`HardDrive\`, \`Cpu\` - -10. **Cloud provider icons** — when rendering infrastructure, use \`[architecture]\` nodes and these provider values: - - AWS: \`archProvider: "aws"\`, prefer \`archIconPackId: "aws-official-starter-v1"\` - Common services: EC2, S3, RDS, Lambda, DynamoDB, API Gateway, CloudFront, SQS, SNS, ECS, EKS, ElastiCache, Cognito, IAM - - Azure: \`archProvider: "azure"\`, prefer \`archIconPackId: "azure-official-icons-v20"\` - Common services: VM, Functions, Storage Account, Azure SQL, API Management, Front Door - - GCP: \`archProvider: "gcp"\` - Common services: Compute Engine, Cloud Functions, Cloud Storage, Cloud SQL, Load Balancer, Cloud Run - - Kubernetes / CNCF: \`archProvider: "cncf"\` - Common resources: Cluster, Node, Pod, Service, Ingress, ConfigMap - - Network: \`archProvider: "network"\` - Common resource types: \`load_balancer\`, \`router\`, \`switch\`, \`cdn\`, \`dns\`, \`service\` - - Security: \`archProvider: "security"\` - Common resource types: \`firewall\`, \`service\`, \`dns\` - -11. **Color semantics** — use colors deliberately, not randomly: - - \`blue\` → frontend, user-facing, presentation layer - - \`violet\` → backend services, APIs, internal systems - - \`emerald\` → data stores, persistence, successful outcomes - - \`amber\` → queues, async workers, warning states, decisions - - \`red\` → security boundaries, firewalls, error, end, fail, danger, cancel - - \`slate\` → generic fallback, unknown services, neutral groups - - \`pink\` → third-party or external services - - \`yellow\` → cache, fast path, in-memory systems - -12. **Use node types intentionally**: - - \`[architecture]\`: cloud services, infrastructure, managed databases, queues, gateways, DNS, CDN, VPN, firewalls - - \`[system]\`: product-owned backend services, internal APIs, modules, business logic - - \`[browser]\`: web apps, dashboards, admin panels, portals - - \`[mobile]\`: iOS, Android, React Native, Flutter apps - - \`[process]\`: operational steps, jobs, transformations, workflows - - Do not use container or group nodes for layers, trust boundaries, VPCs, clusters, namespaces, or zones - -13. Label important edges with what flows across them, especially in architecture diagrams: \`HTTP/REST\`, \`SQL\`, \`gRPC\`, \`events\`, \`cache lookup\`, \`files\` - -14. Use comments \`#\` only when they add clarity. - -15. Do NOT explain the output. Do NOT add prose. Only output DSL. - -15b. **Diagram density** — aim for the right density: - - Flowcharts: 6–15 nodes is ideal. More than 20 = simplify the diagram. - - Architecture diagrams: 8–20 nodes, with layers implied by labels, subtitles, and placement instead of containers. - - Sequence/journey: 4–10 steps in the happy path. - - If a request is simple, keep the diagram simple. Do not pad with unnecessary detail. - -15c. **Layout quality rules**: - - Happy path flows TOP → BOTTOM (TB) or LEFT → RIGHT (LR) in a straight line, with alternatives branching off the sides. - - Decision nodes (\`[decision]\`) should have EXACTLY 2 outgoing labeled edges (e.g. \`->|Yes|\` and \`->|No|\`). - - Avoid more than 3 incoming edges on any single node — use a \`[process]\` aggregator if needed. - - Keep tightly coupled nodes visually close without using container blocks. - - Name architectural layers directly in node labels or subtitles instead of using container nodes. - - Use \`==>\` (thick) for the critical path, \`->\` for normal flow, \`..>\` for async/optional, \`-->\` for soft/secondary. - -15d. **Self-describing diagrams** — every diagram should be readable without a legend: - - Include \`subLabel\` on complex nodes to explain protocols, versions, or constraints. - - Label important edges with what flows across them: \`HTTP/REST\`, \`SQL query\`, \`JWT\`, \`events\`, \`file\`. - - Use \`[note]\` nodes for critical constraints, SLAs, or caveats — connect with \`..>\`. - -16. **Node IDs**: - - If the label is simple (e.g., "Login"), you can use it as the ID: \`[process] Login { icon: "LogIn" }\`. - - If the label is long, use an ID: \`[process] login_step: User enters credentials { icon: "LogIn" }\`. - -17. **Iterative editing — preserve existing IDs**: - - When a CURRENT CONTENT block is provided, it includes each node's exact \`id\` (e.g. \`"id": "node-abc123"\`). - - For nodes that should REMAIN in the diagram, reuse their EXACT id as the node identifier in your DSL output. - - Example: if context shows \`"id": "node-abc123", "label": "Login"\`, output \`[process] node-abc123: Login { icon: "LogIn", color: "blue" }\` - - Only introduce new ids for genuinely new nodes you are adding. - - Omit nodes that should be removed — do not output them at all. - - When a FOCUSED EDIT is specified (selected nodes), preserve all non-selected nodes verbatim with their exact IDs and properties. +## Rules + +- Decisions: exactly 2 outgoing labeled edges +- Max 3 incoming edges per node +- Label edges with what flows: \`HTTP/REST\`, \`SQL\`, \`events\`, \`JWT\` +- Use \`subLabel\` for protocols, versions, constraints +- Use \`[note]\` for SLAs/caveats, connected with \`..\` +- 6–15 nodes for flowcharts, 8–20 for architecture +- Do NOT use container/group nodes +- When editing, preserve existing node IDs exactly --- ## Examples -### User Authentication +### Authentication Flow \`\`\` flow: User Authentication direction: TB [start] Start -[process] login: Login Form { icon: "LogIn", color: "blue", subLabel: "Email + password" } -[decision] valid: Credentials valid? { icon: "ShieldCheck", color: "amber" } -[process] mfa: MFA Check { icon: "Smartphone", color: "blue", subLabel: "TOTP / SMS" } -[process] token: Issue JWT { icon: "Key", color: "violet" } -[end] dashboard: Enter Dashboard { icon: "Monitor", color: "emerald" } -[end] fail: Access Denied { icon: "X", color: "red" } +[process] login: Login Form { icon: "LogIn", color: "blue" } +[decision] valid: Credentials valid? { color: "amber" } +[process] mfa: MFA Check { icon: "Smartphone", color: "blue" } +[system] token: Issue JWT { icon: "Key", color: "violet" } +[end] dashboard: Enter Dashboard { color: "emerald" } +[end] fail: Access Denied { color: "red" } Start ==> login login -> valid @@ -213,80 +106,22 @@ mfa ==> token token ==> dashboard \`\`\` -### E-Commerce Checkout - -\`\`\` -flow: Checkout Flow -direction: TB - -[start] Start -[process] cart: Review Cart { icon: "ShoppingCart", color: "blue" } -[process] address: Shipping Address { icon: "Truck", color: "blue" } -[process] payment: Payment Details { icon: "CreditCard", color: "blue", subLabel: "Stripe v3" } -[decision] fraud: Fraud check { icon: "ShieldCheck", color: "amber" } -[system] fulfil: Fulfilment Service { icon: "Package", color: "violet" } -[process] notify: Send Confirmation { icon: "Mail", color: "emerald", subLabel: "Email + SMS" } -[end] done: Order Complete { icon: "Check", color: "emerald" } -[end] declined: Payment Declined { icon: "AlertTriangle", color: "red" } - -Start ==> cart -cart ==> address -address ==> payment -payment -> fraud -fraud ->|Pass| fulfil -fraud ->|Fail| declined -fulfil ==> notify -notify ==> done -\`\`\` - -### CI/CD Pipeline - -\`\`\` -flow: CI/CD Pipeline -direction: LR - -[start] Push -[process] build: Build { icon: "Code2", color: "blue", subLabel: "npm run build" } -[process] test: Run Tests { icon: "Check", color: "blue", subLabel: "Jest + Playwright" } -[decision] pass: All tests pass? { icon: "GitBranch", color: "amber" } -[system] registry: Push to Registry { icon: "Cloud", color: "violet", subLabel: "Docker Hub" } -[process] deploy: Deploy to Production { icon: "Zap", color: "emerald" } -[process] slack_notify: Slack Notification { icon: "MessageSquare", color: "blue" } -[end] live: Live { icon: "Globe", color: "emerald" } -[end] failed: Build Failed { icon: "X", color: "red" } - -Push ==> build -build ==> test -test -> pass -pass ->|Yes| registry -pass ->|No| failed -registry ==> deploy -deploy ..> slack_notify -slack_notify ==> live -\`\`\` - -### Architecture Diagram +### AWS Serverless Architecture \`\`\` -flow: Serverless API - AWS +flow: Serverless API direction: TB -[architecture] cf: CloudFront { archProvider: "aws", archResourceType: "cdn", archIconPackId: "aws-official-starter-v1", color: "blue" } -[architecture] apigw: API Gateway { archProvider: "aws", archResourceType: "service", archIconPackId: "aws-official-starter-v1", color: "violet", subLabel: "Edge Layer" } -[architecture] auth_fn: Auth Lambda { archProvider: "aws", archResourceType: "service", archIconPackId: "aws-official-starter-v1", color: "violet" } -[architecture] api_fn: API Lambda { archProvider: "aws", archResourceType: "service", archIconPackId: "aws-official-starter-v1", color: "violet", subLabel: "Compute Layer" } -[architecture] dynamo: DynamoDB { archProvider: "aws", archResourceType: "database", archIconPackId: "aws-official-starter-v1", color: "emerald" } -[architecture] cache: ElastiCache { archProvider: "aws", archResourceType: "service", archIconPackId: "aws-official-starter-v1", color: "yellow" } -[architecture] s3: S3 Storage { archProvider: "aws", archResourceType: "service", archIconPackId: "aws-official-starter-v1", color: "emerald", subLabel: "Data Layer" } -[architecture] cognito: Cognito { archProvider: "aws", archResourceType: "service", archIconPackId: "aws-official-starter-v1", color: "amber" } +[architecture] cf: CloudFront { archProvider: "aws", archResourceType: "cdn", color: "blue" } +[architecture] apigw: API Gateway { archProvider: "aws", archResourceType: "service", color: "violet" } +[architecture] lambda: API Lambda { archProvider: "aws", archResourceType: "lambda", color: "violet" } +[architecture] dynamo: DynamoDB { archProvider: "aws", archResourceType: "database", color: "emerald" } +[architecture] cache: ElastiCache { archProvider: "aws", archResourceType: "service", color: "yellow" } cf ->|HTTPS| apigw -apigw ->|auth request| auth_fn -apigw ->|HTTP/REST| api_fn -auth_fn ->|identity| cognito -api_fn ->|query| dynamo -api_fn ->|cache lookup| cache -api_fn ->|store files| s3 +apigw ->|HTTP/REST| lambda +lambda ->|query| dynamo +lambda ->|cache lookup| cache \`\`\` `; diff --git a/src/services/mermaid/detectDiagramType.test.ts b/src/services/mermaid/detectDiagramType.test.ts index 5afed97a..1e7333df 100644 --- a/src/services/mermaid/detectDiagramType.test.ts +++ b/src/services/mermaid/detectDiagramType.test.ts @@ -14,10 +14,10 @@ describe('detectMermaidDiagramType', () => { it('detects target q2 families', () => { expect(detectMermaidDiagramType('classDiagram\nA <|-- B')).toBe('classDiagram'); expect(detectMermaidDiagramType('erDiagram\nA ||--o{ B : has')).toBe('erDiagram'); - expect(detectMermaidDiagramType('gitGraph\ncommit')).toBe('gitGraph'); expect(detectMermaidDiagramType('mindmap\nroot')).toBe('mindmap'); expect(detectMermaidDiagramType('journey\ntitle Onboarding')).toBe('journey'); expect(detectMermaidDiagramType('architecture-beta\nservice api')).toBe('architecture'); + expect(detectMermaidDiagramType('sequenceDiagram\nparticipant A')).toBe('sequence'); }); it('skips empty and comment lines', () => { @@ -35,4 +35,3 @@ A --> B expect(detectMermaidDiagramType('')).toBeNull(); }); }); - diff --git a/src/services/mermaid/detectDiagramType.ts b/src/services/mermaid/detectDiagramType.ts index b7d5a7b2..0be69d7f 100644 --- a/src/services/mermaid/detectDiagramType.ts +++ b/src/services/mermaid/detectDiagramType.ts @@ -15,14 +15,13 @@ export function detectMermaidDiagramType(input: string): DiagramType | null { if (/^stateDiagram(?:-v2)?\b/i.test(line)) return 'stateDiagram'; if (/^classDiagram\b/i.test(line)) return 'classDiagram'; if (/^erDiagram\b/i.test(line)) return 'erDiagram'; - if (/^gitGraph\b/i.test(line)) return 'gitGraph'; if (/^mindmap\b/i.test(line)) return 'mindmap'; if (/^journey\b/i.test(line)) return 'journey'; if (/^architecture(?:-beta)?\b/i.test(line)) return 'architecture'; + if (/^sequenceDiagram\b/i.test(line)) return 'sequence'; return null; } return null; } - diff --git a/src/services/mermaid/parseMermaidByType.test.ts b/src/services/mermaid/parseMermaidByType.test.ts index 01ce8d4b..23b34b38 100644 --- a/src/services/mermaid/parseMermaidByType.test.ts +++ b/src/services/mermaid/parseMermaidByType.test.ts @@ -67,8 +67,14 @@ describe('parseMermaidByType', () => { expect(result.diagramType).toBe('classDiagram'); expect(result.error).toBeUndefined(); expect(result.nodes.length).toBeGreaterThan(0); - expect(result.diagnostics?.some((message) => message.includes('Invalid class declaration at line'))).toBe(true); - expect(result.diagnostics?.some((message) => message.includes('Invalid class relation syntax at line'))).toBe(true); + expect( + result.diagnostics?.some((message) => message.includes('Invalid class declaration at line')) + ).toBe(true); + expect( + result.diagnostics?.some((message) => + message.includes('Invalid class relation syntax at line') + ) + ).toBe(true); }); it('parses erDiagram through plugin dispatcher', () => { @@ -103,8 +109,14 @@ describe('parseMermaidByType', () => { expect(result.diagramType).toBe('erDiagram'); expect(result.error).toBeUndefined(); expect(result.nodes.length).toBeGreaterThan(0); - expect(result.diagnostics?.some((message) => message.includes('Invalid entity declaration at line'))).toBe(true); - expect(result.diagnostics?.some((message) => message.includes('Invalid erDiagram relation syntax at line'))).toBe(true); + expect( + result.diagnostics?.some((message) => message.includes('Invalid entity declaration at line')) + ).toBe(true); + expect( + result.diagnostics?.some((message) => + message.includes('Invalid erDiagram relation syntax at line') + ) + ).toBe(true); }); it('parses mindmap through plugin dispatcher', () => { @@ -148,8 +160,14 @@ describe('parseMermaidByType', () => { expect(result.diagramType).toBe('journey'); expect(result.error).toBeUndefined(); expect(result.nodes.length).toBeGreaterThan(0); - expect(result.diagnostics?.some((message) => message.includes('Invalid journey section syntax at line'))).toBe(true); - expect(result.diagnostics?.some((message) => message.includes('Invalid journey step syntax at line'))).toBe(true); + expect( + result.diagnostics?.some((message) => + message.includes('Invalid journey section syntax at line') + ) + ).toBe(true); + expect( + result.diagnostics?.some((message) => message.includes('Invalid journey step syntax at line')) + ).toBe(true); }); it('returns mindmap diagnostics for malformed indentation/wrapper lines', () => { @@ -164,8 +182,29 @@ describe('parseMermaidByType', () => { expect(result.diagramType).toBe('mindmap'); expect(result.error).toBeUndefined(); expect(result.nodes.length).toBeGreaterThan(0); - expect(result.diagnostics?.some((message) => message.includes('Mindmap indentation jump at line'))).toBe(true); - expect(result.diagnostics?.some((message) => message.includes('Malformed mindmap wrapper syntax at line'))).toBe(true); + expect( + result.diagnostics?.some((message) => message.includes('Mindmap indentation jump at line')) + ).toBe(true); + expect( + result.diagnostics?.some((message) => + message.includes('Malformed mindmap wrapper syntax at line') + ) + ).toBe(true); + }); + + it('parses sequenceDiagram through plugin dispatcher', () => { + const result = parseMermaidByType(` + sequenceDiagram + participant Alice + participant Bob + Alice->>Bob: Hello + Bob-->>Alice: Hi + `); + + expect(result.diagramType).toBe('sequence'); + expect(result.error).toBeUndefined(); + expect(result.nodes.length).toBeGreaterThan(0); + expect(result.edges.length).toBeGreaterThan(0); }); it('parses architecture through plugin dispatcher', () => { @@ -187,28 +226,32 @@ describe('parseMermaidByType', () => { }); it('rejects architecture recovery diagnostics in strict mode', () => { - const result = parseMermaidByType(` + const result = parseMermaidByType( + ` architecture-beta service api(server)[API] api --> cache - `, { architectureStrictMode: true }); + `, + { architectureStrictMode: true } + ); expect(result.diagramType).toBe('architecture'); expect(result.error).toContain('strict mode rejected'); expect(result.nodes).toHaveLength(0); expect(result.edges).toHaveLength(0); - expect(result.diagnostics?.some((d) => d.includes('Recovered implicit service node "cache"'))).toBe(true); + expect( + result.diagnostics?.some((d) => d.includes('Recovered implicit service node "cache"')) + ).toBe(true); }); - it('returns explicit unsupported error for non-supported families', () => { + it('returns missing-header error for unsupported diagram types like gitGraph', () => { const result = parseMermaidByType(` gitGraph commit id: "A" commit id: "B" `); - expect(result.diagramType).toBe('gitGraph'); - expect(result.error).toContain('not supported yet in editable mode'); + expect(result.error).toContain('Missing chart type declaration'); expect(result.nodes).toHaveLength(0); expect(result.edges).toHaveLength(0); }); diff --git a/src/services/mermaid/parseMermaidByType.ts b/src/services/mermaid/parseMermaidByType.ts index 3ece8892..787af22a 100644 --- a/src/services/mermaid/parseMermaidByType.ts +++ b/src/services/mermaid/parseMermaidByType.ts @@ -13,19 +13,31 @@ export interface ParseMermaidByTypeOptions { architectureStrictMode?: boolean; } -const SUPPORTED_MERMAID_FAMILIES: DiagramType[] = ['flowchart', 'stateDiagram', 'classDiagram', 'erDiagram', 'mindmap', 'journey', 'architecture']; +const SUPPORTED_MERMAID_FAMILIES: DiagramType[] = [ + 'flowchart', + 'stateDiagram', + 'classDiagram', + 'erDiagram', + 'mindmap', + 'journey', + 'architecture', + 'sequence', +]; function getUnsupportedTypeError(diagramType: DiagramType): string { - return `Mermaid "${diagramType}" is not supported yet in editable mode. Supported families: flowchart, stateDiagram, classDiagram, erDiagram, mindmap, journey, architecture.`; + return `Mermaid "${diagramType}" is not supported yet in editable mode. Supported families: flowchart, stateDiagram, classDiagram, erDiagram, mindmap, journey, architecture, sequence.`; } -function applyArchitectureStrictMode(result: MermaidDispatchParseResult): MermaidDispatchParseResult { +function applyArchitectureStrictMode( + result: MermaidDispatchParseResult +): MermaidDispatchParseResult { const diagnostics = Array.isArray(result.diagnostics) ? result.diagnostics : []; - const strictViolations = diagnostics.filter((message) => ( - message.startsWith('Invalid architecture ') - || message.startsWith('Duplicate architecture node id') - || message.startsWith('Recovered implicit service node') - )); + const strictViolations = diagnostics.filter( + (message) => + message.startsWith('Invalid architecture ') || + message.startsWith('Duplicate architecture node id') || + message.startsWith('Recovered implicit service node') + ); if (strictViolations.length === 0) { return result; @@ -70,7 +82,8 @@ export function parseMermaidByType( return { nodes: [], edges: [], - error: 'Missing chart type declaration. Start with "flowchart TD", "stateDiagram-v2", or another Mermaid diagram type header.', + error: + 'Missing chart type declaration. Start with "flowchart TD", "stateDiagram-v2", or another Mermaid diagram type header.', }; } diff --git a/src/services/mermaidParser.test.ts b/src/services/mermaidParser.test.ts index 1ed0ebbb..28010048 100644 --- a/src/services/mermaidParser.test.ts +++ b/src/services/mermaidParser.test.ts @@ -2,195 +2,223 @@ import { describe, it, expect } from 'vitest'; import { parseMermaid } from '@/lib/mermaidParser'; describe('mermaidParser', () => { - it('should parse a basic flowchart with TD direction', () => { - const input = ` + it('should parse a basic flowchart with TD direction', () => { + const input = ` flowchart TD A[Start] --> B[End] `; - const result = parseMermaid(input); - expect(result.nodes).toHaveLength(2); - expect(result.edges).toHaveLength(1); - expect(result.nodes[0].data.label).toBe('Start'); - expect(result.nodes[1].data.label).toBe('End'); - expect(result.edges[0].source).toBe('A'); - expect(result.edges[0].target).toBe('B'); - }); - - it('should handle different node types based on shapes', () => { - const input = ` + const result = parseMermaid(input); + expect(result.nodes).toHaveLength(2); + expect(result.edges).toHaveLength(1); + expect(result.nodes[0].data.label).toBe('Start'); + expect(result.nodes[1].data.label).toBe('End'); + expect(result.edges[0].source).toBe('A'); + expect(result.edges[0].target).toBe('B'); + }); + + it('should handle different node types based on shapes', () => { + const input = ` flowchart TD S([Start Node]) P[Process Node] D{Decision Node} E((End Node)) `; - const result = parseMermaid(input); - expect(result.nodes.find(n => n.id === 'S')?.type).toBe('start'); - expect(result.nodes.find(n => n.id === 'P')?.type).toBe('process'); - expect(result.nodes.find(n => n.id === 'D')?.type).toBe('decision'); - expect(result.nodes.find(n => n.id === 'E')?.type).toBe('end'); - }); - - it('should parse edges with labels', () => { - const input = ` + const result = parseMermaid(input); + expect(result.nodes.find((n) => n.id === 'S')?.type).toBe('start'); + expect(result.nodes.find((n) => n.id === 'P')?.type).toBe('process'); + expect(result.nodes.find((n) => n.id === 'D')?.type).toBe('decision'); + expect(result.nodes.find((n) => n.id === 'E')?.type).toBe('end'); + }); + + it('should parse edges with labels', () => { + const input = ` flowchart TD A --> |Yes| B A --> |No| C `; - const result = parseMermaid(input); - expect(result.edges).toHaveLength(2); - expect(result.edges[0].label).toBe('Yes'); - expect(result.edges[1].label).toBe('No'); - }); - - it('should handle LR direction', () => { - const input = ` + const result = parseMermaid(input); + expect(result.edges).toHaveLength(2); + expect(result.edges[0].label).toBe('Yes'); + expect(result.edges[1].label).toBe('No'); + }); + + it('should handle LR direction', () => { + const input = ` flowchart LR A --> B `; - const result = parseMermaid(input); - expect(result.direction).toBe('LR'); - }); - - it('should return error if no flowchart declaration is found', () => { - const input = `A --> B`; - const result = parseMermaid(input); - expect(result.error).toBeDefined(); - expect(result.nodes).toHaveLength(0); - }); - - it('should handle inline node declarations in edges', () => { - const input = ` + const result = parseMermaid(input); + expect(result.direction).toBe('LR'); + }); + + it('should return error if no flowchart declaration is found', () => { + const input = `A --> B`; + const result = parseMermaid(input); + expect(result.error).toBeDefined(); + expect(result.nodes).toHaveLength(0); + }); + + it('should handle inline node declarations in edges', () => { + const input = ` flowchart TD A[Node A] --> B((Node B)) `; - const result = parseMermaid(input); - expect(result.nodes).toHaveLength(2); - expect(result.nodes.find(n => n.id === 'A')?.data.label).toBe('Node A'); - expect(result.nodes.find(n => n.id === 'B')?.type).toBe('end'); - }); + const result = parseMermaid(input); + expect(result.nodes).toHaveLength(2); + expect(result.nodes.find((n) => n.id === 'A')?.data.label).toBe('Node A'); + expect(result.nodes.find((n) => n.id === 'B')?.type).toBe('end'); + }); - // --- NEW TESTS --- + // --- NEW TESTS --- - it('should support "graph TD" keyword (not just flowchart)', () => { - const input = ` + it('should support "graph TD" keyword (not just flowchart)', () => { + const input = ` graph TD A[Start] --> B[End] `; - const result = parseMermaid(input); - expect(result.nodes).toHaveLength(2); - expect(result.edges).toHaveLength(1); - expect(result.direction).toBe('TB'); - }); - - it('should strip fa: icon prefixes from labels', () => { - const input = ` + const result = parseMermaid(input); + expect(result.nodes).toHaveLength(2); + expect(result.edges).toHaveLength(1); + expect(result.direction).toBe('TB'); + }); + + it('should strip fa: icon prefixes from labels', () => { + const input = ` graph TD Bat(fa:fa-car-battery Batteries) --> ShutOff[Shut Off] `; - const result = parseMermaid(input); - expect(result.nodes.find(n => n.id === 'Bat')?.data.label).toBe('Batteries'); - expect(result.nodes.find(n => n.id === 'ShutOff')?.data.label).toBe('Shut Off'); - }); + const result = parseMermaid(input); + expect(result.nodes.find((n) => n.id === 'Bat')?.data.label).toBe('Batteries'); + expect(result.nodes.find((n) => n.id === 'ShutOff')?.data.label).toBe('Shut Off'); + }); - it('should handle chained edges: A --> B --> C', () => { - const input = ` + it('should handle modern @{shape: name} syntax', () => { + const input = ` + flowchart TD + A@{shape: cyl}[(Database)] + B@{shape: diamond}{Is Valid?} + C@{shape: stadium}[Start] + `; + const result = parseMermaid(input); + expect(result.nodes).toHaveLength(3); + expect(result.nodes.find((n) => n.id === 'A')?.type).toBe('process'); + expect(result.nodes.find((n) => n.id === 'A')?.data.shape).toBe('cylinder'); + expect(result.nodes.find((n) => n.id === 'B')?.type).toBe('decision'); + expect(result.nodes.find((n) => n.id === 'B')?.data.shape).toBe('diamond'); + expect(result.nodes.find((n) => n.id === 'C')?.type).toBe('start'); + }); + + it('should strip markdown from labels', () => { + const input = ` + flowchart TD + A[**Bold** text] --> B[*Italic* label] + `; + const result = parseMermaid(input); + expect(result.nodes.find((n) => n.id === 'A')?.data.label).toBe('Bold text'); + expect(result.nodes.find((n) => n.id === 'B')?.data.label).toBe('Italic label'); + }); + + it('should handle chained edges: A --> B --> C', () => { + const input = ` flowchart TD A --> B --> C `; - const result = parseMermaid(input); - expect(result.nodes).toHaveLength(3); - expect(result.edges).toHaveLength(2); - expect(result.edges[0].source).toBe('A'); - expect(result.edges[0].target).toBe('B'); - expect(result.edges[1].source).toBe('B'); - expect(result.edges[1].target).toBe('C'); - }); - - it('should handle chained edges with labels', () => { - const input = ` + const result = parseMermaid(input); + expect(result.nodes).toHaveLength(3); + expect(result.edges).toHaveLength(2); + expect(result.edges[0].source).toBe('A'); + expect(result.edges[0].target).toBe('B'); + expect(result.edges[1].source).toBe('B'); + expect(result.edges[1].target).toBe('C'); + }); + + it('should handle chained edges with labels', () => { + const input = ` flowchart TD Fuse -->|1.5a| Switch -->|1.5a| Wifi `; - const result = parseMermaid(input); - expect(result.edges).toHaveLength(2); - expect(result.edges[0].source).toBe('Fuse'); - expect(result.edges[0].target).toBe('Switch'); - expect(result.edges[0].label).toBe('1.5a'); - expect(result.edges[1].source).toBe('Switch'); - expect(result.edges[1].target).toBe('Wifi'); - expect(result.edges[1].label).toBe('1.5a'); - }); - - it('ignores subgraph wrappers instead of creating container nodes', () => { - const input = ` + const result = parseMermaid(input); + expect(result.edges).toHaveLength(2); + expect(result.edges[0].source).toBe('Fuse'); + expect(result.edges[0].target).toBe('Switch'); + expect(result.edges[0].label).toBe('1.5a'); + expect(result.edges[1].source).toBe('Switch'); + expect(result.edges[1].target).toBe('Wifi'); + expect(result.edges[1].label).toBe('1.5a'); + }); + + it('creates section nodes for subgraph wrappers and sets parentId on children', () => { + const input = ` flowchart TD subgraph Services API[API] + DB[(Database)] end `; - const result = parseMermaid(input); - const apiNode = result.nodes.find((node) => node.id === 'API'); - expect(result.nodes).toHaveLength(1); - expect(apiNode?.parentId).toBeUndefined(); - }); - - it('should handle duplicate edges between same pair', () => { - const input = ` + const result = parseMermaid(input); + expect(result.nodes.length).toBeGreaterThanOrEqual(3); + const sectionNode = result.nodes.find((node) => node.type === 'section'); + expect(sectionNode).toBeDefined(); + expect(sectionNode?.data.label).toBe('Services'); + const apiNode = result.nodes.find((node) => node.id === 'API'); + expect(apiNode?.parentId).toBe(sectionNode?.id); + const dbNode = result.nodes.find((node) => node.id === 'DB'); + expect(dbNode?.parentId).toBe(sectionNode?.id); + }); + + it('should handle duplicate edges between same pair', () => { + const input = ` flowchart TD Fuse -->|10a| Cig1[Cigarette Lighter] Fuse -->|10a| Cig1 `; - const result = parseMermaid(input); - expect(result.edges).toHaveLength(2); - expect(result.edges[0].source).toBe('Fuse'); - expect(result.edges[1].source).toBe('Fuse'); - }); + const result = parseMermaid(input); + expect(result.edges).toHaveLength(2); + expect(result.edges[0].source).toBe('Fuse'); + expect(result.edges[1].source).toBe('Fuse'); + }); - it('should return direction in ParseResult', () => { - const lr = parseMermaid('flowchart LR\n A --> B'); - expect(lr.direction).toBe('LR'); + it('should return direction in ParseResult', () => { + const lr = parseMermaid('flowchart LR\n A --> B'); + expect(lr.direction).toBe('LR'); - const rl = parseMermaid('graph RL\n A --> B'); - expect(rl.direction).toBe('RL'); + const rl = parseMermaid('graph RL\n A --> B'); + expect(rl.direction).toBe('RL'); - const bt = parseMermaid('flowchart BT\n A --> B'); - expect(bt.direction).toBe('BT'); - }); + const bt = parseMermaid('flowchart BT\n A --> B'); + expect(bt.direction).toBe('BT'); + }); - it('should skip linkStyle, classDef, style directives gracefully', () => { - const input = ` + it('should skip linkStyle, classDef, style directives gracefully', () => { + const input = ` graph TD A --> B linkStyle 0 stroke-width:2px,fill:none,stroke:red; classDef default fill:#f9f style A fill:#bbf `; - const result = parseMermaid(input); - expect(result.nodes).toHaveLength(2); - expect(result.edges).toHaveLength(1); - expect(result.error).toBeUndefined(); - }); - - it('should parse linkStyle and apply stroke color to edges', () => { - const input = ` + const result = parseMermaid(input); + expect(result.nodes).toHaveLength(2); + expect(result.edges).toHaveLength(1); + expect(result.error).toBeUndefined(); + }); + + it('should parse linkStyle and apply stroke color to edges', () => { + const input = ` graph TD A --> B B --> C linkStyle 0 stroke-width:2px,fill:none,stroke:red; linkStyle 1 stroke-width:2px,fill:none,stroke:green; `; - const result = parseMermaid(input); - expect(result.edges[0].style).toEqual( - expect.objectContaining({ stroke: 'red' }) - ); - expect(result.edges[1].style).toEqual( - expect.objectContaining({ stroke: 'green' }) - ); - }); - - it('should handle the full battery diagram', () => { - const input = `graph TD + const result = parseMermaid(input); + expect(result.edges[0].style).toEqual(expect.objectContaining({ stroke: 'red' })); + expect(result.edges[1].style).toEqual(expect.objectContaining({ stroke: 'green' })); + }); + + it('should handle the full battery diagram', () => { + const input = `graph TD Bat(fa:fa-car-battery Batteries) -->|150a 50mm| ShutOff Bat -->|150a 50mm| Shunt @@ -236,125 +264,117 @@ describe('mermaidParser', () => { linkStyle 18 stroke-width:2px,fill:none,stroke:green; linkStyle 19 stroke-width:2px,fill:none,stroke:green;`; - const result = parseMermaid(input); - - // Should have no errors - expect(result.error).toBeUndefined(); - - // Direction should be TB - expect(result.direction).toBe('TB'); - - // Should find all unique nodes - const nodeIds = result.nodes.map(n => n.id); - expect(nodeIds).toContain('Bat'); - expect(nodeIds).toContain('ShutOff'); - expect(nodeIds).toContain('Shunt'); - expect(nodeIds).toContain('BusPos'); - expect(nodeIds).toContain('BusNeg'); - expect(nodeIds).toContain('Fuse'); - expect(nodeIds).toContain('Old'); - expect(nodeIds).toContain('USB'); - expect(nodeIds).toContain('Switch'); - expect(nodeIds).toContain('Wifi'); - expect(nodeIds).toContain('Cig1'); - expect(nodeIds).toContain('Cig2'); - expect(nodeIds).toContain('Solar'); - expect(nodeIds).toContain('SolarCont'); - - // Check labels - expect(result.nodes.find(n => n.id === 'Bat')?.data.label).toBe('Batteries'); - expect(result.nodes.find(n => n.id === 'ShutOff')?.data.label).toBe('Shut Off'); - expect(result.nodes.find(n => n.id === 'BusPos')?.data.label).toBe('Bus Bar +'); - expect(result.nodes.find(n => n.id === 'USB')?.data.label).toBe('USB-C'); - - // Check that Old is a decision node (diamond shape) - expect(result.nodes.find(n => n.id === 'Old')?.type).toBe('decision'); - - // Should have many edges (20 in the original) - expect(result.edges.length).toBeGreaterThanOrEqual(18); - - // Check edge labels - const batToShutoff = result.edges.find(e => e.source === 'Bat' && e.target === 'ShutOff'); - expect(batToShutoff?.label).toBe('150a 50mm'); - - // Check linkStyle applied colors - expect(result.edges[0].style).toEqual( - expect.objectContaining({ stroke: 'red' }) - ); - }); - - it('should handle dotted arrow -.-> ', () => { - const input = ` + const result = parseMermaid(input); + + // Should have no errors + expect(result.error).toBeUndefined(); + + // Direction should be TB + expect(result.direction).toBe('TB'); + + // Should find all unique nodes + const nodeIds = result.nodes.map((n) => n.id); + expect(nodeIds).toContain('Bat'); + expect(nodeIds).toContain('ShutOff'); + expect(nodeIds).toContain('Shunt'); + expect(nodeIds).toContain('BusPos'); + expect(nodeIds).toContain('BusNeg'); + expect(nodeIds).toContain('Fuse'); + expect(nodeIds).toContain('Old'); + expect(nodeIds).toContain('USB'); + expect(nodeIds).toContain('Switch'); + expect(nodeIds).toContain('Wifi'); + expect(nodeIds).toContain('Cig1'); + expect(nodeIds).toContain('Cig2'); + expect(nodeIds).toContain('Solar'); + expect(nodeIds).toContain('SolarCont'); + + // Check labels + expect(result.nodes.find((n) => n.id === 'Bat')?.data.label).toBe('Batteries'); + expect(result.nodes.find((n) => n.id === 'ShutOff')?.data.label).toBe('Shut Off'); + expect(result.nodes.find((n) => n.id === 'BusPos')?.data.label).toBe('Bus Bar +'); + expect(result.nodes.find((n) => n.id === 'USB')?.data.label).toBe('USB-C'); + + // Check that Old is a decision node (diamond shape) + expect(result.nodes.find((n) => n.id === 'Old')?.type).toBe('decision'); + + // Should have many edges (20 in the original) + expect(result.edges.length).toBeGreaterThanOrEqual(18); + + // Check edge labels + const batToShutoff = result.edges.find((e) => e.source === 'Bat' && e.target === 'ShutOff'); + expect(batToShutoff?.label).toBe('150a 50mm'); + + // Check linkStyle applied colors + expect(result.edges[0].style).toEqual(expect.objectContaining({ stroke: 'red' })); + }); + + it('should handle dotted arrow -.-> ', () => { + const input = ` flowchart TD A -.-> B `; - const result = parseMermaid(input); - expect(result.edges).toHaveLength(1); - expect(result.edges[0].style).toEqual( - expect.objectContaining({ strokeDasharray: '5 3' }) - ); - }); - - it('should handle thick arrow ==>', () => { - const input = ` + const result = parseMermaid(input); + expect(result.edges).toHaveLength(1); + expect(result.edges[0].style).toEqual(expect.objectContaining({ strokeDasharray: '5 3' })); + }); + + it('should handle thick arrow ==>', () => { + const input = ` flowchart TD A ==> B `; - const result = parseMermaid(input); - expect(result.edges).toHaveLength(1); - expect(result.edges[0].style).toEqual( - expect.objectContaining({ strokeWidth: 4 }) - ); - }); - - it('should handle thick arrow ==> with inline label', () => { - const input = ` + const result = parseMermaid(input); + expect(result.edges).toHaveLength(1); + expect(result.edges[0].style).toEqual(expect.objectContaining({ strokeWidth: 4 })); + }); + + it('should handle thick arrow ==> with inline label', () => { + const input = ` flowchart TD A == Yes ==> B `; - const result = parseMermaid(input); - expect(result.edges).toHaveLength(1); - expect(result.edges[0].style).toEqual( - expect.objectContaining({ strokeWidth: 4 }) - ); - expect(result.edges[0].label).toBe('Yes'); - }); - - it('should handle reverse arrow <-- with markerStart', () => { - const input = ` + const result = parseMermaid(input); + expect(result.edges).toHaveLength(1); + expect(result.edges[0].style).toEqual(expect.objectContaining({ strokeWidth: 4 })); + expect(result.edges[0].label).toBe('Yes'); + }); + + it('should handle reverse arrow <-- with markerStart', () => { + const input = ` flowchart TD A <-- B `; - const result = parseMermaid(input); - expect(result.edges).toHaveLength(1); - expect(result.edges[0].markerStart).toBeDefined(); - expect(result.edges[0].markerEnd).toBeUndefined(); - }); - - it('should handle bidirectional arrow <--> with both markers', () => { - const input = ` + const result = parseMermaid(input); + expect(result.edges).toHaveLength(1); + expect(result.edges[0].markerStart).toBeDefined(); + expect(result.edges[0].markerEnd).toBeUndefined(); + }); + + it('should handle bidirectional arrow <--> with both markers', () => { + const input = ` flowchart TD A <--> B `; - const result = parseMermaid(input); - expect(result.edges).toHaveLength(1); - expect(result.edges[0].markerStart).toBeDefined(); - expect(result.edges[0].markerEnd).toBeDefined(); - }); - - it('should handle multiline quoted strings', () => { - const input = ` + const result = parseMermaid(input); + expect(result.edges).toHaveLength(1); + expect(result.edges[0].markerStart).toBeDefined(); + expect(result.edges[0].markerEnd).toBeDefined(); + }); + + it('should handle multiline quoted strings', () => { + const input = ` graph TD A["Line 1 Line 2"] `; - const result = parseMermaid(input); - expect(result.nodes).toHaveLength(1); - expect(result.nodes[0].data.label).toBe('Line 1\nLine 2'); - }); + const result = parseMermaid(input); + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0].data.label).toBe('Line 1\nLine 2'); + }); - it('should handle the Service Learning example', () => { - const input = `graph TB + it('should handle the Service Learning example', () => { + const input = `graph TB A("Do you think online service learning is right for you?") B("Do you have time to design @@ -375,26 +395,26 @@ D--No-->E E--Yes-->F E--No-->C`; - const result = parseMermaid(input); + const result = parseMermaid(input); - // Should parse 6 nodes - expect(result.nodes).toHaveLength(6); - // ID A should have multiline label - const nodeA = result.nodes.find(n => n.id === 'A'); - expect(nodeA).toBeDefined(); - expect(nodeA?.data.label).toContain('online service\nlearning'); + // Should parse 6 nodes + expect(result.nodes).toHaveLength(6); + // ID A should have multiline label + const nodeA = result.nodes.find((n) => n.id === 'A'); + expect(nodeA).toBeDefined(); + expect(nodeA?.data.label).toContain('online service\nlearning'); - // Should parse 8 edges - expect(result.edges).toHaveLength(8); + // Should parse 8 edges + expect(result.edges).toHaveLength(8); - // Check specific edges - const startYes = result.edges.find(e => e.source === 'A' && e.target === 'B'); - expect(startYes).toBeDefined(); - expect(startYes?.label).toBe('Yes'); - expect(startYes?.style?.strokeWidth).toBe(4); // ==> is thick + // Check specific edges + const startYes = result.edges.find((e) => e.source === 'A' && e.target === 'B'); + expect(startYes).toBeDefined(); + expect(startYes?.label).toBe('Yes'); + expect(startYes?.style?.strokeWidth).toBe(4); // ==> is thick - const startNo = result.edges.find(e => e.source === 'A' && e.target === 'C'); - expect(startNo).toBeDefined(); - expect(startNo?.label).toBe('No'); - }); + const startNo = result.edges.find((e) => e.source === 'A' && e.target === 'C'); + expect(startNo).toBeDefined(); + expect(startNo?.label).toBe('No'); + }); }); diff --git a/src/services/shapeLibrary/providerCatalog.ts b/src/services/shapeLibrary/providerCatalog.ts index 3816bc5d..f95da862 100644 --- a/src/services/shapeLibrary/providerCatalog.ts +++ b/src/services/shapeLibrary/providerCatalog.ts @@ -27,6 +27,7 @@ const shapePreviewPromiseCache = new Map> = { aws: 'aws-official-starter-v1', azure: 'azure-official-icons-v20', + gcp: 'gcp-official-icons-v1', cncf: 'cncf-artwork-icons-v1', developer: 'developer-icons-v1', }; @@ -55,6 +56,26 @@ function getPackIdForProvider(provider: string): string { return KNOWN_PROVIDER_PACK_IDS[provider] ?? `${provider}-processed-pack-v1`; } +function getProviderColor(provider: string): string { + if (provider === 'aws') { + return 'amber'; + } + + if (provider === 'azure') { + return 'blue'; + } + + if (provider === 'gcp') { + return 'emerald'; + } + + if (provider === 'cncf') { + return 'cyan'; + } + + return 'slate'; +} + function parseSvgSource(modulePath: string, previewLoader: () => Promise): SvgSource | null { const normalized = modulePath.replaceAll('\\', '/'); const match = normalized.match(/assets\/third-party-icons\/([^/]+)\/processed\/(.+)\.svg$/); @@ -93,15 +114,7 @@ function createProviderItem( label: source.label, description: `${provider.toUpperCase()} ${source.category}`, icon: 'Box', - color: provider === 'aws' - ? 'amber' - : provider === 'azure' - ? 'blue' - : provider === 'gcp' - ? 'emerald' - : provider === 'cncf' - ? 'cyan' - : 'slate', + color: getProviderColor(provider), nodeType: 'custom', assetPresentation: 'icon', providerShapeCategory: source.category, diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index a440610e..8a4c8a5a 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.test.tsx","./src/app.tsx","./src/constants.test.ts","./src/constants.ts","./src/index.tsx","./src/store.test.ts","./src/store.ts","./src/theme.test.ts","./src/theme.ts","./src/app/routestate.test.ts","./src/app/routestate.ts","./src/components/annotationnode.tsx","./src/components/architecturerulespanel.tsx","./src/components/cinematicexportoverlay.tsx","./src/components/commandbar.test.tsx","./src/components/commandbar.tsx","./src/components/connectmenu.test.tsx","./src/components/connectmenu.tsx","./src/components/connectmenusections.tsx","./src/components/contextmenu.test.tsx","./src/components/contextmenu.tsx","./src/components/customconnectionline.test.tsx","./src/components/customconnectionline.tsx","./src/components/customedge.tsx","./src/components/customnode.handleinteraction.test.tsx","./src/components/customnode.tsx","./src/components/customnodecontent.tsx","./src/components/designsystem.integration.test.tsx","./src/components/diagramviewer.tsx","./src/components/errorboundary.tsx","./src/components/exportmenu.tsx","./src/components/exportmenupanel.test.tsx","./src/components/exportmenupanel.tsx","./src/components/flowcanvas.tsx","./src/components/floweditor.tsx","./src/components/floweditoremptystate.tsx","./src/components/floweditorlayoutoverlay.tsx","./src/components/floweditorpanels.test.tsx","./src/components/floweditorpanels.tsx","./src/components/flowtabs.test.tsx","./src/components/flowtabs.tsx","./src/components/groupnode.tsx","./src/components/homepage.integration.test.tsx","./src/components/homepage.tsx","./src/components/iconassetnodebody.tsx","./src/components/iconmap.test.tsx","./src/components/iconmap.ts","./src/components/imagenode.tsx","./src/components/importrecoverydialog.test.tsx","./src/components/importrecoverydialog.tsx","./src/components/inlinetexteditsurface.test.tsx","./src/components/inlinetexteditsurface.tsx","./src/components/keyboardshortcutsmodal.tsx","./src/components/languageselector.tsx","./src/components/markdownrenderer.tsx","./src/components/memoizedmarkdown.tsx","./src/components/navigationcontrols.tsx","./src/components/nodebadges.tsx","./src/components/nodechrome.tsx","./src/components/nodequickcreatebuttons.tsx","./src/components/nodeshapesvg.tsx","./src/components/nodetransformcontrols.tsx","./src/components/playbackcontrols.tsx","./src/components/propertiespanel.tsx","./src/components/rightrail.tsx","./src/components/sectionnode.tsx","./src/components/shareembedmodal.tsx","./src/components/sharemodal.test.tsx","./src/components/sharemodal.tsx","./src/components/sidebarshell.tsx","./src/components/snapshotspanel.test.tsx","./src/components/snapshotspanel.tsx","./src/components/studioaipanel.test.tsx","./src/components/studioaipanel.tsx","./src/components/studioaipanelsections.tsx","./src/components/studiocodepanel.test.tsx","./src/components/studiocodepanel.tsx","./src/components/studiopanel.test.tsx","./src/components/studiopanel.tsx","./src/components/studioplaybackpanel.test.tsx","./src/components/studioplaybackpanel.tsx","./src/components/swimlanenode.tsx","./src/components/textnode.tsx","./src/components/themetoggle.tsx","./src/components/toolbar.tsx","./src/components/tooltip.tsx","./src/components/topnav.tsx","./src/components/welcomemodal.tsx","./src/components/annotationtheme.ts","./src/components/container-nodes.handleinteraction.test.tsx","./src/components/editorsurfacetiers.ts","./src/components/handleinteraction.test.ts","./src/components/handleinteraction.ts","./src/components/handleinteractionusage.test.ts","./src/components/lightweight-nodes.handleinteraction.test.tsx","./src/components/markdownsyntax.ts","./src/components/nodehelpers.test.ts","./src/components/nodehelpers.ts","./src/components/sharemodalcontent.ts","./src/components/studioaicopy.ts","./src/components/studioaipanelexamples.ts","./src/components/transformdiagnostics.test.ts","./src/components/transformdiagnostics.ts","./src/components/useactivenodeselection.ts","./src/components/useexportmenu.test.tsx","./src/components/useexportmenu.ts","./src/components/settingsmodal/aisettings.tsx","./src/components/settingsmodal/canvassettings.tsx","./src/components/settingsmodal/generalsettings.test.tsx","./src/components/settingsmodal/generalsettings.tsx","./src/components/settingsmodal/settingsmodal.test.tsx","./src/components/settingsmodal/settingsmodal.tsx","./src/components/settingsmodal/shortcutssettings.tsx","./src/components/settingsmodal/ai/advancedendpointsection.tsx","./src/components/settingsmodal/ai/customheaderseditor.tsx","./src/components/settingsmodal/ai/privacysection.tsx","./src/components/settingsmodal/ai/providericon.tsx","./src/components/settingsmodal/ai/providersection.tsx","./src/components/settingsmodal/ai/step.tsx","./src/components/add-items/additemregistry.tsx","./src/components/app/docssiteredirect.test.tsx","./src/components/app/docssiteredirect.tsx","./src/components/app/mobileworkspacegate.test.tsx","./src/components/app/mobileworkspacegate.tsx","./src/components/app/routeloadingfallback.test.tsx","./src/components/app/routeloadingfallback.tsx","./src/components/app/mobileworkspacegatecopy.ts","./src/components/app/routeloadingcopy.ts","./src/components/architecture-lint/lintrulespanel.tsx","./src/components/architecture-lint/ruleform.tsx","./src/components/architecture-lint/visualeditor.tsx","./src/components/command-bar/assetsview.test.tsx","./src/components/command-bar/assetsview.tsx","./src/components/command-bar/codebaseimportpanels.tsx","./src/components/command-bar/codebaseimportsection.tsx","./src/components/command-bar/designsystemeditor.tsx","./src/components/command-bar/designsystemview.tsx","./src/components/command-bar/diagramminipreview.tsx","./src/components/command-bar/figmaimportpanel.tsx","./src/components/command-bar/importsurfaceprimitives.tsx","./src/components/command-bar/importview.tsx","./src/components/command-bar/importviewpanels.tsx","./src/components/command-bar/layersview.tsx","./src/components/command-bar/layoutview.tsx","./src/components/command-bar/pagesview.tsx","./src/components/command-bar/rootview.test.tsx","./src/components/command-bar/rootview.tsx","./src/components/command-bar/searchview.tsx","./src/components/command-bar/templatesview.test.tsx","./src/components/command-bar/templatesview.tsx","./src/components/command-bar/viewheader.tsx","./src/components/command-bar/visualsview.tsx","./src/components/command-bar/applycodechanges.ts","./src/components/command-bar/assetsviewconstants.ts","./src/components/command-bar/importdetection.test.ts","./src/components/command-bar/importdetection.ts","./src/components/command-bar/importnativeparsers.ts","./src/components/command-bar/importviewmodel.test.ts","./src/components/command-bar/importviewmodel.ts","./src/components/command-bar/mermaidimportparser.ts","./src/components/command-bar/searchquery.test.ts","./src/components/command-bar/searchquery.ts","./src/components/command-bar/types.ts","./src/components/command-bar/useaiviewstate.test.tsx","./src/components/command-bar/useaiviewstate.ts","./src/components/command-bar/usecloudassetcatalog.ts","./src/components/command-bar/usecommandbarcommands.test.tsx","./src/components/command-bar/usecommandbarcommands.tsx","./src/components/custom-edge/customedgewrapper.tsx","./src/components/custom-edge/edgemarkerdefs.tsx","./src/components/custom-edge/sequencemessageedge.test.tsx","./src/components/custom-edge/sequencemessageedge.tsx","./src/components/custom-edge/animatededgepresentation.test.ts","./src/components/custom-edge/animatededgepresentation.ts","./src/components/custom-edge/classrelationsemantics.test.ts","./src/components/custom-edge/classrelationsemantics.ts","./src/components/custom-edge/edgerendermode.ts","./src/components/custom-edge/pathutils.test.ts","./src/components/custom-edge/pathutils.ts","./src/components/custom-edge/pathutilsgeometry.ts","./src/components/custom-edge/pathutilssiblingrouting.ts","./src/components/custom-edge/pathutilstypes.ts","./src/components/custom-edge/relationroutingsemantics.test.ts","./src/components/custom-edge/relationroutingsemantics.ts","./src/components/custom-edge/standardedgemarkers.test.ts","./src/components/custom-edge/standardedgemarkers.ts","./src/components/custom-nodes/architecturenode.handleinteraction.test.tsx","./src/components/custom-nodes/architecturenode.tsx","./src/components/custom-nodes/browsernode.tsx","./src/components/custom-nodes/classentitynode.handleinteraction.test.tsx","./src/components/custom-nodes/classnode.tsx","./src/components/custom-nodes/entitynode.tsx","./src/components/custom-nodes/journeynode.tsx","./src/components/custom-nodes/mindmapnode.tsx","./src/components/custom-nodes/mobilenode.tsx","./src/components/custom-nodes/sequencenotenode.tsx","./src/components/custom-nodes/sequenceparticipantnode.tsx","./src/components/custom-nodes/structurednodehandles.tsx","./src/components/custom-nodes/visualnodes.handleinteraction.test.tsx","./src/components/custom-nodes/browservariantrenderer.tsx","./src/components/custom-nodes/mobilevariantrenderer.tsx","./src/components/custom-nodes/structuredlistnavigation.test.ts","./src/components/custom-nodes/structuredlistnavigation.ts","./src/components/custom-nodes/variantconstants.ts","./src/components/diagram-diff/diffmodebanner.tsx","./src/components/flow-canvas/flowcanvasalignmentguidesoverlay.tsx","./src/components/flow-canvas/flowcanvasoverlays.tsx","./src/components/flow-canvas/flowcanvassurface.test.tsx","./src/components/flow-canvas/flowcanvassurface.tsx","./src/components/flow-canvas/streamingoverlay.tsx","./src/components/flow-canvas/alignmentguides.test.ts","./src/components/flow-canvas/alignmentguides.ts","./src/components/flow-canvas/flowcanvasreactflowcontracts.ts","./src/components/flow-canvas/flowcanvastypes.test.ts","./src/components/flow-canvas/flowcanvastypes.tsx","./src/components/flow-canvas/largegraphsafetymode.test.ts","./src/components/flow-canvas/largegraphsafetymode.ts","./src/components/flow-canvas/nodechromecoverage.test.ts","./src/components/flow-canvas/pastehelpers.test.ts","./src/components/flow-canvas/pastehelpers.ts","./src/components/flow-canvas/useflowcanvasalignmentguides.ts","./src/components/flow-canvas/useflowcanvasconnectionstate.ts","./src/components/flow-canvas/useflowcanvascontextactions.ts","./src/components/flow-canvas/useflowcanvasdragdrop.ts","./src/components/flow-canvas/useflowcanvasinteractionlod.ts","./src/components/flow-canvas/useflowcanvasmenus.ts","./src/components/flow-canvas/useflowcanvasmenusandactions.test.tsx","./src/components/flow-canvas/useflowcanvasmenusandactions.ts","./src/components/flow-canvas/useflowcanvasnodedraghandlers.ts","./src/components/flow-canvas/useflowcanvaspaste.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.test.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.ts","./src/components/flow-canvas/useflowcanvasselectiontools.test.tsx","./src/components/flow-canvas/useflowcanvasselectiontools.ts","./src/components/flow-canvas/useflowcanvasviewstate.test.ts","./src/components/flow-canvas/useflowcanvasviewstate.ts","./src/components/flow-canvas/useflowcanvaszoomlod.ts","./src/components/flow-editor/collaborationpresenceoverlay.test.tsx","./src/components/flow-editor/collaborationpresenceoverlay.tsx","./src/components/flow-editor/floweditorchrome.tsx","./src/components/flow-editor/buildfloweditorcontrollerparams.ts","./src/components/flow-editor/buildfloweditorscreencontrollerparams.ts","./src/components/flow-editor/chromeproptypes.ts","./src/components/flow-editor/floweditorchromeprops.ts","./src/components/flow-editor/infradslapply.test.ts","./src/components/flow-editor/infradslapply.ts","./src/components/flow-editor/panelprops.test.ts","./src/components/flow-editor/panelprops.ts","./src/components/flow-editor/shouldexitstudioonselection.test.ts","./src/components/flow-editor/shouldexitstudioonselection.ts","./src/components/flow-editor/usecollaborationnodepositions.ts","./src/components/flow-editor/usefloweditorcontroller.ts","./src/components/flow-editor/usefloweditorinteractionbindings.ts","./src/components/flow-editor/usefloweditorpanelactions.ts","./src/components/flow-editor/usefloweditorpanelprops.ts","./src/components/flow-editor/usefloweditorruntime.ts","./src/components/flow-editor/usefloweditorscreenbehavior.ts","./src/components/flow-editor/usefloweditorscreenmodel.ts","./src/components/flow-editor/usefloweditorscreenstate.ts","./src/components/flow-editor/usefloweditorshellcontroller.test.tsx","./src/components/flow-editor/usefloweditorshellcontroller.ts","./src/components/flow-editor/usefloweditorstudiocontroller.test.tsx","./src/components/flow-editor/usefloweditorstudiocontroller.ts","./src/components/flow-editor/useinfradslapply.ts","./src/components/home/githubcard.tsx","./src/components/home/homedashboard.tsx","./src/components/home/homeflowdialogs.tsx","./src/components/home/homesettingsview.tsx","./src/components/home/homesidebar.tsx","./src/components/home/hometemplatesview.tsx","./src/components/home/sidebarfooter.tsx","./src/components/home/welcomemodalstate.ts","./src/components/icons/assetsicon.tsx","./src/components/icons/openflowlogo.tsx","./src/components/infra-sync/infrasyncpanel.test.tsx","./src/components/infra-sync/infrasyncpanel.tsx","./src/components/journey/journeyscorecontrol.test.tsx","./src/components/journey/journeyscorecontrol.tsx","./src/components/properties/bulknodeproperties.test.tsx","./src/components/properties/bulknodeproperties.tsx","./src/components/properties/bulknodepropertiesfamilysections.tsx","./src/components/properties/bulknodepropertiessections.tsx","./src/components/properties/bulknodepropertiesutilitysections.tsx","./src/components/properties/colorpicker.test.tsx","./src/components/properties/colorpicker.tsx","./src/components/properties/customcolorpopover.tsx","./src/components/properties/diagramnodepropertiesrouter.test.ts","./src/components/properties/diagramnodepropertiesrouter.tsx","./src/components/properties/edgeproperties.tsx","./src/components/properties/iconpicker.tsx","./src/components/properties/icontilepickerprimitives.tsx","./src/components/properties/imageupload.tsx","./src/components/properties/inspectorprimitives.tsx","./src/components/properties/nodeactionbuttons.tsx","./src/components/properties/nodecontentsection.tsx","./src/components/properties/nodeimagesettingssection.tsx","./src/components/properties/nodeproperties.test.tsx","./src/components/properties/nodeproperties.tsx","./src/components/properties/nodewireframevariantsection.tsx","./src/components/properties/propertysliderrow.tsx","./src/components/properties/segmentedchoice.tsx","./src/components/properties/shapeselector.tsx","./src/components/properties/swatchpicker.tsx","./src/components/properties/bulknodepropertiesmodel.test.ts","./src/components/properties/bulknodepropertiesmodel.ts","./src/components/properties/colorpickerutils.ts","./src/components/properties/propertyinputbehavior.test.ts","./src/components/properties/propertyinputbehavior.ts","./src/components/properties/sectionactionbuilder.ts","./src/components/properties/shared.ts","./src/components/properties/wireframevariants.ts","./src/components/properties/edge/architectureedgesemanticssection.tsx","./src/components/properties/edge/edgecolorsection.test.tsx","./src/components/properties/edge/edgecolorsection.tsx","./src/components/properties/edge/edgeconditionsection.tsx","./src/components/properties/edge/edgelabelsection.tsx","./src/components/properties/edge/edgerelationsection.test.tsx","./src/components/properties/edge/edgerelationsection.tsx","./src/components/properties/edge/edgeroutesection.test.tsx","./src/components/properties/edge/edgeroutesection.tsx","./src/components/properties/edge/edgestylesection.tsx","./src/components/properties/edge/sequencemessagesection.tsx","./src/components/properties/edge/architecturesemantics.test.ts","./src/components/properties/edge/architecturesemantics.ts","./src/components/properties/edge/edgecolorutils.ts","./src/components/properties/edge/edgelabelmodel.test.ts","./src/components/properties/edge/edgelabelmodel.ts","./src/components/properties/edge/errelationoptions.ts","./src/components/properties/families/architecturenodeproperties.test.tsx","./src/components/properties/families/architecturenodeproperties.tsx","./src/components/properties/families/architecturenodesection.tsx","./src/components/properties/families/classdiagramnodeproperties.tsx","./src/components/properties/families/classmemberlisteditor.tsx","./src/components/properties/families/classnodesection.tsx","./src/components/properties/families/erdiagramnodeproperties.tsx","./src/components/properties/families/entityfieldlisteditor.tsx","./src/components/properties/families/entitynodesection.tsx","./src/components/properties/families/journeynodeproperties.tsx","./src/components/properties/families/journeynodesection.tsx","./src/components/properties/families/mindmapnodeproperties.test.tsx","./src/components/properties/families/mindmapnodeproperties.tsx","./src/components/properties/families/sequencenodeproperties.tsx","./src/components/properties/families/sequencenodesection.tsx","./src/components/properties/families/specializednodecolorsections.test.tsx","./src/components/properties/families/structuredtextlisteditor.tsx","./src/components/properties/families/architectureoptions.ts","./src/components/studio-code-panel/usestudiocodepanelcontroller.test.tsx","./src/components/studio-code-panel/usestudiocodepanelcontroller.ts","./src/components/templates/templatepresentation.tsx","./src/components/toolbar/toolbaraddmenu.test.tsx","./src/components/toolbar/toolbaraddmenu.tsx","./src/components/toolbar/toolbaraddmenupanel.tsx","./src/components/toolbar/toolbarhistorycontrols.tsx","./src/components/toolbar/toolbarmodecontrols.tsx","./src/components/toolbar/toolbarbuttonstyles.ts","./src/components/top-nav/savestatusindicator.tsx","./src/components/top-nav/topnavactions.tsx","./src/components/top-nav/topnavbrand.tsx","./src/components/top-nav/topnavmenu.test.tsx","./src/components/top-nav/topnavmenu.tsx","./src/components/top-nav/topnavmenupanel.tsx","./src/components/top-nav/usetopnavstate.ts","./src/components/ui/button.tsx","./src/components/ui/collapsiblesection.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/searchfield.tsx","./src/components/ui/segmentedtabs.tsx","./src/components/ui/select.test.tsx","./src/components/ui/select.tsx","./src/components/ui/sidebaritem.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toastcontext.tsx","./src/components/ui/editorfieldstyles.ts","./src/config/aiproviders.test.ts","./src/config/aiproviders.ts","./src/config/rolloutflags.ts","./src/context/architecturelintcontext.tsx","./src/context/cinematicexportcontext.tsx","./src/context/diagramdiffcontext.tsx","./src/context/themecontext.tsx","./src/diagram-types/bootstrap.test.ts","./src/diagram-types/bootstrap.ts","./src/diagram-types/builtinplugins.ts","./src/diagram-types/builtinpropertypanels.ts","./src/diagram-types/registerbuiltinplugins.test.ts","./src/diagram-types/registerbuiltinplugins.ts","./src/diagram-types/registerbuiltinpropertypanels.test.ts","./src/diagram-types/registerbuiltinpropertypanels.ts","./src/diagram-types/architecture/fuzzcorpus.test.ts","./src/diagram-types/architecture/plugin.test.ts","./src/diagram-types/architecture/plugin.ts","./src/diagram-types/classdiagram/fuzzcorpus.test.ts","./src/diagram-types/classdiagram/plugin.test.ts","./src/diagram-types/classdiagram/plugin.ts","./src/diagram-types/core/contracts.ts","./src/diagram-types/core/index.ts","./src/diagram-types/core/propertypanels.test.ts","./src/diagram-types/core/propertypanels.ts","./src/diagram-types/core/registry.ts","./src/diagram-types/erdiagram/fuzzcorpus.test.ts","./src/diagram-types/erdiagram/plugin.test.ts","./src/diagram-types/erdiagram/plugin.ts","./src/diagram-types/flowchart/plugin.ts","./src/diagram-types/journey/fuzzcorpus.test.ts","./src/diagram-types/journey/plugin.test.ts","./src/diagram-types/journey/plugin.ts","./src/diagram-types/mindmap/fuzzcorpus.test.ts","./src/diagram-types/mindmap/plugin.test.ts","./src/diagram-types/mindmap/plugin.ts","./src/diagram-types/sequence/plugin.test.ts","./src/diagram-types/sequence/plugin.ts","./src/diagram-types/statediagram/plugin.test.ts","./src/diagram-types/statediagram/plugin.ts","./src/docs/docsroutes.ts","./src/docs/publicdocscatalog.js","./src/hooks/edgeconnectinteractions.test.ts","./src/hooks/edgeconnectinteractions.ts","./src/hooks/flowexportviewport.test.ts","./src/hooks/flowexportviewport.ts","./src/hooks/mindmaptopicactionrequest.ts","./src/hooks/nodelabeleditrequest.test.ts","./src/hooks/nodelabeleditrequest.ts","./src/hooks/nodequickcreaterequest.test.ts","./src/hooks/nodequickcreaterequest.ts","./src/hooks/snapshotpolicy.test.ts","./src/hooks/snapshotpolicy.ts","./src/hooks/useaigeneration.ts","./src/hooks/useanalyticspreference.ts","./src/hooks/useanimatededgeperformancewarning.test.ts","./src/hooks/useanimatededgeperformancewarning.ts","./src/hooks/useassetcatalog.ts","./src/hooks/usecinematicexport.ts","./src/hooks/useclipboardoperations.ts","./src/hooks/usedesignsystem.ts","./src/hooks/useedgeinteractions.ts","./src/hooks/useedgeoperations.ts","./src/hooks/usefloweditoractions.test.ts","./src/hooks/usefloweditoractions.ts","./src/hooks/usefloweditorcallbacks.ts","./src/hooks/usefloweditorcollaboration.test.ts","./src/hooks/usefloweditorcollaboration.ts","./src/hooks/usefloweditoruistate.ts","./src/hooks/useflowexport.ts","./src/hooks/useflowhistory.test.ts","./src/hooks/useflowhistory.ts","./src/hooks/useflowoperations.ts","./src/hooks/usegithubstars.ts","./src/hooks/useinfrasync.ts","./src/hooks/useinlinenodetextedit.test.ts","./src/hooks/useinlinenodetextedit.ts","./src/hooks/usekeyboardshortcuts.test.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/uselayoutoperations.ts","./src/hooks/usemarkdowneditor.ts","./src/hooks/usemenukeyboardnavigation.ts","./src/hooks/usemodifierkeys.test.ts","./src/hooks/usemodifierkeys.ts","./src/hooks/usenodeoperations.ts","./src/hooks/useplayback.ts","./src/hooks/useprovidershapepreview.ts","./src/hooks/userecentimports.ts","./src/hooks/useshiftheld.ts","./src/hooks/usesnapshots.ts","./src/hooks/usestaticexport.ts","./src/hooks/usestoragepressureguard.ts","./src/hooks/usestructuredlisteditor.ts","./src/hooks/usestyleclipboard.test.ts","./src/hooks/usestyleclipboard.ts","./src/hooks/ai-generation/chathistorystorage.test.ts","./src/hooks/ai-generation/chathistorystorage.ts","./src/hooks/ai-generation/codetoarchitecture.test.ts","./src/hooks/ai-generation/codetoarchitecture.ts","./src/hooks/ai-generation/codebaseanalyzer.test.ts","./src/hooks/ai-generation/codebaseanalyzer.ts","./src/hooks/ai-generation/codebaseparser.test.ts","./src/hooks/ai-generation/codebaseparser.ts","./src/hooks/ai-generation/codebasetonativediagram.test.ts","./src/hooks/ai-generation/codebasetonativediagram.ts","./src/hooks/ai-generation/graphcomposer.test.ts","./src/hooks/ai-generation/graphcomposer.ts","./src/hooks/ai-generation/nodeactionprompts.test.ts","./src/hooks/ai-generation/nodeactionprompts.ts","./src/hooks/ai-generation/openapiparser.test.ts","./src/hooks/ai-generation/openapiparser.ts","./src/hooks/ai-generation/openapitosequence.ts","./src/hooks/ai-generation/positionpreservingapply.test.ts","./src/hooks/ai-generation/positionpreservingapply.ts","./src/hooks/ai-generation/readiness.test.ts","./src/hooks/ai-generation/readiness.ts","./src/hooks/ai-generation/requestlifecycle.test.ts","./src/hooks/ai-generation/requestlifecycle.ts","./src/hooks/ai-generation/sqlparser.test.ts","./src/hooks/ai-generation/sqlparser.ts","./src/hooks/ai-generation/sqltoerd.ts","./src/hooks/ai-generation/streamingparser.ts","./src/hooks/ai-generation/streamingstore.ts","./src/hooks/ai-generation/terraformtocloud.ts","./src/hooks/edge-operations/utils.test.ts","./src/hooks/edge-operations/utils.ts","./src/hooks/flow-editor-actions/exporthandlers.test.ts","./src/hooks/flow-editor-actions/exporthandlers.ts","./src/hooks/flow-editor-actions/helpers.ts","./src/hooks/flow-editor-actions/layouthandlers.test.ts","./src/hooks/flow-editor-actions/layouthandlers.ts","./src/hooks/flow-export/diagramdocumenttransfer.test.ts","./src/hooks/flow-export/diagramdocumenttransfer.ts","./src/hooks/flow-export/exportcapture.test.ts","./src/hooks/flow-export/exportcapture.ts","./src/hooks/flow-operations/useflowcoreactions.ts","./src/hooks/node-operations/createconnectedsibling.ts","./src/hooks/node-operations/dragstopreconcilepolicy.test.ts","./src/hooks/node-operations/dragstopreconcilepolicy.ts","./src/hooks/node-operations/nodefactories.ts","./src/hooks/node-operations/routingduringdrag.test.ts","./src/hooks/node-operations/routingduringdrag.ts","./src/hooks/node-operations/sectionbounds.ts","./src/hooks/node-operations/sectionhittesting.ts","./src/hooks/node-operations/sectionoperations.ts","./src/hooks/node-operations/usearchitecturenodeoperations.test.ts","./src/hooks/node-operations/usearchitecturenodeoperations.ts","./src/hooks/node-operations/usemindmapnodeoperations.test.ts","./src/hooks/node-operations/usemindmapnodeoperations.ts","./src/hooks/node-operations/usenodedragoperations.test.ts","./src/hooks/node-operations/usenodedragoperations.ts","./src/hooks/node-operations/usenodeoperationadders.ts","./src/hooks/node-operations/utils.test.ts","./src/hooks/node-operations/utils.ts","./src/i18n/config.test.ts","./src/i18n/config.ts","./src/i18n/strictmodelocalecoverage.test.ts","./src/i18n/usedlocalecoverage.test.ts","./src/lib/architecturetemplatedata.ts","./src/lib/architecturetemplates.test.ts","./src/lib/architecturetemplates.ts","./src/lib/brand.ts","./src/lib/classmembers.ts","./src/lib/colorutils.ts","./src/lib/connectcreationpolicy.test.ts","./src/lib/connectcreationpolicy.ts","./src/lib/date.ts","./src/lib/designtokens.ts","./src/lib/devperformance.ts","./src/lib/entityfields.ts","./src/lib/ertoclassconversion.test.ts","./src/lib/ertoclassconversion.ts","./src/lib/exportfilename.test.ts","./src/lib/exportfilename.ts","./src/lib/flowminddslparserv2.test.ts","./src/lib/flowminddslparserv2.ts","./src/lib/fuzzymatch.ts","./src/lib/genericshapepolicy.ts","./src/lib/id.ts","./src/lib/index.ts","./src/lib/legacybranding.ts","./src/lib/logger.ts","./src/lib/mermaidparser.ts","./src/lib/mermaidparserhelpers.ts","./src/lib/mermaidparsermodel.ts","./src/lib/mindmaplayout.test.ts","./src/lib/mindmaplayout.ts","./src/lib/mindmaplayoutengine.ts","./src/lib/mindmaptree.ts","./src/lib/nodebulkediting.test.ts","./src/lib/nodebulkediting.ts","./src/lib/nodehandles.ts","./src/lib/nodeparent.ts","./src/lib/nodestyledata.test.ts","./src/lib/nodestyledata.ts","./src/lib/openflowdslparser.ts","./src/lib/openflowdslparserv2.ts","./src/lib/reactflowcompat.ts","./src/lib/reconnectedge.test.ts","./src/lib/reconnectedge.ts","./src/lib/relationsemantics.test.ts","./src/lib/relationsemantics.ts","./src/lib/releasestaleelkroutes.test.ts","./src/lib/releasestaleelkroutes.ts","./src/lib/result.ts","./src/lib/storagepressure.test.ts","./src/lib/storagepressure.ts","./src/lib/types.ts","./src/lib/xsssafety.test.tsx","./src/services/aligndistribute.ts","./src/services/aiservice.test.ts","./src/services/aiservice.ts","./src/services/aiserviceschemas.ts","./src/services/animatedexport.test.ts","./src/services/animatedexport.ts","./src/services/architectureroundtrip.test.ts","./src/services/assetcatalog.test.ts","./src/services/assetcatalog.ts","./src/services/assetpresentation.ts","./src/services/canonicalserialization.test.ts","./src/services/canonicalserialization.ts","./src/services/composediagramfordisplay.test.ts","./src/services/composediagramfordisplay.ts","./src/services/diagramdocument.test.ts","./src/services/diagramdocument.ts","./src/services/diagramdocumentschemas.ts","./src/services/domainlibrary.test.ts","./src/services/domainlibrary.ts","./src/services/elklayout.test.ts","./src/services/elklayout.ts","./src/services/exportservice.test.ts","./src/services/exportservice.ts","./src/services/figmaexportservice.ts","./src/services/flowchartroundtrip.test.ts","./src/services/geminiservice.ts","./src/services/geminisysteminstruction.ts","./src/services/gifencoder.test.ts","./src/services/gifencoder.ts","./src/services/githubfetcher.test.ts","./src/services/githubfetcher.ts","./src/services/iconassetcatalog.ts","./src/services/importfidelity.test.ts","./src/services/importfidelity.ts","./src/services/mermaidparser.test.ts","./src/services/openflowdslexporter.test.ts","./src/services/openflowdslexporter.ts","./src/services/openflowdslparser.test.ts","./src/services/openflowroundtripgolden.test.ts","./src/services/openflowroundtripgoldenfixtures.ts","./src/services/operationfeedback.ts","./src/services/remainingfamiliesroundtrip.test.ts","./src/services/smartedgerouting.test.ts","./src/services/smartedgerouting.ts","./src/services/statediagramroundtrip.test.ts","./src/services/templates.selector.test.ts","./src/services/templates.ts","./src/services/zipextractor.ts","./src/services/ai/contextserializer.test.ts","./src/services/ai/contextserializer.ts","./src/services/analytics/analytics.ts","./src/services/analytics/analyticssettings.test.ts","./src/services/analytics/analyticssettings.ts","./src/services/analytics/surfaceanalyticsclient.ts","./src/services/architecturelint/defaultrules.ts","./src/services/architecturelint/ruleengine.test.ts","./src/services/architecturelint/ruleengine.ts","./src/services/architecturelint/rulelibrary.ts","./src/services/architecturelint/types.ts","./src/services/architecturelint/workspacerules.ts","./src/services/collaboration/bootstrap.test.ts","./src/services/collaboration/bootstrap.ts","./src/services/collaboration/canvasdiff.test.ts","./src/services/collaboration/canvasdiff.ts","./src/services/collaboration/comments.test.ts","./src/services/collaboration/comments.ts","./src/services/collaboration/contracts.test.ts","./src/services/collaboration/contracts.ts","./src/services/collaboration/hookutils.ts","./src/services/collaboration/operationlog.test.ts","./src/services/collaboration/operationlog.ts","./src/services/collaboration/presenceviewmodel.test.ts","./src/services/collaboration/presenceviewmodel.ts","./src/services/collaboration/reducer.test.ts","./src/services/collaboration/reducer.ts","./src/services/collaboration/roomconfig.ts","./src/services/collaboration/roomlink.test.ts","./src/services/collaboration/roomlink.ts","./src/services/collaboration/runtimecontroller.test.ts","./src/services/collaboration/runtimecontroller.ts","./src/services/collaboration/runtimehookutils.test.ts","./src/services/collaboration/runtimehookutils.ts","./src/services/collaboration/schemas.ts","./src/services/collaboration/session.test.ts","./src/services/collaboration/session.ts","./src/services/collaboration/storebridge.test.ts","./src/services/collaboration/storebridge.ts","./src/services/collaboration/transport.test.ts","./src/services/collaboration/transport.ts","./src/services/collaboration/transportfactory.test.ts","./src/services/collaboration/transportfactory.ts","./src/services/collaboration/types.ts","./src/services/collaboration/versioning.test.ts","./src/services/collaboration/versioning.ts","./src/services/collaboration/yjspeertransport.test.ts","./src/services/collaboration/yjspeertransport.ts","./src/services/diagramdiff/diffengine.ts","./src/services/elk-layout/boundaryfanout.ts","./src/services/elk-layout/determinism.ts","./src/services/elk-layout/options.ts","./src/services/elk-layout/types.ts","./src/services/export/cinematicbuildplan.test.ts","./src/services/export/cinematicbuildplan.ts","./src/services/export/cinematicexport.ts","./src/services/export/cinematicexporttheme.test.ts","./src/services/export/cinematicexporttheme.ts","./src/services/export/cinematicrenderstate.test.ts","./src/services/export/cinematicrenderstate.ts","./src/services/export/formatting.ts","./src/services/export/mermaidbuilder.test.ts","./src/services/export/mermaidbuilder.ts","./src/services/export/pdfdocument.test.ts","./src/services/export/pdfdocument.ts","./src/services/export/plantumlbuilder.ts","./src/services/export/mermaid/architecturemermaid.ts","./src/services/export/mermaid/classdiagrammermaid.ts","./src/services/export/mermaid/erdiagrammermaid.ts","./src/services/export/mermaid/journeymermaid.ts","./src/services/export/mermaid/mindmapmermaid.ts","./src/services/export/mermaid/sequencemermaid.ts","./src/services/export/mermaid/statediagrammermaid.ts","./src/services/figma/edgehelpers.ts","./src/services/figma/iconhelpers.ts","./src/services/figma/nodelayers.ts","./src/services/figma/themehelpers.ts","./src/services/figmaimport/figmaapiclient.ts","./src/services/flowpilot/assetgrounding.test.ts","./src/services/flowpilot/assetgrounding.ts","./src/services/flowpilot/prompting.ts","./src/services/flowpilot/responsepolicy.test.ts","./src/services/flowpilot/responsepolicy.ts","./src/services/flowpilot/skills.ts","./src/services/flowpilot/thread.ts","./src/services/flowpilot/types.ts","./src/services/infrasync/dockercomposeparser.test.ts","./src/services/infrasync/dockercomposeparser.ts","./src/services/infrasync/infratodsl.test.ts","./src/services/infrasync/infratodsl.ts","./src/services/infrasync/kubernetesparser.test.ts","./src/services/infrasync/kubernetesparser.ts","./src/services/infrasync/terraformstateparser.test.ts","./src/services/infrasync/terraformstateparser.ts","./src/services/infrasync/types.ts","./src/services/mermaid/detectdiagramtype.test.ts","./src/services/mermaid/detectdiagramtype.ts","./src/services/mermaid/diagnosticformatting.test.ts","./src/services/mermaid/diagnosticformatting.ts","./src/services/mermaid/parsemermaidbytype.test.ts","./src/services/mermaid/parsemermaidbytype.ts","./src/services/mermaid/strictmodediagnosticspresentation.test.ts","./src/services/mermaid/strictmodediagnosticspresentation.ts","./src/services/mermaid/strictmodeguidance.test.ts","./src/services/mermaid/strictmodeguidance.ts","./src/services/mermaid/strictmodeuxregression.test.ts","./src/services/offline/registerappshellserviceworker.test.ts","./src/services/offline/registerappshellserviceworker.ts","./src/services/onboarding/config.ts","./src/services/onboarding/eventschemas.ts","./src/services/onboarding/events.test.ts","./src/services/onboarding/events.ts","./src/services/playback/contracts.test.ts","./src/services/playback/contracts.ts","./src/services/playback/model.test.ts","./src/services/playback/model.ts","./src/services/playback/studio.test.ts","./src/services/playback/studio.ts","./src/services/sequence/sequencemessage.test.ts","./src/services/sequence/sequencemessage.ts","./src/services/shapelibrary/bootstrap.test.ts","./src/services/shapelibrary/bootstrap.ts","./src/services/shapelibrary/ingestionpipeline.test.ts","./src/services/shapelibrary/ingestionpipeline.ts","./src/services/shapelibrary/manifestvalidation.test.ts","./src/services/shapelibrary/manifestvalidation.ts","./src/services/shapelibrary/providercatalog.test.ts","./src/services/shapelibrary/providercatalog.ts","./src/services/shapelibrary/registry.test.ts","./src/services/shapelibrary/registry.ts","./src/services/shapelibrary/starterpacks.ts","./src/services/shapelibrary/types.ts","./src/services/storage/fallbackstorage.ts","./src/services/storage/flowdocumentmodel.test.ts","./src/services/storage/flowdocumentmodel.ts","./src/services/storage/flowpersiststorage.test.ts","./src/services/storage/flowpersiststorage.ts","./src/services/storage/indexeddbhelpers.ts","./src/services/storage/indexeddbschema.test.ts","./src/services/storage/indexeddbschema.ts","./src/services/storage/indexeddbstatestorage.test.ts","./src/services/storage/indexeddbstatestorage.ts","./src/services/storage/localfirstrepository.ts","./src/services/storage/localfirstruntime.ts","./src/services/storage/persisteddocumentadapters.test.ts","./src/services/storage/persisteddocumentadapters.ts","./src/services/storage/persistencetypes.ts","./src/services/storage/snapshotstorage.test.ts","./src/services/storage/snapshotstorage.ts","./src/services/storage/storageruntime.test.ts","./src/services/storage/storageruntime.ts","./src/services/storage/storageschemas.test.ts","./src/services/storage/storageschemas.ts","./src/services/storage/storagetelemetry.test.ts","./src/services/storage/storagetelemetry.ts","./src/services/storage/storagetelemetrysink.test.ts","./src/services/storage/storagetelemetrysink.ts","./src/services/storage/uilocalstorage.test.ts","./src/services/storage/uilocalstorage.ts","./src/services/templatelibrary/registry.test.ts","./src/services/templatelibrary/registry.ts","./src/services/templatelibrary/startertemplates.assets.test.ts","./src/services/templatelibrary/startertemplates.test.ts","./src/services/templatelibrary/startertemplates.ts","./src/services/templatelibrary/templatefactories.ts","./src/services/templatelibrary/types.ts","./src/store/actionfactory.ts","./src/store/aisettings.test.ts","./src/store/aisettings.ts","./src/store/aisettingspersistence.ts","./src/store/aisettingsschemas.test.ts","./src/store/aisettingsschemas.ts","./src/store/canvashooks.ts","./src/store/createflowstore.test.ts","./src/store/createflowstore.ts","./src/store/createflowstorepersistoptions.ts","./src/store/createflowstorestate.ts","./src/store/defaults.ts","./src/store/designsystemhooks.ts","./src/store/documenthooks.ts","./src/store/documentstatesync.test.ts","./src/store/documentstatesync.ts","./src/store/editorpagehooks.ts","./src/store/historyhooks.ts","./src/store/historystate.ts","./src/store/persistence.test.ts","./src/store/persistence.ts","./src/store/persistenceschemas.ts","./src/store/selectionhooks.ts","./src/store/selectors.test.ts","./src/store/selectors.ts","./src/store/tabhooks.ts","./src/store/types.ts","./src/store/viewhooks.ts","./src/store/workspacedocumentmodel.test.ts","./src/store/workspacedocumentmodel.ts","./src/store/actions/createaiandselectionactions.ts","./src/store/actions/createcanvasactions.ts","./src/store/actions/createdesignsystemactions.ts","./src/store/actions/createhistoryactions.test.ts","./src/store/actions/createhistoryactions.ts","./src/store/actions/createlayeractions.ts","./src/store/actions/createtabactions.ts","./src/store/actions/createviewactions.ts","./src/store/actions/createworkspacedocumentactions.ts","./src/store/actions/synctabnodesedges.ts","./src/store/slices/createcanvaseditorslice.ts","./src/store/slices/createexperienceslice.ts","./src/store/slices/createworkspaceslice.ts"],"version":"5.8.3"} \ No newline at end of file +{"root":["./src/app.test.tsx","./src/app.tsx","./src/constants.test.ts","./src/constants.ts","./src/index.tsx","./src/store.test.ts","./src/store.ts","./src/theme.test.ts","./src/theme.ts","./src/app/routestate.test.ts","./src/app/routestate.ts","./src/components/annotationnode.tsx","./src/components/architecturerulespanel.tsx","./src/components/cinematicexportoverlay.tsx","./src/components/commandbar.test.tsx","./src/components/commandbar.tsx","./src/components/connectmenu.test.tsx","./src/components/connectmenu.tsx","./src/components/connectmenusections.tsx","./src/components/contextmenu.test.tsx","./src/components/contextmenu.tsx","./src/components/customconnectionline.test.tsx","./src/components/customconnectionline.tsx","./src/components/customedge.tsx","./src/components/customnode.handleinteraction.test.tsx","./src/components/customnode.tsx","./src/components/customnodecontent.tsx","./src/components/designsystem.integration.test.tsx","./src/components/diagramviewer.tsx","./src/components/errorboundary.tsx","./src/components/exportmenu.tsx","./src/components/exportmenupanel.test.tsx","./src/components/exportmenupanel.tsx","./src/components/flowcanvas.tsx","./src/components/floweditor.tsx","./src/components/floweditoremptystate.tsx","./src/components/floweditorlayoutoverlay.tsx","./src/components/floweditorpanels.test.tsx","./src/components/floweditorpanels.tsx","./src/components/flowtabs.test.tsx","./src/components/flowtabs.tsx","./src/components/groupnode.tsx","./src/components/homepage.integration.test.tsx","./src/components/homepage.tsx","./src/components/iconassetnodebody.tsx","./src/components/iconmap.test.tsx","./src/components/iconmap.ts","./src/components/imagenode.tsx","./src/components/importrecoverydialog.test.tsx","./src/components/importrecoverydialog.tsx","./src/components/inlinetexteditsurface.test.tsx","./src/components/inlinetexteditsurface.tsx","./src/components/keyboardshortcutsmodal.tsx","./src/components/languageselector.tsx","./src/components/markdownrenderer.tsx","./src/components/memoizedmarkdown.tsx","./src/components/navigationcontrols.tsx","./src/components/nodebadges.tsx","./src/components/nodechrome.tsx","./src/components/nodequickcreatebuttons.tsx","./src/components/nodeshapesvg.tsx","./src/components/nodetransformcontrols.tsx","./src/components/playbackcontrols.tsx","./src/components/propertiespanel.tsx","./src/components/rightrail.tsx","./src/components/sectionnode.tsx","./src/components/shareembedmodal.tsx","./src/components/sharemodal.test.tsx","./src/components/sharemodal.tsx","./src/components/sidebarshell.tsx","./src/components/snapshotspanel.test.tsx","./src/components/snapshotspanel.tsx","./src/components/studioaipanel.test.tsx","./src/components/studioaipanel.tsx","./src/components/studioaipanelsections.tsx","./src/components/studiocodepanel.test.tsx","./src/components/studiocodepanel.tsx","./src/components/studiopanel.test.tsx","./src/components/studiopanel.tsx","./src/components/studioplaybackpanel.test.tsx","./src/components/studioplaybackpanel.tsx","./src/components/swimlanenode.tsx","./src/components/textnode.tsx","./src/components/themetoggle.tsx","./src/components/toolbar.tsx","./src/components/tooltip.tsx","./src/components/topnav.tsx","./src/components/welcomemodal.tsx","./src/components/annotationtheme.ts","./src/components/container-nodes.handleinteraction.test.tsx","./src/components/editorsurfacetiers.ts","./src/components/handleinteraction.test.ts","./src/components/handleinteraction.ts","./src/components/handleinteractionusage.test.ts","./src/components/lightweight-nodes.handleinteraction.test.tsx","./src/components/markdownsyntax.ts","./src/components/nodehelpers.test.ts","./src/components/nodehelpers.ts","./src/components/sharemodalcontent.ts","./src/components/studioaicopy.ts","./src/components/studioaipanelexamples.ts","./src/components/transformdiagnostics.test.ts","./src/components/transformdiagnostics.ts","./src/components/useactivenodeselection.ts","./src/components/useexportmenu.test.tsx","./src/components/useexportmenu.ts","./src/components/settingsmodal/aisettings.tsx","./src/components/settingsmodal/canvassettings.tsx","./src/components/settingsmodal/generalsettings.test.tsx","./src/components/settingsmodal/generalsettings.tsx","./src/components/settingsmodal/settingsmodal.test.tsx","./src/components/settingsmodal/settingsmodal.tsx","./src/components/settingsmodal/shortcutssettings.tsx","./src/components/settingsmodal/ai/advancedendpointsection.tsx","./src/components/settingsmodal/ai/customheaderseditor.tsx","./src/components/settingsmodal/ai/privacysection.tsx","./src/components/settingsmodal/ai/providericon.tsx","./src/components/settingsmodal/ai/providersection.tsx","./src/components/settingsmodal/ai/step.tsx","./src/components/add-items/additemregistry.tsx","./src/components/app/docssiteredirect.test.tsx","./src/components/app/docssiteredirect.tsx","./src/components/app/mobileworkspacegate.test.tsx","./src/components/app/mobileworkspacegate.tsx","./src/components/app/routeloadingfallback.test.tsx","./src/components/app/routeloadingfallback.tsx","./src/components/app/mobileworkspacegatecopy.ts","./src/components/app/routeloadingcopy.ts","./src/components/architecture-lint/lintrulespanel.tsx","./src/components/architecture-lint/ruleform.tsx","./src/components/architecture-lint/visualeditor.tsx","./src/components/command-bar/assetsview.test.tsx","./src/components/command-bar/assetsview.tsx","./src/components/command-bar/codebaseimportpanels.tsx","./src/components/command-bar/codebaseimportsection.tsx","./src/components/command-bar/designsystemeditor.tsx","./src/components/command-bar/designsystemview.tsx","./src/components/command-bar/diagramminipreview.tsx","./src/components/command-bar/figmaimportpanel.tsx","./src/components/command-bar/importsurfaceprimitives.tsx","./src/components/command-bar/importview.tsx","./src/components/command-bar/importviewpanels.tsx","./src/components/command-bar/layersview.tsx","./src/components/command-bar/layoutview.tsx","./src/components/command-bar/pagesview.tsx","./src/components/command-bar/rootview.test.tsx","./src/components/command-bar/rootview.tsx","./src/components/command-bar/searchview.tsx","./src/components/command-bar/templatesview.test.tsx","./src/components/command-bar/templatesview.tsx","./src/components/command-bar/viewheader.tsx","./src/components/command-bar/visualsview.tsx","./src/components/command-bar/applycodechanges.ts","./src/components/command-bar/assetsviewconstants.ts","./src/components/command-bar/importdetection.test.ts","./src/components/command-bar/importdetection.ts","./src/components/command-bar/importnativeparsers.ts","./src/components/command-bar/importviewmodel.test.ts","./src/components/command-bar/importviewmodel.ts","./src/components/command-bar/mermaidimportparser.ts","./src/components/command-bar/searchquery.test.ts","./src/components/command-bar/searchquery.ts","./src/components/command-bar/types.ts","./src/components/command-bar/useaiviewstate.test.tsx","./src/components/command-bar/useaiviewstate.ts","./src/components/command-bar/usecloudassetcatalog.ts","./src/components/command-bar/usecommandbarcommands.test.tsx","./src/components/command-bar/usecommandbarcommands.tsx","./src/components/custom-edge/customedgewrapper.tsx","./src/components/custom-edge/edgemarkerdefs.tsx","./src/components/custom-edge/sequencemessageedge.test.tsx","./src/components/custom-edge/sequencemessageedge.tsx","./src/components/custom-edge/animatededgepresentation.test.ts","./src/components/custom-edge/animatededgepresentation.ts","./src/components/custom-edge/classrelationsemantics.test.ts","./src/components/custom-edge/classrelationsemantics.ts","./src/components/custom-edge/edgerendermode.ts","./src/components/custom-edge/pathutils.test.ts","./src/components/custom-edge/pathutils.ts","./src/components/custom-edge/pathutilsgeometry.ts","./src/components/custom-edge/pathutilssiblingrouting.ts","./src/components/custom-edge/pathutilstypes.ts","./src/components/custom-edge/relationroutingsemantics.test.ts","./src/components/custom-edge/relationroutingsemantics.ts","./src/components/custom-edge/standardedgemarkers.test.ts","./src/components/custom-edge/standardedgemarkers.ts","./src/components/custom-nodes/architecturenode.handleinteraction.test.tsx","./src/components/custom-nodes/architecturenode.tsx","./src/components/custom-nodes/browsernode.tsx","./src/components/custom-nodes/classentitynode.handleinteraction.test.tsx","./src/components/custom-nodes/classnode.tsx","./src/components/custom-nodes/entitynode.tsx","./src/components/custom-nodes/journeynode.tsx","./src/components/custom-nodes/mindmapnode.tsx","./src/components/custom-nodes/mobilenode.tsx","./src/components/custom-nodes/sequencenotenode.tsx","./src/components/custom-nodes/sequenceparticipantnode.tsx","./src/components/custom-nodes/structurednodehandles.tsx","./src/components/custom-nodes/visualnodes.handleinteraction.test.tsx","./src/components/custom-nodes/browservariantrenderer.tsx","./src/components/custom-nodes/mobilevariantrenderer.tsx","./src/components/custom-nodes/structuredlistnavigation.test.ts","./src/components/custom-nodes/structuredlistnavigation.ts","./src/components/custom-nodes/variantconstants.ts","./src/components/diagram-diff/diffmodebanner.tsx","./src/components/flow-canvas/flowcanvasalignmentguidesoverlay.tsx","./src/components/flow-canvas/flowcanvasoverlays.tsx","./src/components/flow-canvas/flowcanvassurface.test.tsx","./src/components/flow-canvas/flowcanvassurface.tsx","./src/components/flow-canvas/streamingoverlay.tsx","./src/components/flow-canvas/alignmentguides.test.ts","./src/components/flow-canvas/alignmentguides.ts","./src/components/flow-canvas/flowcanvasreactflowcontracts.ts","./src/components/flow-canvas/flowcanvastypes.test.ts","./src/components/flow-canvas/flowcanvastypes.tsx","./src/components/flow-canvas/largegraphsafetymode.test.ts","./src/components/flow-canvas/largegraphsafetymode.ts","./src/components/flow-canvas/nodechromecoverage.test.ts","./src/components/flow-canvas/pastehelpers.test.ts","./src/components/flow-canvas/pastehelpers.ts","./src/components/flow-canvas/useflowcanvasalignmentguides.ts","./src/components/flow-canvas/useflowcanvasconnectionstate.ts","./src/components/flow-canvas/useflowcanvascontextactions.ts","./src/components/flow-canvas/useflowcanvasdragdrop.ts","./src/components/flow-canvas/useflowcanvasinteractionlod.ts","./src/components/flow-canvas/useflowcanvasmenus.ts","./src/components/flow-canvas/useflowcanvasmenusandactions.test.tsx","./src/components/flow-canvas/useflowcanvasmenusandactions.ts","./src/components/flow-canvas/useflowcanvasnodedraghandlers.ts","./src/components/flow-canvas/useflowcanvaspaste.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.test.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.ts","./src/components/flow-canvas/useflowcanvasselectiontools.test.tsx","./src/components/flow-canvas/useflowcanvasselectiontools.ts","./src/components/flow-canvas/useflowcanvasviewstate.test.ts","./src/components/flow-canvas/useflowcanvasviewstate.ts","./src/components/flow-canvas/useflowcanvaszoomlod.ts","./src/components/flow-editor/collaborationpresenceoverlay.test.tsx","./src/components/flow-editor/collaborationpresenceoverlay.tsx","./src/components/flow-editor/floweditorchrome.tsx","./src/components/flow-editor/buildfloweditorcontrollerparams.ts","./src/components/flow-editor/buildfloweditorscreencontrollerparams.ts","./src/components/flow-editor/chromeproptypes.ts","./src/components/flow-editor/floweditorchromeprops.ts","./src/components/flow-editor/infradslapply.test.ts","./src/components/flow-editor/infradslapply.ts","./src/components/flow-editor/panelprops.test.ts","./src/components/flow-editor/panelprops.ts","./src/components/flow-editor/shouldexitstudioonselection.test.ts","./src/components/flow-editor/shouldexitstudioonselection.ts","./src/components/flow-editor/usecollaborationnodepositions.ts","./src/components/flow-editor/usefloweditorcontroller.ts","./src/components/flow-editor/usefloweditorinteractionbindings.ts","./src/components/flow-editor/usefloweditorpanelactions.ts","./src/components/flow-editor/usefloweditorpanelprops.ts","./src/components/flow-editor/usefloweditorruntime.ts","./src/components/flow-editor/usefloweditorscreenbehavior.ts","./src/components/flow-editor/usefloweditorscreenmodel.ts","./src/components/flow-editor/usefloweditorscreenstate.ts","./src/components/flow-editor/usefloweditorshellcontroller.test.tsx","./src/components/flow-editor/usefloweditorshellcontroller.ts","./src/components/flow-editor/usefloweditorstudiocontroller.test.tsx","./src/components/flow-editor/usefloweditorstudiocontroller.ts","./src/components/flow-editor/useinfradslapply.ts","./src/components/home/githubcard.tsx","./src/components/home/homedashboard.tsx","./src/components/home/homeflowdialogs.tsx","./src/components/home/homesettingsview.tsx","./src/components/home/homesidebar.tsx","./src/components/home/hometemplatesview.tsx","./src/components/home/sidebarfooter.tsx","./src/components/home/welcomemodalstate.ts","./src/components/icons/assetsicon.tsx","./src/components/icons/openflowlogo.tsx","./src/components/infra-sync/infrasyncpanel.test.tsx","./src/components/infra-sync/infrasyncpanel.tsx","./src/components/journey/journeyscorecontrol.test.tsx","./src/components/journey/journeyscorecontrol.tsx","./src/components/properties/bulknodeproperties.test.tsx","./src/components/properties/bulknodeproperties.tsx","./src/components/properties/bulknodepropertiesfamilysections.tsx","./src/components/properties/bulknodepropertiessections.tsx","./src/components/properties/bulknodepropertiesutilitysections.tsx","./src/components/properties/colorpicker.test.tsx","./src/components/properties/colorpicker.tsx","./src/components/properties/customcolorpopover.tsx","./src/components/properties/diagramnodepropertiesrouter.test.ts","./src/components/properties/diagramnodepropertiesrouter.tsx","./src/components/properties/edgeproperties.tsx","./src/components/properties/iconpicker.tsx","./src/components/properties/icontilepickerprimitives.tsx","./src/components/properties/imageupload.tsx","./src/components/properties/inspectorprimitives.tsx","./src/components/properties/nodeactionbuttons.tsx","./src/components/properties/nodecontentsection.tsx","./src/components/properties/nodeimagesettingssection.tsx","./src/components/properties/nodeproperties.test.tsx","./src/components/properties/nodeproperties.tsx","./src/components/properties/nodewireframevariantsection.tsx","./src/components/properties/propertysliderrow.tsx","./src/components/properties/segmentedchoice.tsx","./src/components/properties/shapeselector.tsx","./src/components/properties/swatchpicker.tsx","./src/components/properties/bulknodepropertiesmodel.test.ts","./src/components/properties/bulknodepropertiesmodel.ts","./src/components/properties/colorpickerutils.ts","./src/components/properties/propertyinputbehavior.test.ts","./src/components/properties/propertyinputbehavior.ts","./src/components/properties/sectionactionbuilder.ts","./src/components/properties/shared.ts","./src/components/properties/wireframevariants.ts","./src/components/properties/edge/architectureedgesemanticssection.tsx","./src/components/properties/edge/edgecolorsection.test.tsx","./src/components/properties/edge/edgecolorsection.tsx","./src/components/properties/edge/edgeconditionsection.tsx","./src/components/properties/edge/edgelabelsection.tsx","./src/components/properties/edge/edgerelationsection.test.tsx","./src/components/properties/edge/edgerelationsection.tsx","./src/components/properties/edge/edgeroutesection.test.tsx","./src/components/properties/edge/edgeroutesection.tsx","./src/components/properties/edge/edgestylesection.tsx","./src/components/properties/edge/sequencemessagesection.tsx","./src/components/properties/edge/architecturesemantics.test.ts","./src/components/properties/edge/architecturesemantics.ts","./src/components/properties/edge/edgecolorutils.ts","./src/components/properties/edge/edgelabelmodel.test.ts","./src/components/properties/edge/edgelabelmodel.ts","./src/components/properties/edge/errelationoptions.ts","./src/components/properties/families/architecturenodeproperties.test.tsx","./src/components/properties/families/architecturenodeproperties.tsx","./src/components/properties/families/architecturenodesection.tsx","./src/components/properties/families/classdiagramnodeproperties.tsx","./src/components/properties/families/classmemberlisteditor.tsx","./src/components/properties/families/classnodesection.tsx","./src/components/properties/families/erdiagramnodeproperties.tsx","./src/components/properties/families/entityfieldlisteditor.tsx","./src/components/properties/families/entitynodesection.tsx","./src/components/properties/families/journeynodeproperties.tsx","./src/components/properties/families/journeynodesection.tsx","./src/components/properties/families/mindmapnodeproperties.test.tsx","./src/components/properties/families/mindmapnodeproperties.tsx","./src/components/properties/families/sequencenodeproperties.tsx","./src/components/properties/families/sequencenodesection.tsx","./src/components/properties/families/specializednodecolorsections.test.tsx","./src/components/properties/families/structuredtextlisteditor.tsx","./src/components/properties/families/architectureoptions.ts","./src/components/studio-code-panel/usestudiocodepanelcontroller.test.tsx","./src/components/studio-code-panel/usestudiocodepanelcontroller.ts","./src/components/templates/templatepresentation.tsx","./src/components/toolbar/toolbaraddmenu.test.tsx","./src/components/toolbar/toolbaraddmenu.tsx","./src/components/toolbar/toolbaraddmenupanel.tsx","./src/components/toolbar/toolbarhistorycontrols.tsx","./src/components/toolbar/toolbarmodecontrols.tsx","./src/components/toolbar/toolbarbuttonstyles.ts","./src/components/top-nav/savestatusindicator.tsx","./src/components/top-nav/topnavactions.tsx","./src/components/top-nav/topnavbrand.tsx","./src/components/top-nav/topnavmenu.test.tsx","./src/components/top-nav/topnavmenu.tsx","./src/components/top-nav/topnavmenupanel.tsx","./src/components/top-nav/usetopnavstate.ts","./src/components/ui/button.tsx","./src/components/ui/collapsiblesection.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/searchfield.tsx","./src/components/ui/segmentedtabs.tsx","./src/components/ui/select.test.tsx","./src/components/ui/select.tsx","./src/components/ui/sidebaritem.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toastcontext.tsx","./src/components/ui/editorfieldstyles.ts","./src/config/aiproviders.test.ts","./src/config/aiproviders.ts","./src/config/rolloutflags.ts","./src/context/architecturelintcontext.tsx","./src/context/cinematicexportcontext.tsx","./src/context/diagramdiffcontext.tsx","./src/context/themecontext.tsx","./src/diagram-types/bootstrap.test.ts","./src/diagram-types/bootstrap.ts","./src/diagram-types/builtinplugins.ts","./src/diagram-types/builtinpropertypanels.ts","./src/diagram-types/registerbuiltinplugins.test.ts","./src/diagram-types/registerbuiltinplugins.ts","./src/diagram-types/registerbuiltinpropertypanels.test.ts","./src/diagram-types/registerbuiltinpropertypanels.ts","./src/diagram-types/architecture/fuzzcorpus.test.ts","./src/diagram-types/architecture/plugin.test.ts","./src/diagram-types/architecture/plugin.ts","./src/diagram-types/classdiagram/fuzzcorpus.test.ts","./src/diagram-types/classdiagram/plugin.test.ts","./src/diagram-types/classdiagram/plugin.ts","./src/diagram-types/core/contracts.ts","./src/diagram-types/core/index.ts","./src/diagram-types/core/propertypanels.test.ts","./src/diagram-types/core/propertypanels.ts","./src/diagram-types/core/registry.ts","./src/diagram-types/erdiagram/fuzzcorpus.test.ts","./src/diagram-types/erdiagram/plugin.test.ts","./src/diagram-types/erdiagram/plugin.ts","./src/diagram-types/flowchart/plugin.ts","./src/diagram-types/journey/fuzzcorpus.test.ts","./src/diagram-types/journey/plugin.test.ts","./src/diagram-types/journey/plugin.ts","./src/diagram-types/mindmap/fuzzcorpus.test.ts","./src/diagram-types/mindmap/plugin.test.ts","./src/diagram-types/mindmap/plugin.ts","./src/diagram-types/sequence/plugin.test.ts","./src/diagram-types/sequence/plugin.ts","./src/diagram-types/statediagram/plugin.test.ts","./src/diagram-types/statediagram/plugin.ts","./src/docs/docsroutes.ts","./src/docs/publicdocscatalog.js","./src/hooks/edgeconnectinteractions.test.ts","./src/hooks/edgeconnectinteractions.ts","./src/hooks/flowexportviewport.test.ts","./src/hooks/flowexportviewport.ts","./src/hooks/mindmaptopicactionrequest.ts","./src/hooks/nodelabeleditrequest.test.ts","./src/hooks/nodelabeleditrequest.ts","./src/hooks/nodequickcreaterequest.test.ts","./src/hooks/nodequickcreaterequest.ts","./src/hooks/snapshotpolicy.test.ts","./src/hooks/snapshotpolicy.ts","./src/hooks/useaigeneration.ts","./src/hooks/useanalyticspreference.ts","./src/hooks/useanimatededgeperformancewarning.test.ts","./src/hooks/useanimatededgeperformancewarning.ts","./src/hooks/useassetcatalog.ts","./src/hooks/usecinematicexport.ts","./src/hooks/useclipboardoperations.ts","./src/hooks/usedesignsystem.ts","./src/hooks/useedgeinteractions.ts","./src/hooks/useedgeoperations.ts","./src/hooks/usefloweditoractions.test.ts","./src/hooks/usefloweditoractions.ts","./src/hooks/usefloweditorcallbacks.ts","./src/hooks/usefloweditorcollaboration.test.ts","./src/hooks/usefloweditorcollaboration.ts","./src/hooks/usefloweditoruistate.ts","./src/hooks/useflowexport.ts","./src/hooks/useflowhistory.test.ts","./src/hooks/useflowhistory.ts","./src/hooks/useflowoperations.ts","./src/hooks/usegithubstars.ts","./src/hooks/useinfrasync.ts","./src/hooks/useinlinenodetextedit.test.ts","./src/hooks/useinlinenodetextedit.ts","./src/hooks/usekeyboardshortcuts.test.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/uselayoutoperations.ts","./src/hooks/usemarkdowneditor.ts","./src/hooks/usemenukeyboardnavigation.ts","./src/hooks/usemodifierkeys.test.ts","./src/hooks/usemodifierkeys.ts","./src/hooks/usenodeoperations.ts","./src/hooks/useplayback.ts","./src/hooks/useprovidershapepreview.ts","./src/hooks/userecentimports.ts","./src/hooks/useshiftheld.ts","./src/hooks/usesnapshots.ts","./src/hooks/usestaticexport.ts","./src/hooks/usestoragepressureguard.ts","./src/hooks/usestructuredlisteditor.ts","./src/hooks/usestyleclipboard.test.ts","./src/hooks/usestyleclipboard.ts","./src/hooks/ai-generation/chathistorystorage.test.ts","./src/hooks/ai-generation/chathistorystorage.ts","./src/hooks/ai-generation/codetoarchitecture.test.ts","./src/hooks/ai-generation/codetoarchitecture.ts","./src/hooks/ai-generation/codebaseanalyzer.test.ts","./src/hooks/ai-generation/codebaseanalyzer.ts","./src/hooks/ai-generation/codebaseparser.test.ts","./src/hooks/ai-generation/codebaseparser.ts","./src/hooks/ai-generation/codebasetonativediagram.test.ts","./src/hooks/ai-generation/codebasetonativediagram.ts","./src/hooks/ai-generation/graphcomposer.test.ts","./src/hooks/ai-generation/graphcomposer.ts","./src/hooks/ai-generation/nodeactionprompts.test.ts","./src/hooks/ai-generation/nodeactionprompts.ts","./src/hooks/ai-generation/openapiparser.test.ts","./src/hooks/ai-generation/openapiparser.ts","./src/hooks/ai-generation/openapitosequence.ts","./src/hooks/ai-generation/positionpreservingapply.test.ts","./src/hooks/ai-generation/positionpreservingapply.ts","./src/hooks/ai-generation/readiness.test.ts","./src/hooks/ai-generation/readiness.ts","./src/hooks/ai-generation/requestlifecycle.test.ts","./src/hooks/ai-generation/requestlifecycle.ts","./src/hooks/ai-generation/sqlparser.test.ts","./src/hooks/ai-generation/sqlparser.ts","./src/hooks/ai-generation/sqltoerd.ts","./src/hooks/ai-generation/streamingparser.ts","./src/hooks/ai-generation/streamingstore.ts","./src/hooks/ai-generation/terraformtocloud.ts","./src/hooks/edge-operations/utils.test.ts","./src/hooks/edge-operations/utils.ts","./src/hooks/flow-editor-actions/exporthandlers.test.ts","./src/hooks/flow-editor-actions/exporthandlers.ts","./src/hooks/flow-editor-actions/helpers.ts","./src/hooks/flow-editor-actions/layouthandlers.test.ts","./src/hooks/flow-editor-actions/layouthandlers.ts","./src/hooks/flow-export/diagramdocumenttransfer.test.ts","./src/hooks/flow-export/diagramdocumenttransfer.ts","./src/hooks/flow-export/exportcapture.test.ts","./src/hooks/flow-export/exportcapture.ts","./src/hooks/flow-operations/useflowcoreactions.ts","./src/hooks/node-operations/createconnectedsibling.ts","./src/hooks/node-operations/dragstopreconcilepolicy.test.ts","./src/hooks/node-operations/dragstopreconcilepolicy.ts","./src/hooks/node-operations/nodefactories.ts","./src/hooks/node-operations/routingduringdrag.test.ts","./src/hooks/node-operations/routingduringdrag.ts","./src/hooks/node-operations/sectionbounds.ts","./src/hooks/node-operations/sectionhittesting.ts","./src/hooks/node-operations/sectionoperations.ts","./src/hooks/node-operations/usearchitecturenodeoperations.test.ts","./src/hooks/node-operations/usearchitecturenodeoperations.ts","./src/hooks/node-operations/usemindmapnodeoperations.test.ts","./src/hooks/node-operations/usemindmapnodeoperations.ts","./src/hooks/node-operations/usenodedragoperations.test.ts","./src/hooks/node-operations/usenodedragoperations.ts","./src/hooks/node-operations/usenodeoperationadders.ts","./src/hooks/node-operations/utils.test.ts","./src/hooks/node-operations/utils.ts","./src/i18n/config.test.ts","./src/i18n/config.ts","./src/i18n/strictmodelocalecoverage.test.ts","./src/i18n/usedlocalecoverage.test.ts","./src/lib/architecturetemplatedata.ts","./src/lib/architecturetemplates.test.ts","./src/lib/architecturetemplates.ts","./src/lib/brand.ts","./src/lib/classmembers.ts","./src/lib/colorutils.ts","./src/lib/connectcreationpolicy.test.ts","./src/lib/connectcreationpolicy.ts","./src/lib/date.ts","./src/lib/designtokens.ts","./src/lib/devperformance.ts","./src/lib/entityfields.ts","./src/lib/ertoclassconversion.test.ts","./src/lib/ertoclassconversion.ts","./src/lib/exportfilename.test.ts","./src/lib/exportfilename.ts","./src/lib/flowminddslparserv2.test.ts","./src/lib/flowminddslparserv2.ts","./src/lib/fuzzymatch.ts","./src/lib/genericshapepolicy.ts","./src/lib/iconresolver.test.ts","./src/lib/iconresolver.ts","./src/lib/id.ts","./src/lib/index.ts","./src/lib/legacybranding.ts","./src/lib/logger.ts","./src/lib/mermaidenrichmentpipeline.test.ts","./src/lib/mermaidparser.ts","./src/lib/mermaidparserhelpers.ts","./src/lib/mermaidparsermodel.ts","./src/lib/mindmaplayout.test.ts","./src/lib/mindmaplayout.ts","./src/lib/mindmaplayoutengine.ts","./src/lib/mindmaptree.ts","./src/lib/nodebulkediting.test.ts","./src/lib/nodebulkediting.ts","./src/lib/nodeenricher.test.ts","./src/lib/nodeenricher.ts","./src/lib/nodehandles.ts","./src/lib/nodeparent.ts","./src/lib/nodestyledata.test.ts","./src/lib/nodestyledata.ts","./src/lib/openflowdslparser.ts","./src/lib/openflowdslparserv2.ts","./src/lib/reactflowcompat.ts","./src/lib/reconnectedge.test.ts","./src/lib/reconnectedge.ts","./src/lib/relationsemantics.test.ts","./src/lib/relationsemantics.ts","./src/lib/releasestaleelkroutes.test.ts","./src/lib/releasestaleelkroutes.ts","./src/lib/result.ts","./src/lib/semanticclassifier.test.ts","./src/lib/semanticclassifier.ts","./src/lib/storagepressure.test.ts","./src/lib/storagepressure.ts","./src/lib/types.ts","./src/lib/xsssafety.test.tsx","./src/services/aligndistribute.ts","./src/services/aiservice.test.ts","./src/services/aiservice.ts","./src/services/aiserviceschemas.ts","./src/services/animatedexport.test.ts","./src/services/animatedexport.ts","./src/services/architectureroundtrip.test.ts","./src/services/assetcatalog.test.ts","./src/services/assetcatalog.ts","./src/services/assetpresentation.ts","./src/services/canonicalserialization.test.ts","./src/services/canonicalserialization.ts","./src/services/composediagramfordisplay.test.ts","./src/services/composediagramfordisplay.ts","./src/services/diagramdocument.test.ts","./src/services/diagramdocument.ts","./src/services/diagramdocumentschemas.ts","./src/services/domainlibrary.test.ts","./src/services/domainlibrary.ts","./src/services/elklayout.test.ts","./src/services/elklayout.ts","./src/services/exportservice.test.ts","./src/services/exportservice.ts","./src/services/figmaexportservice.ts","./src/services/flowchartroundtrip.test.ts","./src/services/geminiservice.ts","./src/services/geminisysteminstruction.ts","./src/services/gifencoder.test.ts","./src/services/gifencoder.ts","./src/services/githubfetcher.test.ts","./src/services/githubfetcher.ts","./src/services/iconassetcatalog.ts","./src/services/importfidelity.test.ts","./src/services/importfidelity.ts","./src/services/mermaidparser.test.ts","./src/services/openflowdslexporter.test.ts","./src/services/openflowdslexporter.ts","./src/services/openflowdslparser.test.ts","./src/services/openflowroundtripgolden.test.ts","./src/services/openflowroundtripgoldenfixtures.ts","./src/services/operationfeedback.ts","./src/services/remainingfamiliesroundtrip.test.ts","./src/services/smartedgerouting.test.ts","./src/services/smartedgerouting.ts","./src/services/statediagramroundtrip.test.ts","./src/services/templates.selector.test.ts","./src/services/templates.ts","./src/services/zipextractor.ts","./src/services/ai/contextserializer.test.ts","./src/services/ai/contextserializer.ts","./src/services/analytics/analytics.ts","./src/services/analytics/analyticssettings.test.ts","./src/services/analytics/analyticssettings.ts","./src/services/analytics/surfaceanalyticsclient.ts","./src/services/architecturelint/defaultrules.ts","./src/services/architecturelint/ruleengine.test.ts","./src/services/architecturelint/ruleengine.ts","./src/services/architecturelint/rulelibrary.ts","./src/services/architecturelint/types.ts","./src/services/architecturelint/workspacerules.ts","./src/services/collaboration/bootstrap.test.ts","./src/services/collaboration/bootstrap.ts","./src/services/collaboration/canvasdiff.test.ts","./src/services/collaboration/canvasdiff.ts","./src/services/collaboration/comments.test.ts","./src/services/collaboration/comments.ts","./src/services/collaboration/contracts.test.ts","./src/services/collaboration/contracts.ts","./src/services/collaboration/hookutils.ts","./src/services/collaboration/operationlog.test.ts","./src/services/collaboration/operationlog.ts","./src/services/collaboration/presenceviewmodel.test.ts","./src/services/collaboration/presenceviewmodel.ts","./src/services/collaboration/reducer.test.ts","./src/services/collaboration/reducer.ts","./src/services/collaboration/roomconfig.ts","./src/services/collaboration/roomlink.test.ts","./src/services/collaboration/roomlink.ts","./src/services/collaboration/runtimecontroller.test.ts","./src/services/collaboration/runtimecontroller.ts","./src/services/collaboration/runtimehookutils.test.ts","./src/services/collaboration/runtimehookutils.ts","./src/services/collaboration/schemas.ts","./src/services/collaboration/session.test.ts","./src/services/collaboration/session.ts","./src/services/collaboration/storebridge.test.ts","./src/services/collaboration/storebridge.ts","./src/services/collaboration/transport.test.ts","./src/services/collaboration/transport.ts","./src/services/collaboration/transportfactory.test.ts","./src/services/collaboration/transportfactory.ts","./src/services/collaboration/types.ts","./src/services/collaboration/versioning.test.ts","./src/services/collaboration/versioning.ts","./src/services/collaboration/yjspeertransport.test.ts","./src/services/collaboration/yjspeertransport.ts","./src/services/diagramdiff/diffengine.ts","./src/services/elk-layout/boundaryfanout.ts","./src/services/elk-layout/determinism.ts","./src/services/elk-layout/options.ts","./src/services/elk-layout/types.ts","./src/services/export/cinematicbuildplan.test.ts","./src/services/export/cinematicbuildplan.ts","./src/services/export/cinematicexport.ts","./src/services/export/cinematicexporttheme.test.ts","./src/services/export/cinematicexporttheme.ts","./src/services/export/cinematicrenderstate.test.ts","./src/services/export/cinematicrenderstate.ts","./src/services/export/formatting.ts","./src/services/export/mermaidbuilder.test.ts","./src/services/export/mermaidbuilder.ts","./src/services/export/pdfdocument.test.ts","./src/services/export/pdfdocument.ts","./src/services/export/plantumlbuilder.ts","./src/services/export/mermaid/architecturemermaid.ts","./src/services/export/mermaid/classdiagrammermaid.ts","./src/services/export/mermaid/erdiagrammermaid.ts","./src/services/export/mermaid/journeymermaid.ts","./src/services/export/mermaid/mindmapmermaid.ts","./src/services/export/mermaid/sequencemermaid.ts","./src/services/export/mermaid/statediagrammermaid.ts","./src/services/figma/edgehelpers.ts","./src/services/figma/iconhelpers.ts","./src/services/figma/nodelayers.ts","./src/services/figma/themehelpers.ts","./src/services/figmaimport/figmaapiclient.ts","./src/services/flowpilot/assetgrounding.test.ts","./src/services/flowpilot/assetgrounding.ts","./src/services/flowpilot/prompting.ts","./src/services/flowpilot/responsepolicy.test.ts","./src/services/flowpilot/responsepolicy.ts","./src/services/flowpilot/skills.ts","./src/services/flowpilot/thread.ts","./src/services/flowpilot/types.ts","./src/services/infrasync/dockercomposeparser.test.ts","./src/services/infrasync/dockercomposeparser.ts","./src/services/infrasync/infratodsl.test.ts","./src/services/infrasync/infratodsl.ts","./src/services/infrasync/kubernetesparser.test.ts","./src/services/infrasync/kubernetesparser.ts","./src/services/infrasync/terraformstateparser.test.ts","./src/services/infrasync/terraformstateparser.ts","./src/services/infrasync/types.ts","./src/services/mermaid/detectdiagramtype.test.ts","./src/services/mermaid/detectdiagramtype.ts","./src/services/mermaid/diagnosticformatting.test.ts","./src/services/mermaid/diagnosticformatting.ts","./src/services/mermaid/parsemermaidbytype.test.ts","./src/services/mermaid/parsemermaidbytype.ts","./src/services/mermaid/strictmodediagnosticspresentation.test.ts","./src/services/mermaid/strictmodediagnosticspresentation.ts","./src/services/mermaid/strictmodeguidance.test.ts","./src/services/mermaid/strictmodeguidance.ts","./src/services/mermaid/strictmodeuxregression.test.ts","./src/services/offline/registerappshellserviceworker.test.ts","./src/services/offline/registerappshellserviceworker.ts","./src/services/onboarding/config.ts","./src/services/onboarding/eventschemas.ts","./src/services/onboarding/events.test.ts","./src/services/onboarding/events.ts","./src/services/playback/contracts.test.ts","./src/services/playback/contracts.ts","./src/services/playback/model.test.ts","./src/services/playback/model.ts","./src/services/playback/studio.test.ts","./src/services/playback/studio.ts","./src/services/sequence/sequencemessage.test.ts","./src/services/sequence/sequencemessage.ts","./src/services/shapelibrary/bootstrap.test.ts","./src/services/shapelibrary/bootstrap.ts","./src/services/shapelibrary/ingestionpipeline.test.ts","./src/services/shapelibrary/ingestionpipeline.ts","./src/services/shapelibrary/manifestvalidation.test.ts","./src/services/shapelibrary/manifestvalidation.ts","./src/services/shapelibrary/providercatalog.test.ts","./src/services/shapelibrary/providercatalog.ts","./src/services/shapelibrary/registry.test.ts","./src/services/shapelibrary/registry.ts","./src/services/shapelibrary/starterpacks.ts","./src/services/shapelibrary/types.ts","./src/services/storage/fallbackstorage.ts","./src/services/storage/flowdocumentmodel.test.ts","./src/services/storage/flowdocumentmodel.ts","./src/services/storage/flowpersiststorage.test.ts","./src/services/storage/flowpersiststorage.ts","./src/services/storage/indexeddbhelpers.ts","./src/services/storage/indexeddbschema.test.ts","./src/services/storage/indexeddbschema.ts","./src/services/storage/indexeddbstatestorage.test.ts","./src/services/storage/indexeddbstatestorage.ts","./src/services/storage/localfirstrepository.ts","./src/services/storage/localfirstruntime.ts","./src/services/storage/persisteddocumentadapters.test.ts","./src/services/storage/persisteddocumentadapters.ts","./src/services/storage/persistencetypes.ts","./src/services/storage/snapshotstorage.test.ts","./src/services/storage/snapshotstorage.ts","./src/services/storage/storageruntime.test.ts","./src/services/storage/storageruntime.ts","./src/services/storage/storageschemas.test.ts","./src/services/storage/storageschemas.ts","./src/services/storage/storagetelemetry.test.ts","./src/services/storage/storagetelemetry.ts","./src/services/storage/storagetelemetrysink.test.ts","./src/services/storage/storagetelemetrysink.ts","./src/services/storage/uilocalstorage.test.ts","./src/services/storage/uilocalstorage.ts","./src/services/templatelibrary/registry.test.ts","./src/services/templatelibrary/registry.ts","./src/services/templatelibrary/startertemplates.assets.test.ts","./src/services/templatelibrary/startertemplates.test.ts","./src/services/templatelibrary/startertemplates.ts","./src/services/templatelibrary/templatefactories.ts","./src/services/templatelibrary/types.ts","./src/store/actionfactory.ts","./src/store/aisettings.test.ts","./src/store/aisettings.ts","./src/store/aisettingspersistence.ts","./src/store/aisettingsschemas.test.ts","./src/store/aisettingsschemas.ts","./src/store/canvashooks.ts","./src/store/createflowstore.test.ts","./src/store/createflowstore.ts","./src/store/createflowstorepersistoptions.ts","./src/store/createflowstorestate.ts","./src/store/defaults.ts","./src/store/designsystemhooks.ts","./src/store/documenthooks.ts","./src/store/documentstatesync.test.ts","./src/store/documentstatesync.ts","./src/store/editorpagehooks.ts","./src/store/historyhooks.ts","./src/store/historystate.ts","./src/store/persistence.test.ts","./src/store/persistence.ts","./src/store/persistenceschemas.ts","./src/store/selectionhooks.ts","./src/store/selectors.test.ts","./src/store/selectors.ts","./src/store/tabhooks.ts","./src/store/types.ts","./src/store/viewhooks.ts","./src/store/workspacedocumentmodel.test.ts","./src/store/workspacedocumentmodel.ts","./src/store/actions/createaiandselectionactions.ts","./src/store/actions/createcanvasactions.ts","./src/store/actions/createdesignsystemactions.ts","./src/store/actions/createhistoryactions.test.ts","./src/store/actions/createhistoryactions.ts","./src/store/actions/createlayeractions.ts","./src/store/actions/createtabactions.ts","./src/store/actions/createviewactions.ts","./src/store/actions/createworkspacedocumentactions.ts","./src/store/actions/synctabnodesedges.ts","./src/store/slices/createcanvaseditorslice.ts","./src/store/slices/createexperienceslice.ts","./src/store/slices/createworkspaceslice.ts"],"version":"5.8.3"} \ No newline at end of file From 66d5eb1fff2a0ab96cae79f76d78f8f48fa01031 Mon Sep 17 00:00:00 2001 From: Varun Date: Thu, 2 Apr 2026 20:41:20 +0530 Subject: [PATCH 2/9] feat: hide DSL UI and polish icon enrichment --- README.md | 100 ++- src/components/ExportMenuPanel.tsx | 12 - src/components/StudioCodePanel.tsx | 54 +- .../useCommandBarCommands.test.tsx | 5 +- .../command-bar/useCommandBarCommands.tsx | 15 +- src/hooks/useFlowEditorCallbacks.ts | 2 +- src/hooks/useFlowEditorUIState.ts | 2 +- src/lib/aiIconsPipeline.test.ts | 154 ++++ src/lib/flowmindDSLParserV2.test.ts | 185 +++-- src/lib/flowmindDSLParserV2.ts | 710 +++++++++--------- src/lib/iconMatcher.test.ts | 91 +++ src/lib/iconMatcher.ts | 201 +++++ src/lib/nodeEnricher.test.ts | 55 +- src/lib/nodeEnricher.ts | 161 ++-- src/services/export/formatting.ts | 4 +- .../export/mermaid/stateDiagramMermaid.ts | 6 +- src/services/export/mermaidBuilder.ts | 146 +++- .../export/mermaidExportQuality.test.ts | 178 +++++ src/services/exportService.ts | 4 +- src/services/flowchartRoundTrip.test.ts | 20 +- src/services/geminiSystemInstruction.ts | 39 +- .../openFlowRoundTripGoldenFixtures.ts | 68 ++ src/services/shapeLibrary/providerCatalog.ts | 369 ++++----- tsconfig.tsbuildinfo | 2 +- 24 files changed, 1702 insertions(+), 881 deletions(-) create mode 100644 src/lib/aiIconsPipeline.test.ts create mode 100644 src/lib/iconMatcher.test.ts create mode 100644 src/lib/iconMatcher.ts create mode 100644 src/services/export/mermaidExportQuality.test.ts diff --git a/README.md b/README.md index e699e634..896c063b 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ - + @@ -101,79 +101,62 @@ Every diagramming tool makes a compromise. OpenFlowKit doesn't. | **Lucidchart / Miro** | Cloud lock-in — expensive, account required, your data lives on their servers | | **PlantUML** | Server-dependent rendering — no visual editor, no local-first model | -OpenFlowKit is the **only MIT-licensed tool** that combines a real workspace home, a professional visual canvas, bidirectional diagram-as-code, AI generation from 9 providers, deterministic and AI-assisted imports, asset libraries for technical diagrams, and cinematic animated export — with zero server-side storage. +OpenFlowKit is the **only MIT-licensed tool** that combines a real workspace home, a professional visual canvas, bidirectional diagram-as-code, AI generation from 9 providers, **automatic icon assignment from 1,100+ tech icons**, and cinematic animated export — with zero server-side storage. --- ## Feature highlights -| | OpenFlowKit | Excalidraw | Draw.io | Mermaid | Lucidchart | -| ------------------------------ | :---------: | :--------: | :-----: | :-----: | :--------: | -| Visual canvas editor | ✅ | ✅ | ✅ | ❌ | ✅ | -| Bidirectional diagram-as-code | ✅ | ❌ | ❌ | ✅ | ❌ | -| AI generation (9 providers) `Beta` | ✅ | ❌ | ❌ | ❌ | Limited | -| SQL → ERD (native parser) | ✅ | ❌ | ❌ | ❌ | ❌ | -| Terraform / K8s import `Beta` | ✅ | ❌ | ❌ | ❌ | ❌ | -| AWS / Azure / GCP / CNCF icons | ✅ | ❌ | ✅ | Partial | ✅ | +| | OpenFlowKit | Excalidraw | Draw.io | Mermaid | Lucidchart | +| ------------------------------------ | :---------: | :--------: | :-----: | :-----: | :--------: | +| Visual canvas editor | ✅ | ✅ | ✅ | ❌ | ✅ | +| Bidirectional diagram-as-code | ✅ | ❌ | ❌ | ✅ | ❌ | +| AI generation (9 providers) `Beta` | ✅ | ❌ | ❌ | ❌ | Limited | +| Mermaid import (7 types) | ✅ | ❌ | ⚠️ | ✅ | ❌ | +| Auto-icon assignment (1,100+) | ✅ | ❌ | ❌ | ❌ | ❌ | +| AWS / Azure / GCP / CNCF icons | ✅ | ❌ | ✅ | Partial | ✅ | | Real-time collaboration (P2P) `Beta` | ✅ | ✅ | ❌ | ❌ | ✅ (cloud) | -| Cinematic animated export | ✅ | ❌ | ❌ | ❌ | ❌ | -| Figma export (editable SVG) | ✅ | ❌ | ❌ | ❌ | ❌ | -| No account required | ✅ | ✅ | ✅ | ✅ | ❌ | -| Open source (MIT) | ✅ | ✅ | ✅ | ✅ | ❌ | +| Cinematic animated export | ✅ | ❌ | ❌ | ❌ | ❌ | +| Figma export (editable SVG) | ✅ | ❌ | ❌ | ❌ | ❌ | +| No account required | ✅ | ✅ | ✅ | ✅ | ❌ | +| Open source (MIT) | ✅ | ✅ | ✅ | ✅ | ❌ | --- -## Code → Diagram +## Paste Mermaid → Beautiful Diagrams -Drop in your existing artifacts. Many formats are handled by **deterministic native parsers** that run entirely in your browser. AI-powered imports help when the source needs interpretation or when you want a richer first-pass architecture draft. +Paste any Mermaid flowchart, state diagram, class diagram, ER diagram, sequence diagram, mindmap, or journey. OpenFlowKit renders it on a visual canvas — and automatically assigns the correct branded icon to every technology node. -**Native parsers (no API key needed):** - -```sql -CREATE TABLE orders ( - id BIGINT PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES users(id), - status ENUM('pending','paid','shipped') NOT NULL -); +``` +flowchart TD + API[Express API] --> DB[(PostgreSQL)] + DB --> Cache[Redis Cache] + Cache --> Queue[RabbitMQ] ``` -→ Typed ERD with inferred foreign-key edges and cardinalities. Rendered in milliseconds, no server involved. +Paste this → you get the Express wordmark, PostgreSQL elephant, Redis logo, and RabbitMQ icon — all auto-detected, all beautifully laid out. No other tool does this. -```yaml -# deployment.yaml -apiVersion: apps/v1 -kind: Deployment -spec: - replicas: 3 ---- -apiVersion: v1 -kind: Service -selector: - app: api ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -spec: - rules: - - host: api.example.com -``` +**1,100+ icons** from developer, AWS, Azure, CNCF, and GCP catalogs are matched automatically based on node labels. No manual drag-and-drop. No configuration. + +### How it works + +1. **Paste Mermaid** on the canvas or in the code panel +2. **Semantic classifier** detects technology names (PostgreSQL, Redis, Express, Lambda, etc.) +3. **Icon matcher** searches 1,100+ icons across all catalogs — exact match, then alias, then substring +4. **Enricher** assigns colors, icons, and provider SVGs to every node +5. **ELK layout** arranges everything cleanly -→ Kubernetes architecture with Deployment → Service → Ingress connections. +### AI generation (API key required) -**AI-powered imports (API key required):** +Describe your system in plain English. AI generates a diagram on the canvas with correct icons applied automatically. -Paste source code, infrastructure, or API specs and hit generate — the diagram lands directly on your canvas. AWS, Azure, GCP, and CNCF icons are automatically applied when the AI detects cloud services in your input. +| Prompt | Output | +| ----------------------------------------- | --------------------------------------- | +| "Node.js API with PostgreSQL and Redis" | 3 nodes with correct icons | +| "AWS Lambda → SQS → DynamoDB" | 3 nodes with AWS icons | +| "React frontend → Express → MongoDB → S3" | 4 nodes across developer + AWS catalogs | -| Source | Engine | API key? | -| ----------------------------------- | ------------------------- | :------: | -| SQL DDL | **Native parser** | **No** | -| Terraform `.tfstate` | **Native parser** | **No** | -| Terraform HCL | AI-assisted | Yes | -| Kubernetes YAML / Helm | **Native parser** | **No** | -| OpenAPI / Swagger YAML/JSON | **Native parser** | **No** | -| OpenAPI source text → richer flow | AI-assisted | Yes | -| Source code (single file) | AI-assisted · 9 languages | Yes | -| Mermaid | **Native parser** | **No** | +9 providers supported: Google Gemini, OpenAI, Anthropic Claude, Groq, Mistral, NVIDIA NIM, Cerebras, OpenRouter, or any custom OpenAI-compatible endpoint. --- @@ -224,9 +207,10 @@ flowchart TB auth --> db ``` -- Mermaid-compatible syntax +- Mermaid-compatible syntax — paste any Mermaid and it renders with auto-assigned icons +- Specify icons directly: `{ archProvider: "developer", archResourceType: "database-postgresql" }` +- Auto-icon resolution: nodes are enriched with the correct branded icon based on their label - Export to Mermaid, PlantUML, or JSON -- Paste any Mermaid diagram and it renders immediately - Version snapshots — restore any previous state --- diff --git a/src/components/ExportMenuPanel.tsx b/src/components/ExportMenuPanel.tsx index 35ea77d7..d71d820b 100644 --- a/src/components/ExportMenuPanel.tsx +++ b/src/components/ExportMenuPanel.tsx @@ -11,10 +11,8 @@ import { GitBranch, Image, Share2, - Wand2, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { APP_NAME } from '@/lib/brand'; import { type CinematicExportResolution, type CinematicExportSpeed, @@ -177,16 +175,6 @@ export function ExportMenuPanel({ Icon: FileJson, actions: ['download', 'copy'], }, - { - key: 'openflow', - label: t('export.openflowdslLabel', { - appName: APP_NAME, - defaultValue: `${APP_NAME} DSL`, - }), - hint: t('export.actionCopy', 'Copy'), - Icon: Wand2, - actions: ['download', 'copy'], - }, { key: 'mermaid', label: t('export.mermaid', 'Mermaid'), diff --git a/src/components/StudioCodePanel.tsx b/src/components/StudioCodePanel.tsx index e35d0b43..a4ff5762 100644 --- a/src/components/StudioCodePanel.tsx +++ b/src/components/StudioCodePanel.tsx @@ -1,9 +1,7 @@ import React, { useRef } from 'react'; import { AlertCircle, - BookOpen, CheckCircle2, - CircleHelp, Play, RotateCcw, Zap, @@ -12,7 +10,7 @@ import { useTranslation } from 'react-i18next'; import { Button } from './ui/Button'; import { Textarea } from './ui/Textarea'; import { useFlowStore } from '@/store'; -import { APP_NAME, IS_BEVELED } from '@/lib/brand'; +import { IS_BEVELED } from '@/lib/brand'; import { useMermaidDiagnosticsActions } from '@/store/selectionHooks'; import { useToast } from './ui/ToastContext'; import type { FlowEdge, FlowNode } from '@/lib/types'; @@ -21,7 +19,6 @@ import { useStudioCodePanelController, type DraftPreviewState, } from './studio-code-panel/useStudioCodePanelController'; -import { Tooltip } from './Tooltip'; interface CodeModeOption { id: StudioCodeMode; @@ -29,7 +26,6 @@ interface CodeModeOption { } const MODE_OPTIONS: CodeModeOption[] = [ - { id: 'openflow', label: `${APP_NAME} DSL` }, { id: 'mermaid', label: 'Mermaid' }, ]; @@ -168,28 +164,16 @@ export function StudioCodePanel({ ))} - {mode === 'mermaid' ? ( -
- -
- ) : ( - - - - )} +
+ +
@@ -204,7 +188,7 @@ export function StudioCodePanel({ placeholder={ mode === 'mermaid' ? t('commandBar.code.mermaidPlaceholder') - : t('commandBar.code.dslPlaceholder', { appName: APP_NAME }) + : t('commandBar.code.dslPlaceholder') } /> @@ -260,19 +244,7 @@ export function StudioCodePanel({ ) : null} - {mode === 'openflow' ? ( - - - {t('commandBar.code.syntaxGuide')} - - ) : ( -
- )} +
{ 'search-nodes', 'layout', 'architecture-rules', - 'studio-openflow', 'studio-mermaid', 'toggle-grid', 'toggle-snap', @@ -50,16 +49,14 @@ describe('useCommandBarCommands', () => { expect(result.current.find((command) => command.id === 'templates')?.tier).toBe('core'); expect(result.current.find((command) => command.id === 'layout')?.tier).toBe('core'); expect(result.current.find((command) => command.id === 'assets')?.tier).toBe('advanced'); - expect(result.current.find((command) => command.id === 'studio-openflow')?.tier).toBe('advanced'); result.current.find((command) => command.id === 'studio-ai')?.action?.(); result.current.find((command) => command.id === 'architecture-rules')?.action?.(); - result.current.find((command) => command.id === 'studio-openflow')?.action?.(); result.current.find((command) => command.id === 'studio-mermaid')?.action?.(); expect(onOpenStudioAI).toHaveBeenCalledTimes(1); expect(onOpenArchitectureRules).toHaveBeenCalledTimes(1); - expect(onOpenStudioOpenFlow).toHaveBeenCalledTimes(1); + expect(onOpenStudioOpenFlow).toHaveBeenCalledTimes(0); expect(onOpenStudioMermaid).toHaveBeenCalledTimes(1); }); }); diff --git a/src/components/command-bar/useCommandBarCommands.tsx b/src/components/command-bar/useCommandBarCommands.tsx index 5e61b6e3..bf7e274e 100644 --- a/src/components/command-bar/useCommandBarCommands.tsx +++ b/src/components/command-bar/useCommandBarCommands.tsx @@ -3,7 +3,6 @@ import { ArrowRight, Code2, Compass, - FileCode, Import, Search, Settings, @@ -12,7 +11,7 @@ import { Workflow, } from 'lucide-react'; import { useFlowStore } from '@/store'; -import { APP_NAME, FLOWPILOT_NAME } from '@/lib/brand'; +import { FLOWPILOT_NAME } from '@/lib/brand'; import type { CommandItem, CommandBarProps } from './types'; import { AssetsIcon } from '../icons/AssetsIcon'; @@ -32,7 +31,7 @@ export function useCommandBarCommands({ onUndo, onRedo, onOpenStudioAI, - onOpenStudioOpenFlow, + onOpenStudioOpenFlow: _onOpenStudioOpenFlow, onOpenStudioMermaid, onOpenArchitectureRules, hasImport = false, @@ -136,15 +135,6 @@ export function useCommandBarCommands({ description: 'Open architecture guardrails and rule templates', action: onOpenArchitectureRules, }, - { - id: 'studio-openflow', - label: 'Edit Flow DSL', - icon: , - tier: 'advanced', - type: 'action', - description: `Open ${APP_NAME} DSL in Studio`, - action: onOpenStudioOpenFlow, - }, { id: 'studio-mermaid', label: 'Edit Mermaid Code', @@ -194,7 +184,6 @@ export function useCommandBarCommands({ onOpenArchitectureRules, onOpenStudioAI, onOpenStudioMermaid, - onOpenStudioOpenFlow, onRedo, onUndo, settings, diff --git a/src/hooks/useFlowEditorCallbacks.ts b/src/hooks/useFlowEditorCallbacks.ts index 0903a26c..7f462b23 100644 --- a/src/hooks/useFlowEditorCallbacks.ts +++ b/src/hooks/useFlowEditorCallbacks.ts @@ -1,5 +1,6 @@ import { startTransition, useCallback, useRef } from 'react'; import type { FlowEdge, FlowNode, FlowSnapshot } from '@/lib/types'; +import { enrichNodesWithIcons } from '@/lib/nodeEnricher'; import { useFlowStore } from '@/store'; import { composeDiagramForDisplay } from '@/services/composeDiagramForDisplay'; @@ -110,7 +111,6 @@ export function useFlowEditorCallbacks({ const handleCommandBarApply = useCallback( async (newNodes: FlowNode[], newEdges: FlowEdge[]) => { - const { enrichNodesWithIcons } = await import('@/lib/nodeEnricher'); const enrichedNodes = await enrichNodesWithIcons(newNodes); recordHistory(); startTransition(() => { diff --git a/src/hooks/useFlowEditorUIState.ts b/src/hooks/useFlowEditorUIState.ts index d3ff84d6..f20be1a6 100644 --- a/src/hooks/useFlowEditorUIState.ts +++ b/src/hooks/useFlowEditorUIState.ts @@ -42,7 +42,7 @@ export function useFlowEditorUIState(): UseFlowEditorUIStateResult { const [commandBarView, setCommandBarView] = useState('root'); const [editorMode, setEditorMode] = useState('canvas'); const [studioTab, setStudioTab] = useState('ai'); - const [studioCodeMode, setStudioCodeMode] = useState('openflow'); + const [studioCodeMode, setStudioCodeMode] = useState('mermaid'); const [isSelectMode, setIsSelectMode] = useState(true); const [isArchitectureRulesOpen, setIsArchitectureRulesOpen] = useState(false); diff --git a/src/lib/aiIconsPipeline.test.ts b/src/lib/aiIconsPipeline.test.ts new file mode 100644 index 00000000..6a08ad38 --- /dev/null +++ b/src/lib/aiIconsPipeline.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import { parseOpenFlowDslV2 } from './flowmindDSLParserV2'; +import { enrichNodesWithIcons } from './nodeEnricher'; + +// These test the full pipeline: AI-generated DSL → parse → enrich → correct icons +// Simulates what happens when AI outputs DSL with archProvider/archResourceType + +describe('AI + Icons Pipeline (E2E)', () => { + it('Node.js API with PostgreSQL and Redis', async () => { + const dsl = ` + flow: Node.js Stack + direction: TB + + [system] api: Express API { archProvider: "developer", archResourceType: "others-expressjs-dark", color: "blue" } + [system] db: PostgreSQL { archProvider: "developer", archResourceType: "database-postgresql", color: "violet" } + [system] cache: Redis { archProvider: "developer", archResourceType: "database-redis", color: "red" } + + api ->|SQL| db + api ->|cache| cache + `; + + const parsed = parseOpenFlowDslV2(dsl); + const enriched = await enrichNodesWithIcons(parsed.nodes); + + expect(enriched).toHaveLength(3); + + const api = enriched.find((n) => n.id === 'api'); + expect(api?.data.archIconPackId).toBe('developer-icons-v1'); + expect(api?.data.archIconShapeId).toBe('others-expressjs-dark'); + + const db = enriched.find((n) => n.id === 'db'); + expect(db?.data.archIconPackId).toBe('developer-icons-v1'); + expect(db?.data.archIconShapeId).toBe('database-postgresql'); + + const cache = enriched.find((n) => n.id === 'cache'); + expect(cache?.data.archIconPackId).toBe('developer-icons-v1'); + expect(cache?.data.archIconShapeId).toContain('redis'); + }); + + it('AWS Lambda → SQS → DynamoDB', async () => { + const dsl = ` + flow: Serverless Pipeline + direction: TB + + [architecture] lambda: Lambda { archProvider: "aws", archResourceType: "compute-lambda", color: "violet" } + [architecture] sqs: SQS Queue { archProvider: "aws", archResourceType: "app-integration-sqs", color: "amber" } + [architecture] dynamo: DynamoDB { archProvider: "aws", archResourceType: "database-dynamodb", color: "emerald" } + + lambda ->|publish| sqs + sqs ->|write| dynamo + `; + + const parsed = parseOpenFlowDslV2(dsl); + const enriched = await enrichNodesWithIcons(parsed.nodes); + + expect(enriched).toHaveLength(3); + + for (const node of enriched) { + expect(node.data.archIconPackId).toBe('aws-official-starter-v1'); + expect(node.data.archIconShapeId).toBeTruthy(); + } + }); + + it('React → Express → MongoDB → S3 (mixed stacks)', async () => { + const dsl = ` + flow: Full Stack + direction: TB + + [system] react: React App { archProvider: "developer", archResourceType: "frontend-react", color: "blue" } + [system] api: Express { archProvider: "developer", archResourceType: "others-expressjs-dark", color: "violet" } + [system] mongo: MongoDB { archProvider: "developer", archResourceType: "database-mongodb", color: "emerald" } + [architecture] s3: S3 Storage { archProvider: "aws", archResourceType: "storage-s3", color: "amber" } + + react ->|HTTP| api + api ->|query| mongo + api ->|upload| s3 + `; + + const parsed = parseOpenFlowDslV2(dsl); + const enriched = await enrichNodesWithIcons(parsed.nodes); + + expect(enriched).toHaveLength(4); + + const react = enriched.find((n) => n.id === 'react'); + expect(react?.data.archIconPackId).toBe('developer-icons-v1'); + expect(react?.data.color).toBe('blue'); + + const s3 = enriched.find((n) => n.id === 's3'); + expect(s3?.data.archIconPackId).toBe('aws-official-starter-v1'); + }); + + it('auto-enriches nodes without explicit icons (icons: auto behavior)', async () => { + const dsl = ` + flow: Auto Icons + direction: TB + + [system] api: Express API + [system] db: PostgreSQL Database + [system] cache: Redis Cache + `; + + const parsed = parseOpenFlowDslV2(dsl); + const enriched = await enrichNodesWithIcons(parsed.nodes); + + // Without explicit archProvider, enricher should match by label + const api = enriched.find((n) => n.id === 'api'); + expect(api?.data.archIconPackId).toBeTruthy(); + expect(api?.data.color).toBe('blue'); + + const db = enriched.find((n) => n.id === 'db'); + expect(db?.data.color).toBe('violet'); + }); + + it('enricher does not overwrite AI-set provider icons', async () => { + const dsl = ` + [architecture] lambda: My Lambda { archProvider: "aws", archResourceType: "compute-lambda", color: "violet" } + `; + + const parsed = parseOpenFlowDslV2(dsl); + const enriched = await enrichNodesWithIcons(parsed.nodes); + + const lambda = enriched.find((n) => n.id === 'lambda'); + expect(lambda?.data.archIconPackId).toBe('aws-official-starter-v1'); + expect(lambda?.data.archIconShapeId).toBe('compute-lambda'); + expect(lambda?.data.color).toBe('violet'); + }); + + it('enriches architecture-beta imported nodes', async () => { + const dsl = ` + flow: Architecture + direction: TB + + [architecture] server: Express.js { color: "violet" } + [architecture] db: PostgreSQL { color: "violet" } + [architecture] cache: Redis { color: "red" } + + server ->|query| db + server ->|cache| cache + `; + + const parsed = parseOpenFlowDslV2(dsl); + const enriched = await enrichNodesWithIcons(parsed.nodes); + + const server = enriched.find((n) => n.id === 'server'); + expect(server?.data.archIconPackId).toBeTruthy(); + expect(server?.data.color).toBe('violet'); + + const db = enriched.find((n) => n.id === 'db'); + expect(db?.data.archIconPackId).toBeTruthy(); + + const cache = enriched.find((n) => n.id === 'cache'); + expect(cache?.data.archIconPackId).toBeTruthy(); + }); +}); diff --git a/src/lib/flowmindDSLParserV2.test.ts b/src/lib/flowmindDSLParserV2.test.ts index 57414fef..0eeb1407 100644 --- a/src/lib/flowmindDSLParserV2.test.ts +++ b/src/lib/flowmindDSLParserV2.test.ts @@ -1,93 +1,156 @@ - import { describe, it, expect } from 'vitest'; import { parseOpenFlowDslV2 } from './openFlowDslParserV2'; describe('OpenFlow DSL V2 Parser', () => { - it('parses basic nodes and edges', () => { - const input = ` + it('parses basic nodes and edges', () => { + const input = ` [start] Start [process] Step 1 [end] End Start -> Step 1 Step 1 -> End `; - const result = parseOpenFlowDslV2(input); - expect(result.nodes).toHaveLength(3); - expect(result.edges).toHaveLength(2); + const result = parseOpenFlowDslV2(input); + expect(result.nodes).toHaveLength(3); + expect(result.edges).toHaveLength(2); - const startNode = result.nodes.find(n => n.data.label === 'Start'); - expect(startNode).toBeDefined(); - expect(startNode?.type).toBe('start'); - }); + const startNode = result.nodes.find((n) => n.data.label === 'Start'); + expect(startNode).toBeDefined(); + expect(startNode?.type).toBe('start'); + }); - it('parses explicit IDs', () => { - const input = ` + it('parses explicit IDs', () => { + const input = ` [process] p1: Process One [process] p2: Process Two p1 -> p2 `; - const result = parseOpenFlowDslV2(input); - expect(result.nodes).toHaveLength(2); + const result = parseOpenFlowDslV2(input); + expect(result.nodes).toHaveLength(2); - const p1 = result.nodes.find(n => n.id === 'p1'); - expect(p1).toBeDefined(); - expect(p1?.data.label).toBe('Process One'); + const p1 = result.nodes.find((n) => n.id === 'p1'); + expect(p1).toBeDefined(); + expect(p1?.data.label).toBe('Process One'); - const edge = result.edges[0]; - expect(edge.source).toBe('p1'); - expect(edge.target).toBe('p2'); - }); + const edge = result.edges[0]; + expect(edge.source).toBe('p1'); + expect(edge.target).toBe('p2'); + }); - it('parses attributes', () => { - const input = ` + it('parses attributes', () => { + const input = ` [process] p1: Configured Node { color: "red", icon: "settings" } p1 -> p2 { style: "dashed", label: "async" } `; - const result = parseOpenFlowDslV2(input); - - const p1 = result.nodes.find(n => n.id === 'p1'); - expect(p1?.data.color).toBe('red'); - expect(p1?.data.icon).toBe('settings'); - - const edge = result.edges[0]; - expect(edge.data?.styleType).toBe('dashed'); // Attributes merged into edge data/attributes? Parser logic puts it in edge helper or data? - // Checking parser implementation: - // dslEdges.push({ ..., attributes }) - // finalEdges.push(createDefaultEdge(..., attributes/label?)) - // Expecting createDefaultEdge to handle it or we need to check how it's mapped. - // In parser implementation: - // createDefaultEdge(source, target, label, id) - // Wait, I missed passing attributes to createDefaultEdge in my implementation! - - // Let's check the implementation again. - }); - - it('parses quoted attribute values containing commas, colons, and escapes', () => { - const input = ` + const result = parseOpenFlowDslV2(input); + + const p1 = result.nodes.find((n) => n.id === 'p1'); + expect(p1?.data.color).toBe('red'); + expect(p1?.data.icon).toBe('settings'); + + const edge = result.edges[0]; + expect(edge.data?.styleType).toBe('dashed'); // Attributes merged into edge data/attributes? Parser logic puts it in edge helper or data? + // Checking parser implementation: + // dslEdges.push({ ..., attributes }) + // finalEdges.push(createDefaultEdge(..., attributes/label?)) + // Expecting createDefaultEdge to handle it or we need to check how it's mapped. + // In parser implementation: + // createDefaultEdge(source, target, label, id) + // Wait, I missed passing attributes to createDefaultEdge in my implementation! + + // Let's check the implementation again. + }); + + it('parses quoted attribute values containing commas, colons, and escapes', () => { + const input = ` [process] p1: Configured Node { icon: "server, api", note: "http://svc:8080/path", enabled: true, retries: 3, quote: "say \\"hello\\"" } `; - const result = parseOpenFlowDslV2(input); - - const p1 = result.nodes.find((node) => node.id === 'p1'); - expect(p1?.data.icon).toBe('server, api'); - expect(p1?.data.note).toBe('http://svc:8080/path'); - expect(p1?.data.enabled).toBe(true); - expect(p1?.data.retries).toBe(3); - expect(p1?.data.quote).toBe('say "hello"'); - }); - - it('ignores group wrappers and keeps inner nodes flat', () => { - const input = ` + const result = parseOpenFlowDslV2(input); + + const p1 = result.nodes.find((node) => node.id === 'p1'); + expect(p1?.data.icon).toBe('server, api'); + expect(p1?.data.note).toBe('http://svc:8080/path'); + expect(p1?.data.enabled).toBe(true); + expect(p1?.data.retries).toBe(3); + expect(p1?.data.quote).toBe('say "hello"'); + }); + + it('ignores group wrappers and keeps inner nodes flat', () => { + const input = ` group "Backend" { [process] api: API [database] db: DB api -> db } `; - const result = parseOpenFlowDslV2(input); - expect(result.nodes).toHaveLength(2); + const result = parseOpenFlowDslV2(input); + expect(result.nodes).toHaveLength(2); + + const api = result.nodes.find((n) => n.id === 'api'); + expect(api?.parentId).toBeUndefined(); + }); + + it('maps archProvider/archResourceType to archIconPackId/archIconShapeId', () => { + const input = ` + [system] db: PostgreSQL { archProvider: "developer", archResourceType: "database-postgresql", color: "violet" } + [system] api: Express API { archProvider: "developer", archResourceType: "others-expressjs-dark" } + `; + const result = parseOpenFlowDslV2(input); + expect(result.nodes).toHaveLength(2); + + const db = result.nodes.find((n) => n.id === 'db'); + expect(db?.data.archIconPackId).toBe('developer-icons-v1'); + expect(db?.data.archIconShapeId).toBe('database-postgresql'); + expect(db?.data.assetPresentation).toBe('icon'); - const api = result.nodes.find(n => n.id === 'api'); - expect(api?.parentId).toBeUndefined(); - }); + const api = result.nodes.find((n) => n.id === 'api'); + expect(api?.data.archIconPackId).toBe('developer-icons-v1'); + expect(api?.data.archIconShapeId).toBe('others-expressjs-dark'); + }); + + it('passes provider attribute through to node data', () => { + const input = ` + [architecture] lambda: API Lambda { archProvider: "aws", archResourceType: "compute-lambda", color: "violet" } + [architecture] rds: Database { provider: "aws", color: "violet" } + `; + const result = parseOpenFlowDslV2(input); + expect(result.nodes).toHaveLength(2); + + const lambda = result.nodes.find((n) => n.id === 'lambda'); + expect(lambda?.data.archIconPackId).toBe('aws-official-starter-v1'); + expect(lambda?.data.archIconShapeId).toBe('compute-lambda'); + + const rds = result.nodes.find((n) => n.id === 'rds'); + expect(rds?.data.provider).toBe('aws'); + }); + + it('passes icon attribute for catalog search', () => { + const input = ` + [system] cache: Redis Cache { icon: "redis", color: "red" } + `; + const result = parseOpenFlowDslV2(input); + const cache = result.nodes.find((n) => n.id === 'cache'); + expect(cache?.data.icon).toBe('redis'); + }); + + it('maps [architecture] to custom node type', () => { + const input = ` + [architecture] lambda: Lambda { archProvider: "aws", archResourceType: "compute-lambda" } + `; + const result = parseOpenFlowDslV2(input); + const lambda = result.nodes.find((n) => n.id === 'lambda'); + expect(lambda?.type).toBe('custom'); + }); + + it('accepts icons: auto header in metadata', () => { + const input = ` + flow: My Architecture + direction: TB + icons: auto + [system] api: API + `; + const result = parseOpenFlowDslV2(input); + expect(result.metadata.icons).toBe('auto'); + expect(result.nodes).toHaveLength(1); + }); }); diff --git a/src/lib/flowmindDSLParserV2.ts b/src/lib/flowmindDSLParserV2.ts index ab636984..ce680b40 100644 --- a/src/lib/flowmindDSLParserV2.ts +++ b/src/lib/flowmindDSLParserV2.ts @@ -1,30 +1,35 @@ import { setNodeParent } from './nodeParent'; import { NODE_DEFAULTS } from '../theme'; import type { FlowEdge, FlowNode, NodeData } from './types'; +import { KNOWN_PROVIDER_PACK_IDS } from '@/services/shapeLibrary/providerCatalog'; + +function resolveArchPackId(provider: string): string { + return KNOWN_PROVIDER_PACK_IDS[provider.toLowerCase()] ?? `${provider}-processed-pack-v1`; +} // --- Types --- export interface DSLNode { - id: string; - type: string; - label: string; - parentId?: string; - attributes: Record; + id: string; + type: string; + label: string; + parentId?: string; + attributes: Record; } export interface DSLEdge { - sourceId: string; - targetId: string; - label?: string; - attributes: Record; - type?: 'default' | 'step' | 'smoothstep' | 'straight'; + sourceId: string; + targetId: string; + label?: string; + attributes: Record; + type?: 'default' | 'step' | 'smoothstep' | 'straight'; } export interface DSLResult { - nodes: FlowNode[]; - edges: FlowEdge[]; - metadata: Record; - errors: string[]; + nodes: FlowNode[]; + edges: FlowEdge[]; + metadata: Record; + errors: string[]; } type DSLAttributeValue = string | number | boolean; @@ -32,373 +37,372 @@ type DSLAttributeValue = string | number | boolean; // --- Constants --- const NODE_TYPE_MAP: Record = { - start: 'start', - process: 'process', - decision: 'decision', - end: 'end', - system: 'custom', - note: 'annotation', - section: 'process', - group: 'process', - browser: 'browser', - mobile: 'mobile', - container: 'container', // New generic container + start: 'start', + process: 'process', + decision: 'decision', + end: 'end', + system: 'custom', + note: 'annotation', + section: 'process', + group: 'process', + browser: 'browser', + mobile: 'mobile', + container: 'container', + architecture: 'custom', }; // --- Helpers --- function parseAttributes(text: string): Record { - const attributes: Record = {}; - if (!text) return attributes; - - const content = text.trim(); - if (!content.startsWith('{') || !content.endsWith('}')) return attributes; - - const inner = content.slice(1, -1); - const pairs: string[] = []; - let buffer = ''; - let quote: '"' | "'" | null = null; - let escaping = false; - - for (const char of inner) { - if (escaping) { - buffer += char; - escaping = false; - continue; - } - - if (char === '\\') { - buffer += char; - escaping = true; - continue; - } - - if (quote) { - buffer += char; - if (char === quote) { - quote = null; - } - continue; - } + const attributes: Record = {}; + if (!text) return attributes; + + const content = text.trim(); + if (!content.startsWith('{') || !content.endsWith('}')) return attributes; + + const inner = content.slice(1, -1); + const pairs: string[] = []; + let buffer = ''; + let quote: '"' | "'" | null = null; + let escaping = false; + + for (const char of inner) { + if (escaping) { + buffer += char; + escaping = false; + continue; + } - if (char === '"' || char === "'") { - quote = char; - buffer += char; - continue; - } + if (char === '\\') { + buffer += char; + escaping = true; + continue; + } - if (char === ',') { - const pair = buffer.trim(); - if (pair) pairs.push(pair); - buffer = ''; - continue; - } + if (quote) { + buffer += char; + if (char === quote) { + quote = null; + } + continue; + } - buffer += char; + if (char === '"' || char === "'") { + quote = char; + buffer += char; + continue; } - const trailingPair = buffer.trim(); - if (trailingPair) { - pairs.push(trailingPair); + if (char === ',') { + const pair = buffer.trim(); + if (pair) pairs.push(pair); + buffer = ''; + continue; } - pairs.forEach((pair) => { - let colonIndex = -1; - let pairQuote: '"' | "'" | null = null; - let pairEscaping = false; - - for (let index = 0; index < pair.length; index += 1) { - const char = pair[index]; - - if (pairEscaping) { - pairEscaping = false; - continue; - } - - if (char === '\\') { - pairEscaping = true; - continue; - } - - if (pairQuote) { - if (char === pairQuote) { - pairQuote = null; - } - continue; - } - - if (char === '"' || char === "'") { - pairQuote = char; - continue; - } - - if (char === ':') { - colonIndex = index; - break; - } - } + buffer += char; + } + + const trailingPair = buffer.trim(); + if (trailingPair) { + pairs.push(trailingPair); + } + + pairs.forEach((pair) => { + let colonIndex = -1; + let pairQuote: '"' | "'" | null = null; + let pairEscaping = false; + + for (let index = 0; index < pair.length; index += 1) { + const char = pair[index]; + + if (pairEscaping) { + pairEscaping = false; + continue; + } - if (colonIndex <= 0) return; - - const key = pair.slice(0, colonIndex).trim(); - const rawValue = pair.slice(colonIndex + 1).trim(); - if (!key || !rawValue) return; - - let value: DSLAttributeValue = rawValue; - if ( - (value.startsWith('"') && value.endsWith('"')) - || (value.startsWith("'") && value.endsWith("'")) - ) { - value = value - .slice(1, -1) - .replace(/\\(["'])/g, '$1') - .replace(/\\\\/g, '\\'); - } else if (!Number.isNaN(Number(value))) { - value = Number(value); - } else if (value === 'true') { - value = true; - } else if (value === 'false') { - value = false; + if (char === '\\') { + pairEscaping = true; + continue; + } + + if (pairQuote) { + if (char === pairQuote) { + pairQuote = null; } + continue; + } + + if (char === '"' || char === "'") { + pairQuote = char; + continue; + } + + if (char === ':') { + colonIndex = index; + break; + } + } - attributes[key] = value; - }); + if (colonIndex <= 0) return; + + const key = pair.slice(0, colonIndex).trim(); + const rawValue = pair.slice(colonIndex + 1).trim(); + if (!key || !rawValue) return; + + let value: DSLAttributeValue = rawValue; + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value + .slice(1, -1) + .replace(/\\(["'])/g, '$1') + .replace(/\\\\/g, '\\'); + } else if (!Number.isNaN(Number(value))) { + value = Number(value); + } else if (value === 'true') { + value = true; + } else if (value === 'false') { + value = false; + } - return attributes; -}; + attributes[key] = value; + }); + + return attributes; +} // --- Parser --- export function parseOpenFlowDslV2(input: string): DSLResult { - const dslNodes: DSLNode[] = []; - const dslEdges: DSLEdge[] = []; - const metadata: Record = { direction: 'TB' }; - const errors: string[] = []; - - const lines = input.split('\n'); - const currentGroupStack: string[] = []; - - // First pass: symbols and structure - // We need map label -> ID for implicit IDs - const labelToIdMap = new Map(); - - lines.forEach((rawLine, lineIndex) => { - const line = rawLine.trim(); - if (!line || line.startsWith('#')) return; - - // 1. Metadata: key: value - const metadataMatch = line.match(/^([a-zA-Z0-9_]+):\s*(.+)$/); - // Avoid matching "label: value" inside node defs, heuristic: no brackets, no arrow - if (metadataMatch && !line.includes('[') && !line.includes('->')) { - const key = metadataMatch[1].toLowerCase(); - let value = metadataMatch[2].trim(); - // Strip quotes if present - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1); - } - metadata[key] = value; - return; - } - - // 2. Groups Start: group "Label" { - const groupStartMatch = line.match(/^group\s+"?([^"{]+)"?\s*\{$/); - if (groupStartMatch) { - currentGroupStack.push(groupStartMatch[1]); - return; - } + const dslNodes: DSLNode[] = []; + const dslEdges: DSLEdge[] = []; + const metadata: Record = { direction: 'TB' }; + const errors: string[] = []; + + const lines = input.split('\n'); + const currentGroupStack: string[] = []; + + // First pass: symbols and structure + // We need map label -> ID for implicit IDs + const labelToIdMap = new Map(); + + lines.forEach((rawLine, lineIndex) => { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) return; + + // 1. Metadata: key: value + const metadataMatch = line.match(/^([a-zA-Z0-9_]+):\s*(.+)$/); + // Avoid matching "label: value" inside node defs, heuristic: no brackets, no arrow + if (metadataMatch && !line.includes('[') && !line.includes('->')) { + const key = metadataMatch[1].toLowerCase(); + let value = metadataMatch[2].trim(); + // Strip quotes if present + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + metadata[key] = value; + return; + } - // 3. Group End: } - if (line === '}') { - if (currentGroupStack.length > 0) { - currentGroupStack.pop(); - } else { - errors.push(`Line ${lineIndex + 1}: Unexpected '}'`); - } - return; - } + // 2. Groups Start: group "Label" { + const groupStartMatch = line.match(/^group\s+"?([^"{]+)"?\s*\{$/); + if (groupStartMatch) { + currentGroupStack.push(groupStartMatch[1]); + return; + } - // 4. Edges: A -> B { attrs } - // regex: (source) (arrow) (target) (attrs?) - const edgeMatch = line.match(/^(.+?)\s*(->|-->|\.\.>|==>)\s*(.+?)(\s*\{.*\})?$/); - if (edgeMatch) { - // Note: We intentionally catch lines starting with '[' here if they have an arrow. - // This handles cases where AI mistakenly writes "[type] Node -> Node". - - const [, sourceRaw, arrow, targetRaw, attrsRaw] = edgeMatch; - - // Helper to clean potential [type] prefixes from IDs in edges - const cleanId = (raw: string) => { - const typeMatch = raw.match(/^\[.*?\]\s*(.+)$/); - return (typeMatch ? typeMatch[1] : raw).trim(); - }; - - // Extract labels/IDs from potential piped text: Source ->|Label| Target - // Re-parsing source/target for piped labels if valid arrow syntax - // "A ->|yes| B" - const source = cleanId(sourceRaw.trim()); - let targetRawTrimmed = targetRaw.trim(); - let label = ''; - - const pipeMatch = targetRawTrimmed.match(/^\|([^|]+)\|\s*(.+)$/); - if (pipeMatch) { - label = pipeMatch[1]; - targetRawTrimmed = pipeMatch[2].trim(); - } - const target = cleanId(targetRawTrimmed); - - // Attributes - const attributes = parseAttributes(attrsRaw || ''); - - // Arrow styling - if (arrow === '-->') attributes.styleType = 'curved'; - if (arrow === '..>') attributes.styleType = 'dashed'; - if (arrow === '==>') attributes.styleType = 'thick'; - - dslEdges.push({ - sourceId: source, // Resolved later - targetId: target, // Resolved later - label, - attributes - }); - return; - } + // 3. Group End: } + if (line === '}') { + if (currentGroupStack.length > 0) { + currentGroupStack.pop(); + } else { + errors.push(`Line ${lineIndex + 1}: Unexpected '}'`); + } + return; + } - // 5. Nodes: [type] id: Label { attrs } - const nodeMatch = line.match(/^\[([a-zA-Z0-9_]+)\]\s*(?:([a-zA-Z0-9_]+):\s*)?([^{]+)(\s*\{.*\})?$/); - if (nodeMatch) { - const [, typeRaw, idRaw, labelRaw, attrsRaw] = nodeMatch; - const type = NODE_TYPE_MAP[typeRaw.toLowerCase()] || 'process'; - const label = labelRaw.trim(); - const id = idRaw ? idRaw.trim() : label; // If no explicit ID, use label (backward compact) - - const attributes = parseAttributes(attrsRaw || ''); - - const node: DSLNode = { - id, - type, - label, - attributes, - parentId: undefined - }; - - dslNodes.push(node); - labelToIdMap.set(label, id); // Map label to ID for edge resolution - labelToIdMap.set(id, id); // Map ID to ID - return; - } + // 4. Edges: A -> B { attrs } + // regex: (source) (arrow) (target) (attrs?) + const edgeMatch = line.match(/^(.+?)\s*(->|-->|\.\.>|==>)\s*(.+?)(\s*\{.*\})?$/); + if (edgeMatch) { + // Note: We intentionally catch lines starting with '[' here if they have an arrow. + // This handles cases where AI mistakenly writes "[type] Node -> Node". + + const [, sourceRaw, arrow, targetRaw, attrsRaw] = edgeMatch; + + // Helper to clean potential [type] prefixes from IDs in edges + const cleanId = (raw: string) => { + const typeMatch = raw.match(/^\[.*?\]\s*(.+)$/); + return (typeMatch ? typeMatch[1] : raw).trim(); + }; + + // Extract labels/IDs from potential piped text: Source ->|Label| Target + // Re-parsing source/target for piped labels if valid arrow syntax + // "A ->|yes| B" + const source = cleanId(sourceRaw.trim()); + let targetRawTrimmed = targetRaw.trim(); + let label = ''; + + const pipeMatch = targetRawTrimmed.match(/^\|([^|]+)\|\s*(.+)$/); + if (pipeMatch) { + label = pipeMatch[1]; + targetRawTrimmed = pipeMatch[2].trim(); + } + const target = cleanId(targetRawTrimmed); + + // Attributes + const attributes = parseAttributes(attrsRaw || ''); + + // Arrow styling + if (arrow === '-->') attributes.styleType = 'curved'; + if (arrow === '..>') attributes.styleType = 'dashed'; + if (arrow === '==>') attributes.styleType = 'thick'; + + dslEdges.push({ + sourceId: source, // Resolved later + targetId: target, // Resolved later + label, + attributes, + }); + return; + } - errors.push(`Line ${lineIndex + 1}: Unrecognized syntax "${line}"`); - }); + // 5. Nodes: [type] id: Label { attrs } + const nodeMatch = line.match( + /^\[([a-zA-Z0-9_]+)\]\s*(?:([a-zA-Z0-9_]+):\s*)?([^{]+)(\s*\{.*\})?$/ + ); + if (nodeMatch) { + const [, typeRaw, idRaw, labelRaw, attrsRaw] = nodeMatch; + const type = NODE_TYPE_MAP[typeRaw.toLowerCase()] || 'process'; + const label = labelRaw.trim(); + const id = idRaw ? idRaw.trim() : label; // If no explicit ID, use label (backward compact) + + const attributes = parseAttributes(attrsRaw || ''); + + const node: DSLNode = { + id, + type, + label, + attributes, + }; + + dslNodes.push(node); + labelToIdMap.set(label, id); // Map label to ID for edge resolution + labelToIdMap.set(id, id); // Map ID to ID + return; + } - if (currentGroupStack.length > 0) { - errors.push(`Line ${lines.length}: Unclosed group block (missing closing "}")`); + errors.push(`Line ${lineIndex + 1}: Unrecognized syntax "${line}"`); + }); + + if (currentGroupStack.length > 0) { + errors.push(`Line ${lines.length}: Unclosed group block (missing closing "}")`); + } + + // Post-processing: Resolve implicit nodes and edge IDs + const finalNodes: FlowNode[] = []; + const finalEdges: FlowEdge[] = []; + const createdNodeIds = new Set(); + + // 1. Process explicit nodes + dslNodes.forEach((n) => { + const defaultStyle = NODE_DEFAULTS[n.type] || NODE_DEFAULTS['process']; + + // Layout placeholder (will be handled by ELK layout) + let node: FlowNode = { + id: n.id, + type: n.type, + position: { x: 0, y: 0 }, + data: { + label: n.label, + shape: defaultStyle?.shape as NodeData['shape'], + color: defaultStyle?.color, + icon: defaultStyle?.icon && defaultStyle.icon !== 'none' ? defaultStyle.icon : undefined, + ...n.attributes, + ...(n.attributes.archProvider + ? { archIconPackId: resolveArchPackId(String(n.attributes.archProvider)) } + : {}), + ...(n.attributes.archResourceType + ? { archIconShapeId: String(n.attributes.archResourceType) } + : {}), + ...(n.attributes.archResourceType ? { assetPresentation: 'icon' as const } : {}), + }, + }; + if (n.parentId) { + node = setNodeParent(node, n.parentId); } + finalNodes.push(node); + createdNodeIds.add(n.id); + }); + + // 2. Process edges and create implicit nodes + dslEdges.forEach((e, i) => { + const sourceId = labelToIdMap.get(e.sourceId) || e.sourceId; + const targetId = labelToIdMap.get(e.targetId) || e.targetId; + + const ensureNode = (nodeId: string) => { + if (createdNodeIds.has(nodeId)) return; + const style = NODE_DEFAULTS['process']; + finalNodes.push({ + id: nodeId, + type: 'process', + position: { x: 0, y: 0 }, + data: { + label: nodeId, + shape: style?.shape as NodeData['shape'], + color: style?.color, + icon: style?.icon && style.icon !== 'none' ? style.icon : undefined, + }, + }); + createdNodeIds.add(nodeId); + labelToIdMap.set(nodeId, nodeId); + }; - // Post-processing: Resolve implicit nodes and edge IDs - const finalNodes: FlowNode[] = []; - const finalEdges: FlowEdge[] = []; - const createdNodeIds = new Set(); - - // 1. Process explicit nodes - dslNodes.forEach((n) => { - const defaultStyle = NODE_DEFAULTS[n.type] || NODE_DEFAULTS['process']; - - // Layout placeholder (will be handled by ELK layout) - let node: FlowNode = { - id: n.id, - type: n.type, - position: { x: 0, y: 0 }, - data: { - label: n.label, - shape: defaultStyle?.shape as NodeData['shape'], - color: defaultStyle?.color, - icon: defaultStyle?.icon && defaultStyle.icon !== 'none' ? defaultStyle.icon : undefined, - ...n.attributes - }, - }; - if (n.parentId) { - node = setNodeParent(node, n.parentId); - } - finalNodes.push(node); - createdNodeIds.add(n.id); - }); - - // 2. Process edges and create implicit nodes - dslEdges.forEach((e, i) => { - const sourceId = labelToIdMap.get(e.sourceId) || e.sourceId; - const targetId = labelToIdMap.get(e.targetId) || e.targetId; - - // If nodes parse as "A -> B" and A wasn't defined, create a default process node - const defaultProcessStyle = NODE_DEFAULTS['process']; - - if (!createdNodeIds.has(sourceId)) { - finalNodes.push({ - id: sourceId, - type: 'process', - position: { x: 0, y: 0 }, - data: { - label: sourceId, - shape: defaultProcessStyle?.shape as NodeData['shape'], - color: defaultProcessStyle?.color, - icon: defaultProcessStyle?.icon && defaultProcessStyle.icon !== 'none' ? defaultProcessStyle.icon : undefined, - } - }); - createdNodeIds.add(sourceId); - labelToIdMap.set(sourceId, sourceId); - } - if (!createdNodeIds.has(targetId)) { - finalNodes.push({ - id: targetId, - type: 'process', - position: { x: 0, y: 0 }, - data: { - label: targetId, - shape: defaultProcessStyle?.shape as NodeData['shape'], - color: defaultProcessStyle?.color, - icon: defaultProcessStyle?.icon && defaultProcessStyle.icon !== 'none' ? defaultProcessStyle.icon : undefined, - } - }); - createdNodeIds.add(targetId); - labelToIdMap.set(targetId, targetId); - } + ensureNode(sourceId); + ensureNode(targetId); - const finalEdge: FlowEdge = { - id: `edge-${i}`, // Unique ID for the edge - source: sourceId, - target: targetId, - label: e.label, - type: 'default', // Default edge type - data: { label: e.label } - }; - - // Merge attributes into edge data or style - if (Object.keys(e.attributes).length > 0) { - finalEdge.data = { ...finalEdge.data, ...e.attributes }; - - // Map 'style' attribute to styleType for convenience/tests - const styleType = e.attributes.styleType || e.attributes.style; - - // Handle specific style mappings if needed - if (styleType === 'curved') { - finalEdge.type = 'smoothstep'; - finalEdge.data.styleType = 'curved'; - } else if (styleType === 'dashed') { - finalEdge.style = { strokeDasharray: '5 5' }; - finalEdge.data.styleType = 'dashed'; - } else if (styleType === 'thick') { - finalEdge.style = { strokeWidth: 3 }; - finalEdge.data.styleType = 'thick'; - } - } - finalEdges.push(finalEdge); - }); - - return { - nodes: finalNodes, - edges: finalEdges, - metadata, - errors + const finalEdge: FlowEdge = { + id: `edge-${i}`, // Unique ID for the edge + source: sourceId, + target: targetId, + label: e.label, + type: 'default', // Default edge type + data: { label: e.label }, }; -}; + + // Merge attributes into edge data or style + if (Object.keys(e.attributes).length > 0) { + finalEdge.data = { ...finalEdge.data, ...e.attributes }; + + // Map 'style' attribute to styleType for convenience/tests + const styleType = e.attributes.styleType || e.attributes.style; + + // Handle specific style mappings if needed + if (styleType === 'curved') { + finalEdge.type = 'smoothstep'; + finalEdge.data.styleType = 'curved'; + } else if (styleType === 'dashed') { + finalEdge.style = { strokeDasharray: '5 5' }; + finalEdge.data.styleType = 'dashed'; + } else if (styleType === 'thick') { + finalEdge.style = { strokeWidth: 3 }; + finalEdge.data.styleType = 'thick'; + } + } + finalEdges.push(finalEdge); + }); + + return { + nodes: finalNodes, + edges: finalEdges, + metadata, + errors, + }; +} export const parseFlowMindDSL = parseOpenFlowDslV2; diff --git a/src/lib/iconMatcher.test.ts b/src/lib/iconMatcher.test.ts new file mode 100644 index 00000000..08466713 --- /dev/null +++ b/src/lib/iconMatcher.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { matchIcon, getMatchableIconCount, listIconProviders, buildCatalogSummary } from './iconMatcher'; + +describe('iconMatcher', () => { + it('finds icons from the catalog', () => { + const count = getMatchableIconCount(); + expect(count).toBeGreaterThan(100); + }); + + it('lists available providers', () => { + const providers = listIconProviders(); + expect(providers).toContain('developer'); + expect(providers).toContain('aws'); + }); + + it('exact match: postgresql finds the PostgreSQL icon', () => { + const results = matchIcon('postgresql'); + expect(results.length).toBeGreaterThan(0); + expect(results[0].shapeId).toContain('postgresql'); + expect(results[0].matchType).toBe('exact'); + expect(results[0].score).toBeGreaterThan(0.9); + }); + + it('alias match: "postgres" resolves to postgresql', () => { + const results = matchIcon('postgres'); + expect(results.length).toBeGreaterThan(0); + expect(results[0].shapeId).toContain('postgresql'); + expect(results[0].matchType).toBe('alias'); + }); + + it('alias match: "k8s" resolves to kubernetes', () => { + const results = matchIcon('k8s'); + expect(results.length).toBeGreaterThan(0); + expect(results[0].shapeId).toContain('kubernetes'); + expect(results[0].matchType).toBe('alias'); + }); + + it('substring match: "redis" finds redis icons', () => { + const results = matchIcon('redis'); + expect(results.length).toBeGreaterThan(0); + expect(results[0].shapeId).toContain('redis'); + }); + + it('provider filter: "lambda" with provider "aws" finds AWS Lambda', () => { + const results = matchIcon('lambda', 'aws'); + expect(results.length).toBeGreaterThan(0); + expect(results[0].provider).toBe('aws'); + expect(results[0].shapeId).toContain('lambda'); + }); + + it('provider filter: "lambda" without filter finds any provider', () => { + const results = matchIcon('lambda'); + expect(results.length).toBeGreaterThan(0); + }); + + it('returns empty for unknown queries', () => { + const results = matchIcon('zzzznotreal99999'); + expect(results).toEqual([]); + }); + + it('returns empty for empty query', () => { + expect(matchIcon('')).toEqual([]); + expect(matchIcon(' ')).toEqual([]); + }); + + it('matchType classification works', () => { + const exact = matchIcon('docker'); + if (exact.length > 0) { + expect(['exact', 'alias', 'substring']).toContain(exact[0].matchType); + } + }); + + it('lists all expected providers', () => { + const providers = listIconProviders(); + expect(providers).toEqual(expect.arrayContaining(['aws', 'azure', 'cncf', 'developer', 'gcp'])); + expect(providers).toHaveLength(5); + }); + + it('buildCatalogSummary returns non-empty summary with provider names', () => { + const summary = buildCatalogSummary(5); + expect(summary.length).toBeGreaterThan(0); + expect(summary).toContain('aws'); + expect(summary).toContain('developer'); + }); + + it('buildCatalogSummary respects maxPerProvider limit', () => { + const small = buildCatalogSummary(2); + const large = buildCatalogSummary(50); + expect(small.length).toBeLessThan(large.length); + }); +}); diff --git a/src/lib/iconMatcher.ts b/src/lib/iconMatcher.ts new file mode 100644 index 00000000..86b7afb9 --- /dev/null +++ b/src/lib/iconMatcher.ts @@ -0,0 +1,201 @@ +import { SVG_SOURCES } from '@/services/shapeLibrary/providerCatalog'; + +export interface IconMatch { + packId: string; + shapeId: string; + label: string; + provider: string; + category: string; + score: number; + matchType: 'exact' | 'alias' | 'substring' | 'category'; +} + +const ALIASES: Record = { + postgres: 'postgresql', + pg: 'postgresql', + pgsql: 'postgresql', + mongo: 'mongodb', + mdb: 'mongodb', + es: 'elasticsearch', + elastic: 'elasticsearch', + k8s: 'kubernetes', + tf: 'terraform', + hcl: 'terraform', + golang: 'go', + js: 'javascript', + ts: 'typescript', + py: 'python', + rb: 'ruby', + njs: 'nodejs', + node: 'nodejs', + 'react.js': 'react', + 'vue.js': 'vue', + next: 'nextjs', + 'nuxt.js': 'nuxt', + mq: 'rabbitmq', + apachekafka: 'kafka', + csharp: 'c#', + dotnet: '.net', + gke: 'google-kubernetes-engine', + aks: 'azure-kubernetes-service', + eks: 'amazon-elastic-kubernetes-service', + rds: 'amazon-rds', + sqs: 'amazon-sqs', + sns: 'amazon-sns', + s3: 'amazon-s3', + cf: 'cloudflare', + kib: 'kibana', + logstash: 'elastic-logstash', + beat: 'elastic-beats', +}; + +function normalize(text: string): string { + return text + .toLowerCase() + .replace(/[\s._]+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +function entries(): IconEntry[] { + return SVG_SOURCES.map((s) => { + const parts = s.shapeId.split('/'); + const lastPathPart = parts[parts.length - 1]; + const lastHyphenPart = lastPathPart.split('-').pop() ?? lastPathPart; + return { + packId: s.packId, + shapeId: s.shapeId, + label: s.label, + provider: s.provider, + category: s.category, + normalizedName: normalize(s.shapeId), + normalizedLastSegment: normalize(lastHyphenPart), + }; + }); +} + +interface IconEntry { + packId: string; + shapeId: string; + label: string; + provider: string; + category: string; + normalizedName: string; + normalizedLastSegment: string; +} + +let cachedEntries: IconEntry[] | null = null; +function getEntries(): IconEntry[] { + if (!cachedEntries) cachedEntries = entries(); + return cachedEntries; +} + +let cachedByNormalized: Map | null = null; +function getByNormalized(): Map { + if (!cachedByNormalized) { + cachedByNormalized = new Map(); + for (const entry of getEntries()) { + cachedByNormalized.set(entry.normalizedName, entry); + if (entry.normalizedLastSegment !== entry.normalizedName) { + cachedByNormalized.set(entry.normalizedLastSegment, entry); + } + } + } + return cachedByNormalized; +} + +export function matchIcon(query: string, providerHint?: string): IconMatch[] { + const normalizedQuery = normalize(query); + if (!normalizedQuery) return []; + + const byNormalized = getByNormalized(); + const all = getEntries(); + + // 1. Exact match on shape ID + const exact = byNormalized.get(normalizedQuery); + if (exact && (!providerHint || exact.provider === providerHint)) { + return [toMatch(exact, 0.99, 'exact')]; + } + + // 2. Alias resolution + const aliasTarget = ALIASES[normalizedQuery]; + if (aliasTarget) { + const aliasEntry = byNormalized.get(normalize(aliasTarget)); + if (aliasEntry && (!providerHint || aliasEntry.provider === providerHint)) { + return [toMatch(aliasEntry, 0.95, 'alias')]; + } + } + + // 3. Substring match (query contained in name, or name contained in query) + const substringMatches: IconMatch[] = []; + for (const entry of all) { + if (providerHint && entry.provider !== providerHint) continue; + if (entry.normalizedLastSegment.length < 3 || normalizedQuery.length < 3) continue; + if ( + entry.normalizedName.includes(normalizedQuery) || + entry.normalizedLastSegment.includes(normalizedQuery) || + normalizedQuery.includes(entry.normalizedLastSegment) + ) { + substringMatches.push(toMatch(entry, 0.85, 'substring')); + } + } + if (substringMatches.length > 0) { + substringMatches.sort((a, b) => b.score - a.score); + return substringMatches.slice(0, 5); + } + + // 4. Category match + const normalizedCategory = normalizedQuery.replace(/-/g, ''); + const categoryMatches: IconMatch[] = []; + for (const entry of all) { + if (providerHint && entry.provider !== providerHint) continue; + if (normalize(entry.category).replace(/-/g, '').includes(normalizedCategory)) { + categoryMatches.push(toMatch(entry, 0.7, 'category')); + } + } + if (categoryMatches.length > 0) { + categoryMatches.sort((a, b) => b.score - a.score); + return categoryMatches.slice(0, 5); + } + + return []; +} + +function toMatch(entry: IconEntry, score: number, matchType: IconMatch['matchType']): IconMatch { + return { + packId: entry.packId, + shapeId: entry.shapeId, + label: entry.label, + provider: entry.provider, + category: entry.category, + score, + matchType, + }; +} + +export function getMatchableIconCount(): number { + return getEntries().length; +} + +export function listIconProviders(): string[] { + return [...new Set(getEntries().map((e) => e.provider))].sort(); +} + +export function buildCatalogSummary(maxPerProvider: number = 30): string { + const byProvider = new Map(); + for (const entry of getEntries()) { + const list = byProvider.get(entry.provider) ?? []; + list.push(entry); + byProvider.set(entry.provider, list); + } + + const lines: string[] = []; + for (const [provider, icons] of byProvider) { + const categories = [...new Set(icons.map((i) => i.category))]; + const sampleNames = icons.slice(0, maxPerProvider).map((i) => i.label); + lines.push(`${provider}: ${categories.join(', ')} (examples: ${sampleNames.join(', ')})`); + } + + return lines.join('\n'); +} diff --git a/src/lib/nodeEnricher.test.ts b/src/lib/nodeEnricher.test.ts index ee31c3a2..8e39467a 100644 --- a/src/lib/nodeEnricher.test.ts +++ b/src/lib/nodeEnricher.test.ts @@ -40,31 +40,14 @@ describe('enrichNodesWithIcons', () => { const enriched = await enrichNodesWithIcons(nodes); - // PostgreSQL should get a provider icon (developer catalog) - const pgNode = enriched[0]; - if (pgNode.data.archIconPackId) { - expect(pgNode.data.archIconPackId).toBe('developer-icons-v1'); - expect(pgNode.data.archIconShapeId).toContain('postgresql'); - } else { - expect(pgNode.data.icon).toBe('database'); - } + // All three should get provider icons (any catalog) + expect(enriched[0].data.archIconPackId).toBeTruthy(); + expect(enriched[0].data.archIconShapeId).toContain('postgresql'); - // Redis should get a provider icon (developer catalog) - const redisNode = enriched[1]; - if (redisNode.data.archIconPackId) { - expect(redisNode.data.archIconPackId).toBe('developer-icons-v1'); - expect(redisNode.data.archIconShapeId).toContain('redis'); - } else { - expect(redisNode.data.icon).toBe('hard-drive'); - } + expect(enriched[1].data.archIconPackId).toBeTruthy(); + expect(enriched[1].data.archIconShapeId).toContain('redis'); - // Express should get a provider icon or Lucide fallback - const expressNode = enriched[2]; - if (expressNode.data.archIconPackId) { - expect(expressNode.data.archIconPackId).toBe('developer-icons-v1'); - } else { - expect(expressNode.data.icon).toBe('server'); - } + expect(enriched[2].data.archIconPackId).toBeTruthy(); }); it('skips section and group nodes', async () => { @@ -148,9 +131,33 @@ describe('enrichNodesWithIcons', () => { const enriched = await enrichNodesWithIcons(nodes); expect(enriched[0].data.color).toBe('violet'); - // Cylinder + PostgreSQL gets a provider icon or Lucide fallback if (!enriched[0].data.archIconPackId) { expect(enriched[0].data.icon).toBe('database'); } }); + + it('uses icon attribute for explicit catalog search', async () => { + const nodes = [ + makeNode('cache', 'My Cache', { + data: { label: 'My Cache', color: 'slate', icon: 'redis' }, + }), + ]; + + const enriched = await enrichNodesWithIcons(nodes); + expect(enriched[0].data.archIconPackId).toBeTruthy(); + expect(enriched[0].data.archIconShapeId).toContain('redis'); + }); + + it('uses provider filter when set', async () => { + const nodes = [ + makeNode('db', 'Database', { + data: { label: 'Database', color: 'slate', provider: 'aws' }, + }), + ]; + + const enriched = await enrichNodesWithIcons(nodes); + if (enriched[0].data.archIconPackId) { + expect(enriched[0].data.archIconPackId).toBe('aws-official-starter-v1'); + } + }); }); diff --git a/src/lib/nodeEnricher.ts b/src/lib/nodeEnricher.ts index 95f5c205..20eba472 100644 --- a/src/lib/nodeEnricher.ts +++ b/src/lib/nodeEnricher.ts @@ -1,24 +1,24 @@ import type { FlowNode } from '@/lib/types'; import { classifyNode } from '@/lib/semanticClassifier'; -import { resolveIconSync } from '@/lib/iconResolver'; -import { loadDomainAssetSuggestions } from '@/services/assetCatalog'; -import type { DomainLibraryCategory } from '@/services/domainLibrary'; +import { matchIcon, type IconMatch } from '@/lib/iconMatcher'; -export async function enrichNodesWithIcons(nodes: FlowNode[]): Promise { - const enriched = await Promise.all(nodes.map(enrichSingleNode)); - return enriched; +export function enrichNodesWithIcons(nodes: FlowNode[]): FlowNode[] { + return nodes.map(enrichSingleNode); } -async function enrichSingleNode(node: FlowNode): Promise { +function enrichSingleNode(node: FlowNode): FlowNode { if (node.type === 'section' || node.type === 'group' || node.type === 'swimlane') { return node; } const label = node.data?.label ?? ''; - const hasExplicitColor = node.data?.color && node.data.color !== 'slate'; - const hasExplicitIcon = Boolean(node.data?.icon); + const nodeColor = node.data?.color; + const isDefaultColor = !nodeColor || nodeColor === 'slate' || nodeColor === 'white'; + const hasExplicitColor = !isDefaultColor; + const hasExplicitProviderIcon = Boolean(node.data?.archIconPackId); + const hasAnyIcon = Boolean(node.data?.icon) || hasExplicitProviderIcon; - if (hasExplicitColor && hasExplicitIcon) { + if (hasExplicitColor && hasAnyIcon) { return node; } @@ -26,43 +26,11 @@ async function enrichSingleNode(node: FlowNode): Promise { const dataUpdates: Record = {}; if (!hasExplicitColor) { - // Use parser-assigned node type to override classifier if it's more specific - if (node.type === 'start') { - dataUpdates.color = 'emerald'; - } else if (node.type === 'end') { - dataUpdates.color = 'red'; - } else if (node.type === 'decision') { - dataUpdates.color = 'amber'; - } else { - dataUpdates.color = hint.color; - } + applyColor(node, hint.color, dataUpdates); } - if (!hasExplicitIcon) { - const classifierIcon = hint.lucideFallback; - - if (hint.iconQuery) { - const resolved = resolveIconSync(hint.iconQuery, hint.category); - if (resolved.found && resolved.catalog && resolved.iconSearch) { - const catalogResult = await searchCatalogForIcon(resolved.catalog, resolved.iconSearch); - if (catalogResult?.archIconPackId && catalogResult.archIconShapeId) { - dataUpdates.archIconPackId = catalogResult.archIconPackId; - dataUpdates.archIconShapeId = catalogResult.archIconShapeId; - dataUpdates.assetPresentation = 'icon'; - } - } - } - - // Use classifier icon if specific (not generic 'box'), otherwise use node type defaults - if (classifierIcon && classifierIcon !== 'box') { - dataUpdates.icon = classifierIcon; - } else if (node.type === 'start') { - dataUpdates.icon = 'play'; - } else if (node.type === 'end') { - dataUpdates.icon = 'check-circle'; - } else if (node.type === 'decision') { - dataUpdates.icon = 'help-circle'; - } + if (!hasExplicitProviderIcon) { + applyIcon(node, label, hint, dataUpdates); } if (Object.keys(dataUpdates).length === 0) { @@ -78,41 +46,80 @@ async function enrichSingleNode(node: FlowNode): Promise { }; } -const catalogCache = new Map< - string, - { icon?: string; archIconPackId?: string; archIconShapeId?: string } | null ->(); - -async function searchCatalogForIcon( - catalog: DomainLibraryCategory, - query: string -): Promise<{ icon?: string; archIconPackId?: string; archIconShapeId?: string } | null> { - const cacheKey = `${catalog}:${query}`; - if (catalogCache.has(cacheKey)) { - return catalogCache.get(cacheKey)!; +function applyColor( + node: FlowNode, + classifierColor: string, + updates: Record +): void { + if (node.type === 'start') { + updates.color = 'emerald'; + } else if (node.type === 'end') { + updates.color = 'red'; + } else if (node.type === 'decision') { + updates.color = 'amber'; + } else { + updates.color = classifierColor; + } +} + +function applyIcon( + node: FlowNode, + label: string, + hint: { iconQuery: string; lucideFallback: string; category: string }, + updates: Record +): void { + const explicitIcon = node.data?.icon; + const provider = node.data?.provider; + const providerHint = typeof provider === 'string' ? provider : undefined; + + // Priority 1: Explicit icon attribute (e.g., icon: "redis") + if (explicitIcon && typeof explicitIcon === 'string' && explicitIcon !== 'none') { + const match = findBestMatch(explicitIcon, providerHint); + if (match) { + updates.archIconPackId = match.packId; + updates.archIconShapeId = match.shapeId; + updates.assetPresentation = 'icon'; + } + return; + } + + // Priority 2: Classifier icon query (e.g., label contains "PostgreSQL") + if (hint.iconQuery) { + const match = findBestMatch(hint.iconQuery, providerHint); + if (match) { + updates.archIconPackId = match.packId; + updates.archIconShapeId = match.shapeId; + updates.assetPresentation = 'icon'; + updates.icon = hint.lucideFallback; + return; + } } - try { - const results = await loadDomainAssetSuggestions(catalog, { query, limit: 1 }); - if (results.length > 0) { - const best = results[0]; - const labelMatch = best.label.toLowerCase().includes(query.toLowerCase()); - const descMatch = best.description.toLowerCase().includes(query.toLowerCase()); - - if (labelMatch || descMatch) { - const match = { - icon: best.icon, - archIconPackId: best.archIconPackId, - archIconShapeId: best.archIconShapeId, - }; - catalogCache.set(cacheKey, match); - return match; - } + // Priority 3: Label-based fallback (icons: auto — match by node label) + // Only when node has NO icon at all + if (label && !node.data?.icon) { + const match = findBestMatch(label, providerHint); + if (match) { + updates.archIconPackId = match.packId; + updates.archIconShapeId = match.shapeId; + updates.assetPresentation = 'icon'; } - } catch { - // Catalog search failed — fall back to Lucide } - catalogCache.set(cacheKey, null); - return null; + // Lucide icon fallback + if (hint.lucideFallback && hint.lucideFallback !== 'box') { + updates.icon = hint.lucideFallback; + } else if (node.type === 'start') { + updates.icon = 'play'; + } else if (node.type === 'end') { + updates.icon = 'check-circle'; + } else if (node.type === 'decision') { + updates.icon = 'help-circle'; + } +} + +function findBestMatch(query: string, providerHint?: string): IconMatch | undefined { + const matches = matchIcon(query, providerHint); + const best = matches[0]; + return best && best.score >= 0.8 ? best : undefined; } diff --git a/src/services/export/formatting.ts b/src/services/export/formatting.ts index dde106ab..58dc360d 100644 --- a/src/services/export/formatting.ts +++ b/src/services/export/formatting.ts @@ -1,9 +1,9 @@ export function sanitizeLabel(label: string): string { - return label.replace(/['"()]/g, '').trim() || 'Node'; + return label.replace(/"/g, "'").trim() || 'Node'; } export function sanitizeEdgeLabel(label: string): string { - return label.replace(/['"{}]/g, '').trim(); + return label.replace(/"/g, "'").replace(/[{}]/g, '').trim(); } export function sanitizeId(id: string): string { diff --git a/src/services/export/mermaid/stateDiagramMermaid.ts b/src/services/export/mermaid/stateDiagramMermaid.ts index 54130e8b..74bd23c6 100644 --- a/src/services/export/mermaid/stateDiagramMermaid.ts +++ b/src/services/export/mermaid/stateDiagramMermaid.ts @@ -21,12 +21,8 @@ export function looksLikeStateDiagram(nodes: FlowNode[]): boolean { if (nodes.length === 0) return false; const hasStateStartNode = nodes.some((node) => node.id.startsWith('state_start_')); const hasExplicitStateNode = nodes.some((node) => node.type === 'state'); - const hasCompositeParenting = nodes.some((node) => { - const parentId = getNodeParentId(node); - return parentId.length > 0; - }); - if (!hasStateStartNode && !hasExplicitStateNode && !hasCompositeParenting) { + if (!hasStateStartNode && !hasExplicitStateNode) { return false; } diff --git a/src/services/export/mermaidBuilder.ts b/src/services/export/mermaidBuilder.ts index 9b21ed3a..987aed5a 100644 --- a/src/services/export/mermaidBuilder.ts +++ b/src/services/export/mermaidBuilder.ts @@ -48,46 +48,112 @@ function resolveFlowchartConnector(edge: FlowEdge): string { return body; } -function toFlowchartMermaid(nodes: FlowNode[], edges: FlowEdge[]): string { - let mermaid = 'flowchart TD\n'; - - nodes.forEach((node) => { - const label = sanitizeLabel(node.data.label); - const id = sanitizeId(node.id); - let shapeStart = '['; - let shapeEnd = ']'; - - const shape = node.data.shape || 'rounded'; - const type = node.type; - - if (shape === 'diamond') { - shapeStart = '{'; - shapeEnd = '}'; - } else if (shape === 'hexagon') { - shapeStart = '{{'; - shapeEnd = '}}'; - } else if (shape === 'cylinder') { - shapeStart = '[('; - shapeEnd = ')]'; - } else if (shape === 'ellipse') { - shapeStart = '(['; - shapeEnd = '])'; - } else if (shape === 'circle') { - shapeStart = '(('; - shapeEnd = '))'; - } else if (shape === 'parallelogram') { - shapeStart = '>'; - shapeEnd = ']'; - } else if (type === 'decision') { - shapeStart = '{'; - shapeEnd = '}'; - } else if (type === 'start' || type === 'end') { - shapeStart = '(['; - shapeEnd = '])'; +function resolveShapeBrackets( + shape: string | undefined, + type: string | undefined +): { start: string; end: string } { + switch (shape) { + case 'diamond': + return { start: '{', end: '}' }; + case 'hexagon': + return { start: '{{', end: '}}' }; + case 'cylinder': + return { start: '[(', end: ')]' }; + case 'circle': + return { start: '((', end: '))' }; + case 'ellipse': + return { start: '([', end: '])' }; + case 'capsule': + return { start: '([', end: '])' }; + case 'parallelogram': + return { start: '>', end: ']' }; + case 'rounded': + return { start: '(', end: ')' }; + default: + break; + } + + if (type === 'decision') return { start: '{', end: '}' }; + if (type === 'start' || type === 'end') return { start: '([', end: '])' }; + + return { start: '[', end: ']' }; +} + +function collectSectionTree(nodes: FlowNode[]): { + roots: FlowNode[]; + childrenByParent: Map; +} { + const childrenByParent = new Map(); + const roots: FlowNode[] = []; + + for (const node of nodes) { + const parentId = node.parentId; + if (parentId) { + const children = childrenByParent.get(parentId) ?? []; + children.push(node); + childrenByParent.set(parentId, children); + } else if (node.type !== 'section' && node.type !== 'group') { + roots.push(node); } + } - mermaid += ` ${id}${shapeStart}"${label}"${shapeEnd}\n`; - }); + return { roots, childrenByParent }; +} + +function emitFlowchartNode(node: FlowNode, indent: string): string { + const label = sanitizeLabel(node.data.label); + const id = sanitizeId(node.id); + const { start, end } = resolveShapeBrackets(node.data.shape, node.type); + return `${indent}${id}${start}"${label}"${end}\n`; +} + +function emitSectionBlock( + section: FlowNode, + children: FlowNode[], + childrenByParent: Map, + indent: string +): string { + const label = sanitizeLabel(section.data.label); + let out = `${indent}subgraph ${label}\n`; + + for (const child of children) { + if (child.type === 'section' || child.type === 'group') { + const grandChildren = childrenByParent.get(child.id) ?? []; + out += emitSectionBlock(child, grandChildren, childrenByParent, indent + ' '); + } else { + out += emitFlowchartNode(child, indent + ' '); + } + } + + out += `${indent}end\n`; + return out; +} + +function toFlowchartMermaid(nodes: FlowNode[], edges: FlowEdge[], direction?: string): string { + const dir = direction ?? 'TD'; + let mermaid = `flowchart ${dir}\n`; + + const sectionNodes = nodes.filter((n) => n.type === 'section' || n.type === 'group'); + const hasSubgraphs = sectionNodes.length > 0; + + if (hasSubgraphs) { + const { roots, childrenByParent } = collectSectionTree(nodes); + + for (const section of sectionNodes) { + if (!section.parentId) { + const children = childrenByParent.get(section.id) ?? []; + mermaid += emitSectionBlock(section, children, childrenByParent, ' '); + } + } + + for (const node of roots) { + mermaid += emitFlowchartNode(node, ' '); + } + } else { + for (const node of nodes) { + mermaid += emitFlowchartNode(node, ' '); + } + } edges.forEach((edge) => { const source = sanitizeId(edge.source); @@ -104,7 +170,7 @@ function toFlowchartMermaid(nodes: FlowNode[], edges: FlowEdge[]): string { return mermaid; } -export function toMermaid(nodes: FlowNode[], edges: FlowEdge[]): string { +export function toMermaid(nodes: FlowNode[], edges: FlowEdge[], direction?: string): string { const architectureNodeCount = nodes.filter((node) => node.type === 'architecture').length; if (nodes.length > 0 && architectureNodeCount === nodes.length) { return toArchitectureMermaid(nodes, edges); @@ -141,5 +207,5 @@ export function toMermaid(nodes: FlowNode[], edges: FlowEdge[]): string { return toStateDiagramMermaid(nodes, edges); } - return toFlowchartMermaid(nodes, edges); + return toFlowchartMermaid(nodes, edges, direction); } diff --git a/src/services/export/mermaidExportQuality.test.ts b/src/services/export/mermaidExportQuality.test.ts new file mode 100644 index 00000000..a00a477e --- /dev/null +++ b/src/services/export/mermaidExportQuality.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest'; +import { parseMermaidByType } from '@/services/mermaid/parseMermaidByType'; +import { enrichNodesWithIcons } from '@/lib/nodeEnricher'; +import { toMermaid } from '@/services/export/mermaidBuilder'; +import type { FlowNode, FlowEdge } from '@/lib/types'; + +describe('Mermaid Export Quality', () => { + it('exports rounded shape as (label) not [label]', async () => { + const input = `flowchart TD + A("Rounded Node") + + A --> B["Rectangle Node"]`; + + const parsed = parseMermaidByType(input); + const enriched = await enrichNodesWithIcons(parsed.nodes); + const exported = toMermaid(enriched, parsed.edges); + + expect(exported).toContain('("Rounded Node")'); + }); + + it('exports start/end as stadium ([label])', async () => { + const input = `flowchart TD + S(["Start"]) + E(("End")) + + S --> E`; + + const parsed = parseMermaidByType(input); + const enriched = await enrichNodesWithIcons(parsed.nodes); + const exported = toMermaid(enriched, parsed.edges); + + expect(exported).toContain('(["Start"])'); + expect(exported).toContain('(("End"))'); + }); + + it('exports subgraph blocks', async () => { + const input = `flowchart TD + subgraph Frontend + UI["React App"] + end + subgraph Backend + API["Express API"] + DB[("PostgreSQL")] + end + UI --> API + API --> DB`; + + const parsed = parseMermaidByType(input); + const enriched = await enrichNodesWithIcons(parsed.nodes); + const exported = toMermaid(enriched, parsed.edges); + + expect(exported).toContain('subgraph Frontend'); + expect(exported).toContain('subgraph Backend'); + expect(exported).toContain('React App'); + expect(exported).toContain('Express API'); + expect(exported).toContain('end'); + }); + + it('preserves direction when passed', async () => { + const input = `flowchart LR + A["Left"] --> B["Right"]`; + + const parsed = parseMermaidByType(input); + const enriched = await enrichNodesWithIcons(parsed.nodes); + const exported = toMermaid(enriched, parsed.edges, 'LR'); + + expect(exported).toContain('flowchart LR'); + }); + + it('defaults to TD when no direction specified', async () => { + const input = `flowchart TD + A["A"] --> B["B"]`; + + const parsed = parseMermaidByType(input); + const enriched = await enrichNodesWithIcons(parsed.nodes); + const exported = toMermaid(enriched, parsed.edges); + + expect(exported).toContain('flowchart TD'); + }); + + it('exports all shape types correctly', async () => { + const nodes = [ + { + id: 'a', + type: 'process', + position: { x: 0, y: 0 }, + data: { label: 'Rounded', shape: 'rounded', color: 'slate' }, + }, + { + id: 'b', + type: 'process', + position: { x: 0, y: 0 }, + data: { label: 'Rect', shape: undefined, color: 'slate' }, + }, + { + id: 'c', + type: 'process', + position: { x: 0, y: 0 }, + data: { label: 'Diamond', shape: 'diamond', color: 'slate' }, + }, + { + id: 'd', + type: 'process', + position: { x: 0, y: 0 }, + data: { label: 'Cylinder', shape: 'cylinder', color: 'slate' }, + }, + { + id: 'e', + type: 'process', + position: { x: 0, y: 0 }, + data: { label: 'Circle', shape: 'circle', color: 'slate' }, + }, + { + id: 'f', + type: 'process', + position: { x: 0, y: 0 }, + data: { label: 'Capsule', shape: 'capsule', color: 'slate' }, + }, + { + id: 'g', + type: 'process', + position: { x: 0, y: 0 }, + data: { label: 'Hexagon', shape: 'hexagon', color: 'slate' }, + }, + ]; + + const exported = toMermaid(nodes as unknown as FlowNode[], [] as unknown as FlowEdge[]); + + expect(exported).toContain('("Rounded")'); + expect(exported).toContain('["Rect"]'); + expect(exported).toContain('{"Diamond"}'); + expect(exported).toContain('[("Cylinder")]'); + expect(exported).toContain('(("Circle"))'); + expect(exported).toContain('(["Capsule"])'); + expect(exported).toContain('{{"Hexagon"}}'); + }); + + it('roundtrips basic flowchart with shapes', async () => { + const input = `flowchart TD + S(["Start"]) + P["Process"] + D{"Decision"} + E(("End")) + + S --> P + P --> D + D -->|"Yes"| E`; + + const parsed = parseMermaidByType(input); + const enriched = await enrichNodesWithIcons(parsed.nodes); + const exported = toMermaid(enriched, parsed.edges); + + expect(exported).toContain('flowchart'); + expect(exported).toContain('Start'); + expect(exported).toContain('Process'); + expect(exported).toContain('Decision'); + expect(exported).toContain('End'); + expect(exported).toContain('Yes'); + }); + + it('preserves parens and apostrophes in labels', async () => { + const input = `flowchart TD + A["Parse (tokens)"] --> B["O'Brien"] + B --> C["Say \\"hello\\""]`; + + const parsed = parseMermaidByType(input); + const enriched = await enrichNodesWithIcons(parsed.nodes); + const exported = toMermaid(enriched, parsed.edges); + + expect(exported).toContain('Parse (tokens)'); + expect(exported).toContain("O'Brien"); + }); + + it('handles empty diagram', () => { + const exported = toMermaid([], []); + expect(exported).toContain('flowchart'); + }); +}); diff --git a/src/services/exportService.ts b/src/services/exportService.ts index cd261588..c80a023b 100644 --- a/src/services/exportService.ts +++ b/src/services/exportService.ts @@ -2,8 +2,8 @@ import type { FlowEdge, FlowNode } from '@/lib/types'; import { toMermaid as toMermaidBuilder } from './export/mermaidBuilder'; import { toPlantUML as toPlantUMLBuilder } from './export/plantumlBuilder'; -export function toMermaid(nodes: FlowNode[], edges: FlowEdge[]): string { - return toMermaidBuilder(nodes, edges); +export function toMermaid(nodes: FlowNode[], edges: FlowEdge[], direction?: string): string { + return toMermaidBuilder(nodes, edges, direction); } export function toPlantUML(nodes: FlowNode[], edges: FlowEdge[]): string { diff --git a/src/services/flowchartRoundTrip.test.ts b/src/services/flowchartRoundTrip.test.ts index d0b6fe80..92f00673 100644 --- a/src/services/flowchartRoundTrip.test.ts +++ b/src/services/flowchartRoundTrip.test.ts @@ -22,8 +22,8 @@ describe('flowchart round-trip', () => { expect(first.edges[2].markerStart).toBeDefined(); expect(first.edges[2].markerEnd).toBeUndefined(); - const exported = toMermaid(first.nodes, first.edges); - expect(exported.startsWith('flowchart TD')).toBe(true); + const exported = toMermaid(first.nodes, first.edges, first.direction); + expect(exported.startsWith('flowchart TB')).toBe(true); expect(exported).toContain('A -.->|"warmup"| B'); expect(exported).toContain('B ==> C'); expect(exported).toContain('C <-- D'); @@ -62,4 +62,20 @@ describe('flowchart round-trip', () => { expect(second.edges[0].markerStart).toBeDefined(); expect(second.edges[0].markerEnd).toBeDefined(); }); + + it('preserves direction through parse/export/parse', () => { + const source = ` + flowchart LR + A["Left"] --> B["Right"] + `; + + const first = parseMermaidByType(source); + expect(first.direction).toBe('LR'); + + const exported = toMermaid(first.nodes, first.edges, first.direction); + expect(exported).toContain('flowchart LR'); + + const second = parseMermaidByType(exported); + expect(second.direction).toBe('LR'); + }); }); diff --git a/src/services/geminiSystemInstruction.ts b/src/services/geminiSystemInstruction.ts index 86e396a0..4157265b 100644 --- a/src/services/geminiSystemInstruction.ts +++ b/src/services/geminiSystemInstruction.ts @@ -1,3 +1,5 @@ +import { buildCatalogSummary } from '@/lib/iconMatcher'; + const EDIT_MODE_PREAMBLE = ` ## EDIT MODE — MODIFYING AN EXISTING DIAGRAM @@ -65,7 +67,14 @@ For \`[architecture]\` nodes: \`[architecture] id: Label { archProvider: "aws", Colors: \`blue\` (frontend), \`violet\` (backend), \`emerald\` (data), \`amber\` (decisions/queues), \`red\` (errors/end), \`slate\` (generic), \`pink\` (third-party), \`yellow\` (cache). -Icons are optional — the system auto-assigns them. Include an \`icon\` only when you want a specific Lucide icon name. +Icons are optional — the system auto-assigns them. For known technologies, use \`archProvider\` and \`archResourceType\` to specify the icon directly: + +\`[system] db: PostgreSQL { archProvider: "developer", archResourceType: "database-postgresql", color: "violet" }\` + +Available icon catalog: +${buildCatalogSummary(15)} + +Use exact shape IDs from the catalog when possible (e.g. \`database-postgresql\`, \`queue-rabbitmq\`). If unsure, omit \`archResourceType\` and the system will match by label. --- @@ -112,17 +121,35 @@ token ==> dashboard flow: Serverless API direction: TB -[architecture] cf: CloudFront { archProvider: "aws", archResourceType: "cdn", color: "blue" } -[architecture] apigw: API Gateway { archProvider: "aws", archResourceType: "service", color: "violet" } -[architecture] lambda: API Lambda { archProvider: "aws", archResourceType: "lambda", color: "violet" } -[architecture] dynamo: DynamoDB { archProvider: "aws", archResourceType: "database", color: "emerald" } -[architecture] cache: ElastiCache { archProvider: "aws", archResourceType: "service", color: "yellow" } +[architecture] cf: CloudFront { archProvider: "aws", archResourceType: "networking-cloudfront", color: "blue" } +[architecture] apigw: API Gateway { archProvider: "aws", archResourceType: "app-integration-api-gateway", color: "violet" } +[architecture] lambda: API Lambda { archProvider: "aws", archResourceType: "compute-lambda", color: "violet" } +[architecture] dynamo: DynamoDB { archProvider: "aws", archResourceType: "database-dynamodb", color: "emerald" } +[architecture] cache: ElastiCache { archProvider: "aws", archResourceType: "database-elasticache", color: "yellow" } cf ->|HTTPS| apigw apigw ->|HTTP/REST| lambda lambda ->|query| dynamo lambda ->|cache lookup| cache \`\`\` + +### Full-Stack with Developer Icons + +\`\`\` +flow: E-Commerce Stack +direction: TB + +[system] react: React App { archProvider: "developer", archResourceType: "frontend-react", color: "blue" } +[system] api: Express API { archProvider: "developer", archResourceType: "others-expressjs-dark", color: "violet" } +[system] db: PostgreSQL { archProvider: "developer", archResourceType: "database-postgresql", color: "violet" } +[system] cache: Redis { archProvider: "developer", archResourceType: "database-redis", color: "red" } +[system] mq: RabbitMQ { archProvider: "developer", archResourceType: "queue-rabbitmq", color: "amber" } + +react ->|HTTP/REST| api +api ->|SQL| db +api ->|cache lookup| cache +api ->|publish| mq +\`\`\` `; export function getGeminiSystemInstruction(mode: 'create' | 'edit' = 'create'): string { diff --git a/src/services/openFlowRoundTripGoldenFixtures.ts b/src/services/openFlowRoundTripGoldenFixtures.ts index b07ad8cd..ffbe9ee8 100644 --- a/src/services/openFlowRoundTripGoldenFixtures.ts +++ b/src/services/openFlowRoundTripGoldenFixtures.ts @@ -1,3 +1,4 @@ +import type { CSSProperties } from 'react'; import type { Edge, Node } from '@/lib/reactflowCompat'; export interface OpenFlowRoundTripGoldenFixture { @@ -39,6 +40,49 @@ function createEdge(id: string, source: string, target: string, label?: string): } as Edge; } +function createArchNode( + id: string, + label: string, + archIconPackId: string, + archIconShapeId: string, + color: string +): Node { + return { + id, + type: 'custom', + position: { x: 0, y: 0 }, + data: { + label, + color, + archIconPackId, + archIconShapeId, + }, + } as Node; +} + +function createEdgeWithStyle( + id: string, + source: string, + target: string, + label?: string, + style?: { type?: string; strokeDasharray?: string; strokeWidth?: number } +): Edge { + const edge: Record = { + id, + source, + target, + label, + }; + if (style?.type) edge.type = style.type; + if (style?.strokeDasharray || style?.strokeWidth) { + edge.style = { + ...(style.strokeDasharray ? { strokeDasharray: style.strokeDasharray } : {}), + ...(style.strokeWidth ? { strokeWidth: style.strokeWidth } : {}), + } as CSSProperties; + } + return edge as Edge; +} + export const OPENFLOW_ROUND_TRIP_GOLDEN_FIXTURES: OpenFlowRoundTripGoldenFixture[] = [ { name: 'simple-linear', @@ -74,4 +118,28 @@ export const OPENFLOW_ROUND_TRIP_GOLDEN_FIXTURES: OpenFlowRoundTripGoldenFixture createEdge('e1', 'n1', 'n3', 'ok'), ], }, + { + name: 'arch-icons', + nodes: [ + createArchNode('lambda', 'Lambda', 'aws-official-starter-v1', 'compute-lambda', 'violet'), + createArchNode('sqs', 'SQS Queue', 'aws-official-starter-v1', 'app-integration-sqs', 'amber'), + createArchNode('dynamo', 'DynamoDB', 'aws-official-starter-v1', 'database-dynamodb', 'emerald'), + ], + edges: [ + createEdge('e1', 'lambda', 'sqs', 'publish'), + createEdge('e2', 'sqs', 'dynamo', 'write'), + ], + }, + { + name: 'edge-styles', + nodes: [ + createNode('n1', 'Source', 'process'), + createNode('n2', 'Dashed Target', 'process'), + createNode('n3', 'Curved Target', 'process'), + ], + edges: [ + createEdgeWithStyle('e1', 'n1', 'n2', undefined, { strokeDasharray: '5 5' }), + createEdgeWithStyle('e2', 'n1', 'n3', 'flow', { type: 'smoothstep' }), + ], + }, ]; diff --git a/src/services/shapeLibrary/providerCatalog.ts b/src/services/shapeLibrary/providerCatalog.ts index f95da862..cac13b95 100644 --- a/src/services/shapeLibrary/providerCatalog.ts +++ b/src/services/shapeLibrary/providerCatalog.ts @@ -1,234 +1,247 @@ import type { DomainLibraryCategory, DomainLibraryItem } from '@/services/domainLibrary'; export interface ProviderShapePreview { - packId: string; - shapeId: string; - label: string; - category: string; - previewUrl: string; + packId: string; + shapeId: string; + label: string; + category: string; + previewUrl: string; } interface SvgSource { - provider: string; - packId: string; - shapeId: string; - label: string; - category: string; - previewLoader: () => Promise; + provider: string; + packId: string; + shapeId: string; + label: string; + category: string; + previewLoader: () => Promise; } const svgModules = import.meta.glob('../../../assets/third-party-icons/*/processed/**/*.svg', { - query: '?url', - import: 'default', + query: '?url', + import: 'default', }) as Record Promise>; const providerCatalogPromiseCache = new Map>(); const shapePreviewCache = new Map(); const shapePreviewPromiseCache = new Map>(); -const KNOWN_PROVIDER_PACK_IDS: Partial> = { - aws: 'aws-official-starter-v1', - azure: 'azure-official-icons-v20', - gcp: 'gcp-official-icons-v1', - cncf: 'cncf-artwork-icons-v1', - developer: 'developer-icons-v1', +export const KNOWN_PROVIDER_PACK_IDS: Record = { + aws: 'aws-official-starter-v1', + azure: 'azure-official-icons-v20', + gcp: 'gcp-official-icons-v1', + cncf: 'cncf-artwork-icons-v1', + developer: 'developer-icons-v1', }; function normalizeProviderPathSegment(value: string): string { - return value.trim().toLowerCase(); + return value.trim().toLowerCase(); } function slugify(value: string): string { - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); } function inferLabelFromId(id: string): string { - return id - .split('-') - .filter(Boolean) - .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) - .join(' '); + return id + .split('-') + .filter(Boolean) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(' '); } function getPackIdForProvider(provider: string): string { - return KNOWN_PROVIDER_PACK_IDS[provider] ?? `${provider}-processed-pack-v1`; + return KNOWN_PROVIDER_PACK_IDS[provider] ?? `${provider}-processed-pack-v1`; } function getProviderColor(provider: string): string { - if (provider === 'aws') { - return 'amber'; - } + if (provider === 'aws') { + return 'amber'; + } - if (provider === 'azure') { - return 'blue'; - } + if (provider === 'azure') { + return 'blue'; + } - if (provider === 'gcp') { - return 'emerald'; - } + if (provider === 'gcp') { + return 'emerald'; + } - if (provider === 'cncf') { - return 'cyan'; - } + if (provider === 'cncf') { + return 'cyan'; + } - return 'slate'; + return 'slate'; } -function parseSvgSource(modulePath: string, previewLoader: () => Promise): SvgSource | null { - const normalized = modulePath.replaceAll('\\', '/'); - const match = normalized.match(/assets\/third-party-icons\/([^/]+)\/processed\/(.+)\.svg$/); - - if (!match) { - return null; - } - - const provider = normalizeProviderPathSegment(match[1]); - const relativePath = match[2]; - const pathParts = relativePath.split('/'); - const category = pathParts.length > 1 ? inferLabelFromId(slugify(pathParts[0])) : 'Misc'; - const shapeId = slugify(relativePath.replaceAll('/', '-')); - - return { - provider, - packId: getPackIdForProvider(provider), - shapeId, - label: inferLabelFromId(shapeId), - category, - previewLoader, - }; +function parseSvgSource( + modulePath: string, + previewLoader: () => Promise +): SvgSource | null { + const normalized = modulePath.replaceAll('\\', '/'); + const match = normalized.match(/assets\/third-party-icons\/([^/]+)\/processed\/(.+)\.svg$/); + + if (!match) { + return null; + } + + const provider = normalizeProviderPathSegment(match[1]); + const relativePath = match[2]; + const pathParts = relativePath.split('/'); + const category = pathParts.length > 1 ? inferLabelFromId(slugify(pathParts[0])) : 'Misc'; + const shapeId = slugify(relativePath.replaceAll('/', '-')); + + return { + provider, + packId: getPackIdForProvider(provider), + shapeId, + label: inferLabelFromId(shapeId), + category, + previewLoader, + }; } -const SVG_SOURCES: SvgSource[] = Object.entries(svgModules) - .map(([modulePath, previewLoader]) => parseSvgSource(modulePath, previewLoader)) - .filter((value): value is SvgSource => value !== null); - -function createProviderItem( - provider: DomainLibraryCategory, - source: SvgSource, -): DomainLibraryItem { - return { - id: `${source.packId}:${source.shapeId}`, - category: provider, - label: source.label, - description: `${provider.toUpperCase()} ${source.category}`, - icon: 'Box', - color: getProviderColor(provider), - nodeType: 'custom', - assetPresentation: 'icon', - providerShapeCategory: source.category, - archIconPackId: source.packId, - archIconShapeId: source.shapeId, - }; +export const SVG_SOURCES: SvgSource[] = Object.entries(svgModules) + .map(([modulePath, previewLoader]) => parseSvgSource(modulePath, previewLoader)) + .filter((value): value is SvgSource => value !== null); + +function createProviderItem(provider: DomainLibraryCategory, source: SvgSource): DomainLibraryItem { + return { + id: `${source.packId}:${source.shapeId}`, + category: provider, + label: source.label, + description: `${provider.toUpperCase()} ${source.category}`, + icon: 'Box', + color: getProviderColor(provider), + nodeType: 'custom', + assetPresentation: 'icon', + providerShapeCategory: source.category, + archIconPackId: source.packId, + archIconShapeId: source.shapeId, + }; } export function listProviderCatalogProviders(): string[] { - return Array.from(new Set(SVG_SOURCES.map((source) => source.provider))).sort((left, right) => left.localeCompare(right)); + return Array.from(new Set(SVG_SOURCES.map((source) => source.provider))).sort((left, right) => + left.localeCompare(right) + ); } export function getProviderCatalogCount(provider: DomainLibraryCategory): number { - const normalizedProvider = normalizeProviderPathSegment(provider); - return SVG_SOURCES.filter((source) => source.provider === normalizedProvider).length; + const normalizedProvider = normalizeProviderPathSegment(provider); + return SVG_SOURCES.filter((source) => source.provider === normalizedProvider).length; } -export async function loadProviderCatalog(provider: DomainLibraryCategory): Promise { - const normalizedProvider = normalizeProviderPathSegment(provider); - const existingPromise = providerCatalogPromiseCache.get(normalizedProvider); - if (existingPromise) { - return existingPromise; - } - - const catalogPromise = (async () => { - return SVG_SOURCES - .filter((source) => source.provider === normalizedProvider) - .map((source) => createProviderItem(provider, source)) - .sort((left, right) => ( - left.providerShapeCategory === right.providerShapeCategory - ? left.label.localeCompare(right.label) - : (left.providerShapeCategory || '').localeCompare(right.providerShapeCategory || '') - )); - })(); - - providerCatalogPromiseCache.set(normalizedProvider, catalogPromise); - return catalogPromise; +export async function loadProviderCatalog( + provider: DomainLibraryCategory +): Promise { + const normalizedProvider = normalizeProviderPathSegment(provider); + const existingPromise = providerCatalogPromiseCache.get(normalizedProvider); + if (existingPromise) { + return existingPromise; + } + + const catalogPromise = (async () => { + return SVG_SOURCES.filter((source) => source.provider === normalizedProvider) + .map((source) => createProviderItem(provider, source)) + .sort((left, right) => + left.providerShapeCategory === right.providerShapeCategory + ? left.label.localeCompare(right.label) + : (left.providerShapeCategory || '').localeCompare(right.providerShapeCategory || '') + ); + })(); + + providerCatalogPromiseCache.set(normalizedProvider, catalogPromise); + return catalogPromise; } interface LoadProviderCatalogSuggestionsOptions { - category?: string; - excludeShapeId?: string; - limit?: number; - query?: string; + category?: string; + excludeShapeId?: string; + limit?: number; + query?: string; } export async function loadProviderCatalogSuggestions( - provider: DomainLibraryCategory, - options: LoadProviderCatalogSuggestionsOptions = {}, + provider: DomainLibraryCategory, + options: LoadProviderCatalogSuggestionsOptions = {} ): Promise { - const items = await loadProviderCatalog(provider); - const normalizedQuery = options.query?.trim().toLowerCase() ?? ''; - const filtered = items.filter((item) => { - if (options.excludeShapeId && item.archIconShapeId === options.excludeShapeId) { - return false; - } - if (options.category && item.providerShapeCategory !== options.category) { - return false; - } - if (!normalizedQuery) { - return true; - } - return item.label.toLowerCase().includes(normalizedQuery) - || item.description.toLowerCase().includes(normalizedQuery) - || (item.providerShapeCategory || '').toLowerCase().includes(normalizedQuery); - }); - - const pool = filtered.length > 0 || !options.category - ? filtered - : items.filter((item) => ( - (!options.excludeShapeId || item.archIconShapeId !== options.excludeShapeId) - && (!normalizedQuery - || item.label.toLowerCase().includes(normalizedQuery) - || item.description.toLowerCase().includes(normalizedQuery) - || (item.providerShapeCategory || '').toLowerCase().includes(normalizedQuery)) - )); - - return pool.slice(0, options.limit ?? 8); -} - -export async function loadProviderShapePreview(packId: string, shapeId: string): Promise { - const cacheKey = `${packId}:${shapeId}`; - const cachedPreview = shapePreviewCache.get(cacheKey); - if (cachedPreview) { - return cachedPreview; + const items = await loadProviderCatalog(provider); + const normalizedQuery = options.query?.trim().toLowerCase() ?? ''; + const filtered = items.filter((item) => { + if (options.excludeShapeId && item.archIconShapeId === options.excludeShapeId) { + return false; } - const cachedPromise = shapePreviewPromiseCache.get(cacheKey); - if (cachedPromise) { - return cachedPromise; + if (options.category && item.providerShapeCategory !== options.category) { + return false; } - - const source = SVG_SOURCES.find((candidate) => candidate.packId === packId && candidate.shapeId === shapeId); - if (!source) { - return null; + if (!normalizedQuery) { + return true; } + return ( + item.label.toLowerCase().includes(normalizedQuery) || + item.description.toLowerCase().includes(normalizedQuery) || + (item.providerShapeCategory || '').toLowerCase().includes(normalizedQuery) + ); + }); + + const pool = + filtered.length > 0 || !options.category + ? filtered + : items.filter( + (item) => + (!options.excludeShapeId || item.archIconShapeId !== options.excludeShapeId) && + (!normalizedQuery || + item.label.toLowerCase().includes(normalizedQuery) || + item.description.toLowerCase().includes(normalizedQuery) || + (item.providerShapeCategory || '').toLowerCase().includes(normalizedQuery)) + ); + + return pool.slice(0, options.limit ?? 8); +} + +export async function loadProviderShapePreview( + packId: string, + shapeId: string +): Promise { + const cacheKey = `${packId}:${shapeId}`; + const cachedPreview = shapePreviewCache.get(cacheKey); + if (cachedPreview) { + return cachedPreview; + } + const cachedPromise = shapePreviewPromiseCache.get(cacheKey); + if (cachedPromise) { + return cachedPromise; + } + + const source = SVG_SOURCES.find( + (candidate) => candidate.packId === packId && candidate.shapeId === shapeId + ); + if (!source) { + return null; + } + + const previewPromise = source + .previewLoader() + .then((previewUrl) => { + const preview = { + packId, + shapeId, + label: source.label, + category: source.category, + previewUrl, + }; + shapePreviewCache.set(cacheKey, preview); + shapePreviewPromiseCache.delete(cacheKey); + return preview; + }) + .catch((error) => { + shapePreviewPromiseCache.delete(cacheKey); + throw error; + }); - const previewPromise = source.previewLoader() - .then((previewUrl) => { - const preview = { - packId, - shapeId, - label: source.label, - category: source.category, - previewUrl, - }; - shapePreviewCache.set(cacheKey, preview); - shapePreviewPromiseCache.delete(cacheKey); - return preview; - }) - .catch((error) => { - shapePreviewPromiseCache.delete(cacheKey); - throw error; - }); - - shapePreviewPromiseCache.set(cacheKey, previewPromise); - return previewPromise; + shapePreviewPromiseCache.set(cacheKey, previewPromise); + return previewPromise; } diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 8a4c8a5a..b87453cc 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.test.tsx","./src/app.tsx","./src/constants.test.ts","./src/constants.ts","./src/index.tsx","./src/store.test.ts","./src/store.ts","./src/theme.test.ts","./src/theme.ts","./src/app/routestate.test.ts","./src/app/routestate.ts","./src/components/annotationnode.tsx","./src/components/architecturerulespanel.tsx","./src/components/cinematicexportoverlay.tsx","./src/components/commandbar.test.tsx","./src/components/commandbar.tsx","./src/components/connectmenu.test.tsx","./src/components/connectmenu.tsx","./src/components/connectmenusections.tsx","./src/components/contextmenu.test.tsx","./src/components/contextmenu.tsx","./src/components/customconnectionline.test.tsx","./src/components/customconnectionline.tsx","./src/components/customedge.tsx","./src/components/customnode.handleinteraction.test.tsx","./src/components/customnode.tsx","./src/components/customnodecontent.tsx","./src/components/designsystem.integration.test.tsx","./src/components/diagramviewer.tsx","./src/components/errorboundary.tsx","./src/components/exportmenu.tsx","./src/components/exportmenupanel.test.tsx","./src/components/exportmenupanel.tsx","./src/components/flowcanvas.tsx","./src/components/floweditor.tsx","./src/components/floweditoremptystate.tsx","./src/components/floweditorlayoutoverlay.tsx","./src/components/floweditorpanels.test.tsx","./src/components/floweditorpanels.tsx","./src/components/flowtabs.test.tsx","./src/components/flowtabs.tsx","./src/components/groupnode.tsx","./src/components/homepage.integration.test.tsx","./src/components/homepage.tsx","./src/components/iconassetnodebody.tsx","./src/components/iconmap.test.tsx","./src/components/iconmap.ts","./src/components/imagenode.tsx","./src/components/importrecoverydialog.test.tsx","./src/components/importrecoverydialog.tsx","./src/components/inlinetexteditsurface.test.tsx","./src/components/inlinetexteditsurface.tsx","./src/components/keyboardshortcutsmodal.tsx","./src/components/languageselector.tsx","./src/components/markdownrenderer.tsx","./src/components/memoizedmarkdown.tsx","./src/components/navigationcontrols.tsx","./src/components/nodebadges.tsx","./src/components/nodechrome.tsx","./src/components/nodequickcreatebuttons.tsx","./src/components/nodeshapesvg.tsx","./src/components/nodetransformcontrols.tsx","./src/components/playbackcontrols.tsx","./src/components/propertiespanel.tsx","./src/components/rightrail.tsx","./src/components/sectionnode.tsx","./src/components/shareembedmodal.tsx","./src/components/sharemodal.test.tsx","./src/components/sharemodal.tsx","./src/components/sidebarshell.tsx","./src/components/snapshotspanel.test.tsx","./src/components/snapshotspanel.tsx","./src/components/studioaipanel.test.tsx","./src/components/studioaipanel.tsx","./src/components/studioaipanelsections.tsx","./src/components/studiocodepanel.test.tsx","./src/components/studiocodepanel.tsx","./src/components/studiopanel.test.tsx","./src/components/studiopanel.tsx","./src/components/studioplaybackpanel.test.tsx","./src/components/studioplaybackpanel.tsx","./src/components/swimlanenode.tsx","./src/components/textnode.tsx","./src/components/themetoggle.tsx","./src/components/toolbar.tsx","./src/components/tooltip.tsx","./src/components/topnav.tsx","./src/components/welcomemodal.tsx","./src/components/annotationtheme.ts","./src/components/container-nodes.handleinteraction.test.tsx","./src/components/editorsurfacetiers.ts","./src/components/handleinteraction.test.ts","./src/components/handleinteraction.ts","./src/components/handleinteractionusage.test.ts","./src/components/lightweight-nodes.handleinteraction.test.tsx","./src/components/markdownsyntax.ts","./src/components/nodehelpers.test.ts","./src/components/nodehelpers.ts","./src/components/sharemodalcontent.ts","./src/components/studioaicopy.ts","./src/components/studioaipanelexamples.ts","./src/components/transformdiagnostics.test.ts","./src/components/transformdiagnostics.ts","./src/components/useactivenodeselection.ts","./src/components/useexportmenu.test.tsx","./src/components/useexportmenu.ts","./src/components/settingsmodal/aisettings.tsx","./src/components/settingsmodal/canvassettings.tsx","./src/components/settingsmodal/generalsettings.test.tsx","./src/components/settingsmodal/generalsettings.tsx","./src/components/settingsmodal/settingsmodal.test.tsx","./src/components/settingsmodal/settingsmodal.tsx","./src/components/settingsmodal/shortcutssettings.tsx","./src/components/settingsmodal/ai/advancedendpointsection.tsx","./src/components/settingsmodal/ai/customheaderseditor.tsx","./src/components/settingsmodal/ai/privacysection.tsx","./src/components/settingsmodal/ai/providericon.tsx","./src/components/settingsmodal/ai/providersection.tsx","./src/components/settingsmodal/ai/step.tsx","./src/components/add-items/additemregistry.tsx","./src/components/app/docssiteredirect.test.tsx","./src/components/app/docssiteredirect.tsx","./src/components/app/mobileworkspacegate.test.tsx","./src/components/app/mobileworkspacegate.tsx","./src/components/app/routeloadingfallback.test.tsx","./src/components/app/routeloadingfallback.tsx","./src/components/app/mobileworkspacegatecopy.ts","./src/components/app/routeloadingcopy.ts","./src/components/architecture-lint/lintrulespanel.tsx","./src/components/architecture-lint/ruleform.tsx","./src/components/architecture-lint/visualeditor.tsx","./src/components/command-bar/assetsview.test.tsx","./src/components/command-bar/assetsview.tsx","./src/components/command-bar/codebaseimportpanels.tsx","./src/components/command-bar/codebaseimportsection.tsx","./src/components/command-bar/designsystemeditor.tsx","./src/components/command-bar/designsystemview.tsx","./src/components/command-bar/diagramminipreview.tsx","./src/components/command-bar/figmaimportpanel.tsx","./src/components/command-bar/importsurfaceprimitives.tsx","./src/components/command-bar/importview.tsx","./src/components/command-bar/importviewpanels.tsx","./src/components/command-bar/layersview.tsx","./src/components/command-bar/layoutview.tsx","./src/components/command-bar/pagesview.tsx","./src/components/command-bar/rootview.test.tsx","./src/components/command-bar/rootview.tsx","./src/components/command-bar/searchview.tsx","./src/components/command-bar/templatesview.test.tsx","./src/components/command-bar/templatesview.tsx","./src/components/command-bar/viewheader.tsx","./src/components/command-bar/visualsview.tsx","./src/components/command-bar/applycodechanges.ts","./src/components/command-bar/assetsviewconstants.ts","./src/components/command-bar/importdetection.test.ts","./src/components/command-bar/importdetection.ts","./src/components/command-bar/importnativeparsers.ts","./src/components/command-bar/importviewmodel.test.ts","./src/components/command-bar/importviewmodel.ts","./src/components/command-bar/mermaidimportparser.ts","./src/components/command-bar/searchquery.test.ts","./src/components/command-bar/searchquery.ts","./src/components/command-bar/types.ts","./src/components/command-bar/useaiviewstate.test.tsx","./src/components/command-bar/useaiviewstate.ts","./src/components/command-bar/usecloudassetcatalog.ts","./src/components/command-bar/usecommandbarcommands.test.tsx","./src/components/command-bar/usecommandbarcommands.tsx","./src/components/custom-edge/customedgewrapper.tsx","./src/components/custom-edge/edgemarkerdefs.tsx","./src/components/custom-edge/sequencemessageedge.test.tsx","./src/components/custom-edge/sequencemessageedge.tsx","./src/components/custom-edge/animatededgepresentation.test.ts","./src/components/custom-edge/animatededgepresentation.ts","./src/components/custom-edge/classrelationsemantics.test.ts","./src/components/custom-edge/classrelationsemantics.ts","./src/components/custom-edge/edgerendermode.ts","./src/components/custom-edge/pathutils.test.ts","./src/components/custom-edge/pathutils.ts","./src/components/custom-edge/pathutilsgeometry.ts","./src/components/custom-edge/pathutilssiblingrouting.ts","./src/components/custom-edge/pathutilstypes.ts","./src/components/custom-edge/relationroutingsemantics.test.ts","./src/components/custom-edge/relationroutingsemantics.ts","./src/components/custom-edge/standardedgemarkers.test.ts","./src/components/custom-edge/standardedgemarkers.ts","./src/components/custom-nodes/architecturenode.handleinteraction.test.tsx","./src/components/custom-nodes/architecturenode.tsx","./src/components/custom-nodes/browsernode.tsx","./src/components/custom-nodes/classentitynode.handleinteraction.test.tsx","./src/components/custom-nodes/classnode.tsx","./src/components/custom-nodes/entitynode.tsx","./src/components/custom-nodes/journeynode.tsx","./src/components/custom-nodes/mindmapnode.tsx","./src/components/custom-nodes/mobilenode.tsx","./src/components/custom-nodes/sequencenotenode.tsx","./src/components/custom-nodes/sequenceparticipantnode.tsx","./src/components/custom-nodes/structurednodehandles.tsx","./src/components/custom-nodes/visualnodes.handleinteraction.test.tsx","./src/components/custom-nodes/browservariantrenderer.tsx","./src/components/custom-nodes/mobilevariantrenderer.tsx","./src/components/custom-nodes/structuredlistnavigation.test.ts","./src/components/custom-nodes/structuredlistnavigation.ts","./src/components/custom-nodes/variantconstants.ts","./src/components/diagram-diff/diffmodebanner.tsx","./src/components/flow-canvas/flowcanvasalignmentguidesoverlay.tsx","./src/components/flow-canvas/flowcanvasoverlays.tsx","./src/components/flow-canvas/flowcanvassurface.test.tsx","./src/components/flow-canvas/flowcanvassurface.tsx","./src/components/flow-canvas/streamingoverlay.tsx","./src/components/flow-canvas/alignmentguides.test.ts","./src/components/flow-canvas/alignmentguides.ts","./src/components/flow-canvas/flowcanvasreactflowcontracts.ts","./src/components/flow-canvas/flowcanvastypes.test.ts","./src/components/flow-canvas/flowcanvastypes.tsx","./src/components/flow-canvas/largegraphsafetymode.test.ts","./src/components/flow-canvas/largegraphsafetymode.ts","./src/components/flow-canvas/nodechromecoverage.test.ts","./src/components/flow-canvas/pastehelpers.test.ts","./src/components/flow-canvas/pastehelpers.ts","./src/components/flow-canvas/useflowcanvasalignmentguides.ts","./src/components/flow-canvas/useflowcanvasconnectionstate.ts","./src/components/flow-canvas/useflowcanvascontextactions.ts","./src/components/flow-canvas/useflowcanvasdragdrop.ts","./src/components/flow-canvas/useflowcanvasinteractionlod.ts","./src/components/flow-canvas/useflowcanvasmenus.ts","./src/components/flow-canvas/useflowcanvasmenusandactions.test.tsx","./src/components/flow-canvas/useflowcanvasmenusandactions.ts","./src/components/flow-canvas/useflowcanvasnodedraghandlers.ts","./src/components/flow-canvas/useflowcanvaspaste.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.test.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.ts","./src/components/flow-canvas/useflowcanvasselectiontools.test.tsx","./src/components/flow-canvas/useflowcanvasselectiontools.ts","./src/components/flow-canvas/useflowcanvasviewstate.test.ts","./src/components/flow-canvas/useflowcanvasviewstate.ts","./src/components/flow-canvas/useflowcanvaszoomlod.ts","./src/components/flow-editor/collaborationpresenceoverlay.test.tsx","./src/components/flow-editor/collaborationpresenceoverlay.tsx","./src/components/flow-editor/floweditorchrome.tsx","./src/components/flow-editor/buildfloweditorcontrollerparams.ts","./src/components/flow-editor/buildfloweditorscreencontrollerparams.ts","./src/components/flow-editor/chromeproptypes.ts","./src/components/flow-editor/floweditorchromeprops.ts","./src/components/flow-editor/infradslapply.test.ts","./src/components/flow-editor/infradslapply.ts","./src/components/flow-editor/panelprops.test.ts","./src/components/flow-editor/panelprops.ts","./src/components/flow-editor/shouldexitstudioonselection.test.ts","./src/components/flow-editor/shouldexitstudioonselection.ts","./src/components/flow-editor/usecollaborationnodepositions.ts","./src/components/flow-editor/usefloweditorcontroller.ts","./src/components/flow-editor/usefloweditorinteractionbindings.ts","./src/components/flow-editor/usefloweditorpanelactions.ts","./src/components/flow-editor/usefloweditorpanelprops.ts","./src/components/flow-editor/usefloweditorruntime.ts","./src/components/flow-editor/usefloweditorscreenbehavior.ts","./src/components/flow-editor/usefloweditorscreenmodel.ts","./src/components/flow-editor/usefloweditorscreenstate.ts","./src/components/flow-editor/usefloweditorshellcontroller.test.tsx","./src/components/flow-editor/usefloweditorshellcontroller.ts","./src/components/flow-editor/usefloweditorstudiocontroller.test.tsx","./src/components/flow-editor/usefloweditorstudiocontroller.ts","./src/components/flow-editor/useinfradslapply.ts","./src/components/home/githubcard.tsx","./src/components/home/homedashboard.tsx","./src/components/home/homeflowdialogs.tsx","./src/components/home/homesettingsview.tsx","./src/components/home/homesidebar.tsx","./src/components/home/hometemplatesview.tsx","./src/components/home/sidebarfooter.tsx","./src/components/home/welcomemodalstate.ts","./src/components/icons/assetsicon.tsx","./src/components/icons/openflowlogo.tsx","./src/components/infra-sync/infrasyncpanel.test.tsx","./src/components/infra-sync/infrasyncpanel.tsx","./src/components/journey/journeyscorecontrol.test.tsx","./src/components/journey/journeyscorecontrol.tsx","./src/components/properties/bulknodeproperties.test.tsx","./src/components/properties/bulknodeproperties.tsx","./src/components/properties/bulknodepropertiesfamilysections.tsx","./src/components/properties/bulknodepropertiessections.tsx","./src/components/properties/bulknodepropertiesutilitysections.tsx","./src/components/properties/colorpicker.test.tsx","./src/components/properties/colorpicker.tsx","./src/components/properties/customcolorpopover.tsx","./src/components/properties/diagramnodepropertiesrouter.test.ts","./src/components/properties/diagramnodepropertiesrouter.tsx","./src/components/properties/edgeproperties.tsx","./src/components/properties/iconpicker.tsx","./src/components/properties/icontilepickerprimitives.tsx","./src/components/properties/imageupload.tsx","./src/components/properties/inspectorprimitives.tsx","./src/components/properties/nodeactionbuttons.tsx","./src/components/properties/nodecontentsection.tsx","./src/components/properties/nodeimagesettingssection.tsx","./src/components/properties/nodeproperties.test.tsx","./src/components/properties/nodeproperties.tsx","./src/components/properties/nodewireframevariantsection.tsx","./src/components/properties/propertysliderrow.tsx","./src/components/properties/segmentedchoice.tsx","./src/components/properties/shapeselector.tsx","./src/components/properties/swatchpicker.tsx","./src/components/properties/bulknodepropertiesmodel.test.ts","./src/components/properties/bulknodepropertiesmodel.ts","./src/components/properties/colorpickerutils.ts","./src/components/properties/propertyinputbehavior.test.ts","./src/components/properties/propertyinputbehavior.ts","./src/components/properties/sectionactionbuilder.ts","./src/components/properties/shared.ts","./src/components/properties/wireframevariants.ts","./src/components/properties/edge/architectureedgesemanticssection.tsx","./src/components/properties/edge/edgecolorsection.test.tsx","./src/components/properties/edge/edgecolorsection.tsx","./src/components/properties/edge/edgeconditionsection.tsx","./src/components/properties/edge/edgelabelsection.tsx","./src/components/properties/edge/edgerelationsection.test.tsx","./src/components/properties/edge/edgerelationsection.tsx","./src/components/properties/edge/edgeroutesection.test.tsx","./src/components/properties/edge/edgeroutesection.tsx","./src/components/properties/edge/edgestylesection.tsx","./src/components/properties/edge/sequencemessagesection.tsx","./src/components/properties/edge/architecturesemantics.test.ts","./src/components/properties/edge/architecturesemantics.ts","./src/components/properties/edge/edgecolorutils.ts","./src/components/properties/edge/edgelabelmodel.test.ts","./src/components/properties/edge/edgelabelmodel.ts","./src/components/properties/edge/errelationoptions.ts","./src/components/properties/families/architecturenodeproperties.test.tsx","./src/components/properties/families/architecturenodeproperties.tsx","./src/components/properties/families/architecturenodesection.tsx","./src/components/properties/families/classdiagramnodeproperties.tsx","./src/components/properties/families/classmemberlisteditor.tsx","./src/components/properties/families/classnodesection.tsx","./src/components/properties/families/erdiagramnodeproperties.tsx","./src/components/properties/families/entityfieldlisteditor.tsx","./src/components/properties/families/entitynodesection.tsx","./src/components/properties/families/journeynodeproperties.tsx","./src/components/properties/families/journeynodesection.tsx","./src/components/properties/families/mindmapnodeproperties.test.tsx","./src/components/properties/families/mindmapnodeproperties.tsx","./src/components/properties/families/sequencenodeproperties.tsx","./src/components/properties/families/sequencenodesection.tsx","./src/components/properties/families/specializednodecolorsections.test.tsx","./src/components/properties/families/structuredtextlisteditor.tsx","./src/components/properties/families/architectureoptions.ts","./src/components/studio-code-panel/usestudiocodepanelcontroller.test.tsx","./src/components/studio-code-panel/usestudiocodepanelcontroller.ts","./src/components/templates/templatepresentation.tsx","./src/components/toolbar/toolbaraddmenu.test.tsx","./src/components/toolbar/toolbaraddmenu.tsx","./src/components/toolbar/toolbaraddmenupanel.tsx","./src/components/toolbar/toolbarhistorycontrols.tsx","./src/components/toolbar/toolbarmodecontrols.tsx","./src/components/toolbar/toolbarbuttonstyles.ts","./src/components/top-nav/savestatusindicator.tsx","./src/components/top-nav/topnavactions.tsx","./src/components/top-nav/topnavbrand.tsx","./src/components/top-nav/topnavmenu.test.tsx","./src/components/top-nav/topnavmenu.tsx","./src/components/top-nav/topnavmenupanel.tsx","./src/components/top-nav/usetopnavstate.ts","./src/components/ui/button.tsx","./src/components/ui/collapsiblesection.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/searchfield.tsx","./src/components/ui/segmentedtabs.tsx","./src/components/ui/select.test.tsx","./src/components/ui/select.tsx","./src/components/ui/sidebaritem.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toastcontext.tsx","./src/components/ui/editorfieldstyles.ts","./src/config/aiproviders.test.ts","./src/config/aiproviders.ts","./src/config/rolloutflags.ts","./src/context/architecturelintcontext.tsx","./src/context/cinematicexportcontext.tsx","./src/context/diagramdiffcontext.tsx","./src/context/themecontext.tsx","./src/diagram-types/bootstrap.test.ts","./src/diagram-types/bootstrap.ts","./src/diagram-types/builtinplugins.ts","./src/diagram-types/builtinpropertypanels.ts","./src/diagram-types/registerbuiltinplugins.test.ts","./src/diagram-types/registerbuiltinplugins.ts","./src/diagram-types/registerbuiltinpropertypanels.test.ts","./src/diagram-types/registerbuiltinpropertypanels.ts","./src/diagram-types/architecture/fuzzcorpus.test.ts","./src/diagram-types/architecture/plugin.test.ts","./src/diagram-types/architecture/plugin.ts","./src/diagram-types/classdiagram/fuzzcorpus.test.ts","./src/diagram-types/classdiagram/plugin.test.ts","./src/diagram-types/classdiagram/plugin.ts","./src/diagram-types/core/contracts.ts","./src/diagram-types/core/index.ts","./src/diagram-types/core/propertypanels.test.ts","./src/diagram-types/core/propertypanels.ts","./src/diagram-types/core/registry.ts","./src/diagram-types/erdiagram/fuzzcorpus.test.ts","./src/diagram-types/erdiagram/plugin.test.ts","./src/diagram-types/erdiagram/plugin.ts","./src/diagram-types/flowchart/plugin.ts","./src/diagram-types/journey/fuzzcorpus.test.ts","./src/diagram-types/journey/plugin.test.ts","./src/diagram-types/journey/plugin.ts","./src/diagram-types/mindmap/fuzzcorpus.test.ts","./src/diagram-types/mindmap/plugin.test.ts","./src/diagram-types/mindmap/plugin.ts","./src/diagram-types/sequence/plugin.test.ts","./src/diagram-types/sequence/plugin.ts","./src/diagram-types/statediagram/plugin.test.ts","./src/diagram-types/statediagram/plugin.ts","./src/docs/docsroutes.ts","./src/docs/publicdocscatalog.js","./src/hooks/edgeconnectinteractions.test.ts","./src/hooks/edgeconnectinteractions.ts","./src/hooks/flowexportviewport.test.ts","./src/hooks/flowexportviewport.ts","./src/hooks/mindmaptopicactionrequest.ts","./src/hooks/nodelabeleditrequest.test.ts","./src/hooks/nodelabeleditrequest.ts","./src/hooks/nodequickcreaterequest.test.ts","./src/hooks/nodequickcreaterequest.ts","./src/hooks/snapshotpolicy.test.ts","./src/hooks/snapshotpolicy.ts","./src/hooks/useaigeneration.ts","./src/hooks/useanalyticspreference.ts","./src/hooks/useanimatededgeperformancewarning.test.ts","./src/hooks/useanimatededgeperformancewarning.ts","./src/hooks/useassetcatalog.ts","./src/hooks/usecinematicexport.ts","./src/hooks/useclipboardoperations.ts","./src/hooks/usedesignsystem.ts","./src/hooks/useedgeinteractions.ts","./src/hooks/useedgeoperations.ts","./src/hooks/usefloweditoractions.test.ts","./src/hooks/usefloweditoractions.ts","./src/hooks/usefloweditorcallbacks.ts","./src/hooks/usefloweditorcollaboration.test.ts","./src/hooks/usefloweditorcollaboration.ts","./src/hooks/usefloweditoruistate.ts","./src/hooks/useflowexport.ts","./src/hooks/useflowhistory.test.ts","./src/hooks/useflowhistory.ts","./src/hooks/useflowoperations.ts","./src/hooks/usegithubstars.ts","./src/hooks/useinfrasync.ts","./src/hooks/useinlinenodetextedit.test.ts","./src/hooks/useinlinenodetextedit.ts","./src/hooks/usekeyboardshortcuts.test.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/uselayoutoperations.ts","./src/hooks/usemarkdowneditor.ts","./src/hooks/usemenukeyboardnavigation.ts","./src/hooks/usemodifierkeys.test.ts","./src/hooks/usemodifierkeys.ts","./src/hooks/usenodeoperations.ts","./src/hooks/useplayback.ts","./src/hooks/useprovidershapepreview.ts","./src/hooks/userecentimports.ts","./src/hooks/useshiftheld.ts","./src/hooks/usesnapshots.ts","./src/hooks/usestaticexport.ts","./src/hooks/usestoragepressureguard.ts","./src/hooks/usestructuredlisteditor.ts","./src/hooks/usestyleclipboard.test.ts","./src/hooks/usestyleclipboard.ts","./src/hooks/ai-generation/chathistorystorage.test.ts","./src/hooks/ai-generation/chathistorystorage.ts","./src/hooks/ai-generation/codetoarchitecture.test.ts","./src/hooks/ai-generation/codetoarchitecture.ts","./src/hooks/ai-generation/codebaseanalyzer.test.ts","./src/hooks/ai-generation/codebaseanalyzer.ts","./src/hooks/ai-generation/codebaseparser.test.ts","./src/hooks/ai-generation/codebaseparser.ts","./src/hooks/ai-generation/codebasetonativediagram.test.ts","./src/hooks/ai-generation/codebasetonativediagram.ts","./src/hooks/ai-generation/graphcomposer.test.ts","./src/hooks/ai-generation/graphcomposer.ts","./src/hooks/ai-generation/nodeactionprompts.test.ts","./src/hooks/ai-generation/nodeactionprompts.ts","./src/hooks/ai-generation/openapiparser.test.ts","./src/hooks/ai-generation/openapiparser.ts","./src/hooks/ai-generation/openapitosequence.ts","./src/hooks/ai-generation/positionpreservingapply.test.ts","./src/hooks/ai-generation/positionpreservingapply.ts","./src/hooks/ai-generation/readiness.test.ts","./src/hooks/ai-generation/readiness.ts","./src/hooks/ai-generation/requestlifecycle.test.ts","./src/hooks/ai-generation/requestlifecycle.ts","./src/hooks/ai-generation/sqlparser.test.ts","./src/hooks/ai-generation/sqlparser.ts","./src/hooks/ai-generation/sqltoerd.ts","./src/hooks/ai-generation/streamingparser.ts","./src/hooks/ai-generation/streamingstore.ts","./src/hooks/ai-generation/terraformtocloud.ts","./src/hooks/edge-operations/utils.test.ts","./src/hooks/edge-operations/utils.ts","./src/hooks/flow-editor-actions/exporthandlers.test.ts","./src/hooks/flow-editor-actions/exporthandlers.ts","./src/hooks/flow-editor-actions/helpers.ts","./src/hooks/flow-editor-actions/layouthandlers.test.ts","./src/hooks/flow-editor-actions/layouthandlers.ts","./src/hooks/flow-export/diagramdocumenttransfer.test.ts","./src/hooks/flow-export/diagramdocumenttransfer.ts","./src/hooks/flow-export/exportcapture.test.ts","./src/hooks/flow-export/exportcapture.ts","./src/hooks/flow-operations/useflowcoreactions.ts","./src/hooks/node-operations/createconnectedsibling.ts","./src/hooks/node-operations/dragstopreconcilepolicy.test.ts","./src/hooks/node-operations/dragstopreconcilepolicy.ts","./src/hooks/node-operations/nodefactories.ts","./src/hooks/node-operations/routingduringdrag.test.ts","./src/hooks/node-operations/routingduringdrag.ts","./src/hooks/node-operations/sectionbounds.ts","./src/hooks/node-operations/sectionhittesting.ts","./src/hooks/node-operations/sectionoperations.ts","./src/hooks/node-operations/usearchitecturenodeoperations.test.ts","./src/hooks/node-operations/usearchitecturenodeoperations.ts","./src/hooks/node-operations/usemindmapnodeoperations.test.ts","./src/hooks/node-operations/usemindmapnodeoperations.ts","./src/hooks/node-operations/usenodedragoperations.test.ts","./src/hooks/node-operations/usenodedragoperations.ts","./src/hooks/node-operations/usenodeoperationadders.ts","./src/hooks/node-operations/utils.test.ts","./src/hooks/node-operations/utils.ts","./src/i18n/config.test.ts","./src/i18n/config.ts","./src/i18n/strictmodelocalecoverage.test.ts","./src/i18n/usedlocalecoverage.test.ts","./src/lib/architecturetemplatedata.ts","./src/lib/architecturetemplates.test.ts","./src/lib/architecturetemplates.ts","./src/lib/brand.ts","./src/lib/classmembers.ts","./src/lib/colorutils.ts","./src/lib/connectcreationpolicy.test.ts","./src/lib/connectcreationpolicy.ts","./src/lib/date.ts","./src/lib/designtokens.ts","./src/lib/devperformance.ts","./src/lib/entityfields.ts","./src/lib/ertoclassconversion.test.ts","./src/lib/ertoclassconversion.ts","./src/lib/exportfilename.test.ts","./src/lib/exportfilename.ts","./src/lib/flowminddslparserv2.test.ts","./src/lib/flowminddslparserv2.ts","./src/lib/fuzzymatch.ts","./src/lib/genericshapepolicy.ts","./src/lib/iconresolver.test.ts","./src/lib/iconresolver.ts","./src/lib/id.ts","./src/lib/index.ts","./src/lib/legacybranding.ts","./src/lib/logger.ts","./src/lib/mermaidenrichmentpipeline.test.ts","./src/lib/mermaidparser.ts","./src/lib/mermaidparserhelpers.ts","./src/lib/mermaidparsermodel.ts","./src/lib/mindmaplayout.test.ts","./src/lib/mindmaplayout.ts","./src/lib/mindmaplayoutengine.ts","./src/lib/mindmaptree.ts","./src/lib/nodebulkediting.test.ts","./src/lib/nodebulkediting.ts","./src/lib/nodeenricher.test.ts","./src/lib/nodeenricher.ts","./src/lib/nodehandles.ts","./src/lib/nodeparent.ts","./src/lib/nodestyledata.test.ts","./src/lib/nodestyledata.ts","./src/lib/openflowdslparser.ts","./src/lib/openflowdslparserv2.ts","./src/lib/reactflowcompat.ts","./src/lib/reconnectedge.test.ts","./src/lib/reconnectedge.ts","./src/lib/relationsemantics.test.ts","./src/lib/relationsemantics.ts","./src/lib/releasestaleelkroutes.test.ts","./src/lib/releasestaleelkroutes.ts","./src/lib/result.ts","./src/lib/semanticclassifier.test.ts","./src/lib/semanticclassifier.ts","./src/lib/storagepressure.test.ts","./src/lib/storagepressure.ts","./src/lib/types.ts","./src/lib/xsssafety.test.tsx","./src/services/aligndistribute.ts","./src/services/aiservice.test.ts","./src/services/aiservice.ts","./src/services/aiserviceschemas.ts","./src/services/animatedexport.test.ts","./src/services/animatedexport.ts","./src/services/architectureroundtrip.test.ts","./src/services/assetcatalog.test.ts","./src/services/assetcatalog.ts","./src/services/assetpresentation.ts","./src/services/canonicalserialization.test.ts","./src/services/canonicalserialization.ts","./src/services/composediagramfordisplay.test.ts","./src/services/composediagramfordisplay.ts","./src/services/diagramdocument.test.ts","./src/services/diagramdocument.ts","./src/services/diagramdocumentschemas.ts","./src/services/domainlibrary.test.ts","./src/services/domainlibrary.ts","./src/services/elklayout.test.ts","./src/services/elklayout.ts","./src/services/exportservice.test.ts","./src/services/exportservice.ts","./src/services/figmaexportservice.ts","./src/services/flowchartroundtrip.test.ts","./src/services/geminiservice.ts","./src/services/geminisysteminstruction.ts","./src/services/gifencoder.test.ts","./src/services/gifencoder.ts","./src/services/githubfetcher.test.ts","./src/services/githubfetcher.ts","./src/services/iconassetcatalog.ts","./src/services/importfidelity.test.ts","./src/services/importfidelity.ts","./src/services/mermaidparser.test.ts","./src/services/openflowdslexporter.test.ts","./src/services/openflowdslexporter.ts","./src/services/openflowdslparser.test.ts","./src/services/openflowroundtripgolden.test.ts","./src/services/openflowroundtripgoldenfixtures.ts","./src/services/operationfeedback.ts","./src/services/remainingfamiliesroundtrip.test.ts","./src/services/smartedgerouting.test.ts","./src/services/smartedgerouting.ts","./src/services/statediagramroundtrip.test.ts","./src/services/templates.selector.test.ts","./src/services/templates.ts","./src/services/zipextractor.ts","./src/services/ai/contextserializer.test.ts","./src/services/ai/contextserializer.ts","./src/services/analytics/analytics.ts","./src/services/analytics/analyticssettings.test.ts","./src/services/analytics/analyticssettings.ts","./src/services/analytics/surfaceanalyticsclient.ts","./src/services/architecturelint/defaultrules.ts","./src/services/architecturelint/ruleengine.test.ts","./src/services/architecturelint/ruleengine.ts","./src/services/architecturelint/rulelibrary.ts","./src/services/architecturelint/types.ts","./src/services/architecturelint/workspacerules.ts","./src/services/collaboration/bootstrap.test.ts","./src/services/collaboration/bootstrap.ts","./src/services/collaboration/canvasdiff.test.ts","./src/services/collaboration/canvasdiff.ts","./src/services/collaboration/comments.test.ts","./src/services/collaboration/comments.ts","./src/services/collaboration/contracts.test.ts","./src/services/collaboration/contracts.ts","./src/services/collaboration/hookutils.ts","./src/services/collaboration/operationlog.test.ts","./src/services/collaboration/operationlog.ts","./src/services/collaboration/presenceviewmodel.test.ts","./src/services/collaboration/presenceviewmodel.ts","./src/services/collaboration/reducer.test.ts","./src/services/collaboration/reducer.ts","./src/services/collaboration/roomconfig.ts","./src/services/collaboration/roomlink.test.ts","./src/services/collaboration/roomlink.ts","./src/services/collaboration/runtimecontroller.test.ts","./src/services/collaboration/runtimecontroller.ts","./src/services/collaboration/runtimehookutils.test.ts","./src/services/collaboration/runtimehookutils.ts","./src/services/collaboration/schemas.ts","./src/services/collaboration/session.test.ts","./src/services/collaboration/session.ts","./src/services/collaboration/storebridge.test.ts","./src/services/collaboration/storebridge.ts","./src/services/collaboration/transport.test.ts","./src/services/collaboration/transport.ts","./src/services/collaboration/transportfactory.test.ts","./src/services/collaboration/transportfactory.ts","./src/services/collaboration/types.ts","./src/services/collaboration/versioning.test.ts","./src/services/collaboration/versioning.ts","./src/services/collaboration/yjspeertransport.test.ts","./src/services/collaboration/yjspeertransport.ts","./src/services/diagramdiff/diffengine.ts","./src/services/elk-layout/boundaryfanout.ts","./src/services/elk-layout/determinism.ts","./src/services/elk-layout/options.ts","./src/services/elk-layout/types.ts","./src/services/export/cinematicbuildplan.test.ts","./src/services/export/cinematicbuildplan.ts","./src/services/export/cinematicexport.ts","./src/services/export/cinematicexporttheme.test.ts","./src/services/export/cinematicexporttheme.ts","./src/services/export/cinematicrenderstate.test.ts","./src/services/export/cinematicrenderstate.ts","./src/services/export/formatting.ts","./src/services/export/mermaidbuilder.test.ts","./src/services/export/mermaidbuilder.ts","./src/services/export/pdfdocument.test.ts","./src/services/export/pdfdocument.ts","./src/services/export/plantumlbuilder.ts","./src/services/export/mermaid/architecturemermaid.ts","./src/services/export/mermaid/classdiagrammermaid.ts","./src/services/export/mermaid/erdiagrammermaid.ts","./src/services/export/mermaid/journeymermaid.ts","./src/services/export/mermaid/mindmapmermaid.ts","./src/services/export/mermaid/sequencemermaid.ts","./src/services/export/mermaid/statediagrammermaid.ts","./src/services/figma/edgehelpers.ts","./src/services/figma/iconhelpers.ts","./src/services/figma/nodelayers.ts","./src/services/figma/themehelpers.ts","./src/services/figmaimport/figmaapiclient.ts","./src/services/flowpilot/assetgrounding.test.ts","./src/services/flowpilot/assetgrounding.ts","./src/services/flowpilot/prompting.ts","./src/services/flowpilot/responsepolicy.test.ts","./src/services/flowpilot/responsepolicy.ts","./src/services/flowpilot/skills.ts","./src/services/flowpilot/thread.ts","./src/services/flowpilot/types.ts","./src/services/infrasync/dockercomposeparser.test.ts","./src/services/infrasync/dockercomposeparser.ts","./src/services/infrasync/infratodsl.test.ts","./src/services/infrasync/infratodsl.ts","./src/services/infrasync/kubernetesparser.test.ts","./src/services/infrasync/kubernetesparser.ts","./src/services/infrasync/terraformstateparser.test.ts","./src/services/infrasync/terraformstateparser.ts","./src/services/infrasync/types.ts","./src/services/mermaid/detectdiagramtype.test.ts","./src/services/mermaid/detectdiagramtype.ts","./src/services/mermaid/diagnosticformatting.test.ts","./src/services/mermaid/diagnosticformatting.ts","./src/services/mermaid/parsemermaidbytype.test.ts","./src/services/mermaid/parsemermaidbytype.ts","./src/services/mermaid/strictmodediagnosticspresentation.test.ts","./src/services/mermaid/strictmodediagnosticspresentation.ts","./src/services/mermaid/strictmodeguidance.test.ts","./src/services/mermaid/strictmodeguidance.ts","./src/services/mermaid/strictmodeuxregression.test.ts","./src/services/offline/registerappshellserviceworker.test.ts","./src/services/offline/registerappshellserviceworker.ts","./src/services/onboarding/config.ts","./src/services/onboarding/eventschemas.ts","./src/services/onboarding/events.test.ts","./src/services/onboarding/events.ts","./src/services/playback/contracts.test.ts","./src/services/playback/contracts.ts","./src/services/playback/model.test.ts","./src/services/playback/model.ts","./src/services/playback/studio.test.ts","./src/services/playback/studio.ts","./src/services/sequence/sequencemessage.test.ts","./src/services/sequence/sequencemessage.ts","./src/services/shapelibrary/bootstrap.test.ts","./src/services/shapelibrary/bootstrap.ts","./src/services/shapelibrary/ingestionpipeline.test.ts","./src/services/shapelibrary/ingestionpipeline.ts","./src/services/shapelibrary/manifestvalidation.test.ts","./src/services/shapelibrary/manifestvalidation.ts","./src/services/shapelibrary/providercatalog.test.ts","./src/services/shapelibrary/providercatalog.ts","./src/services/shapelibrary/registry.test.ts","./src/services/shapelibrary/registry.ts","./src/services/shapelibrary/starterpacks.ts","./src/services/shapelibrary/types.ts","./src/services/storage/fallbackstorage.ts","./src/services/storage/flowdocumentmodel.test.ts","./src/services/storage/flowdocumentmodel.ts","./src/services/storage/flowpersiststorage.test.ts","./src/services/storage/flowpersiststorage.ts","./src/services/storage/indexeddbhelpers.ts","./src/services/storage/indexeddbschema.test.ts","./src/services/storage/indexeddbschema.ts","./src/services/storage/indexeddbstatestorage.test.ts","./src/services/storage/indexeddbstatestorage.ts","./src/services/storage/localfirstrepository.ts","./src/services/storage/localfirstruntime.ts","./src/services/storage/persisteddocumentadapters.test.ts","./src/services/storage/persisteddocumentadapters.ts","./src/services/storage/persistencetypes.ts","./src/services/storage/snapshotstorage.test.ts","./src/services/storage/snapshotstorage.ts","./src/services/storage/storageruntime.test.ts","./src/services/storage/storageruntime.ts","./src/services/storage/storageschemas.test.ts","./src/services/storage/storageschemas.ts","./src/services/storage/storagetelemetry.test.ts","./src/services/storage/storagetelemetry.ts","./src/services/storage/storagetelemetrysink.test.ts","./src/services/storage/storagetelemetrysink.ts","./src/services/storage/uilocalstorage.test.ts","./src/services/storage/uilocalstorage.ts","./src/services/templatelibrary/registry.test.ts","./src/services/templatelibrary/registry.ts","./src/services/templatelibrary/startertemplates.assets.test.ts","./src/services/templatelibrary/startertemplates.test.ts","./src/services/templatelibrary/startertemplates.ts","./src/services/templatelibrary/templatefactories.ts","./src/services/templatelibrary/types.ts","./src/store/actionfactory.ts","./src/store/aisettings.test.ts","./src/store/aisettings.ts","./src/store/aisettingspersistence.ts","./src/store/aisettingsschemas.test.ts","./src/store/aisettingsschemas.ts","./src/store/canvashooks.ts","./src/store/createflowstore.test.ts","./src/store/createflowstore.ts","./src/store/createflowstorepersistoptions.ts","./src/store/createflowstorestate.ts","./src/store/defaults.ts","./src/store/designsystemhooks.ts","./src/store/documenthooks.ts","./src/store/documentstatesync.test.ts","./src/store/documentstatesync.ts","./src/store/editorpagehooks.ts","./src/store/historyhooks.ts","./src/store/historystate.ts","./src/store/persistence.test.ts","./src/store/persistence.ts","./src/store/persistenceschemas.ts","./src/store/selectionhooks.ts","./src/store/selectors.test.ts","./src/store/selectors.ts","./src/store/tabhooks.ts","./src/store/types.ts","./src/store/viewhooks.ts","./src/store/workspacedocumentmodel.test.ts","./src/store/workspacedocumentmodel.ts","./src/store/actions/createaiandselectionactions.ts","./src/store/actions/createcanvasactions.ts","./src/store/actions/createdesignsystemactions.ts","./src/store/actions/createhistoryactions.test.ts","./src/store/actions/createhistoryactions.ts","./src/store/actions/createlayeractions.ts","./src/store/actions/createtabactions.ts","./src/store/actions/createviewactions.ts","./src/store/actions/createworkspacedocumentactions.ts","./src/store/actions/synctabnodesedges.ts","./src/store/slices/createcanvaseditorslice.ts","./src/store/slices/createexperienceslice.ts","./src/store/slices/createworkspaceslice.ts"],"version":"5.8.3"} \ No newline at end of file +{"root":["./src/app.test.tsx","./src/app.tsx","./src/constants.test.ts","./src/constants.ts","./src/index.tsx","./src/store.test.ts","./src/store.ts","./src/theme.test.ts","./src/theme.ts","./src/app/routestate.test.ts","./src/app/routestate.ts","./src/components/annotationnode.tsx","./src/components/architecturerulespanel.tsx","./src/components/cinematicexportoverlay.tsx","./src/components/commandbar.test.tsx","./src/components/commandbar.tsx","./src/components/connectmenu.test.tsx","./src/components/connectmenu.tsx","./src/components/connectmenusections.tsx","./src/components/contextmenu.test.tsx","./src/components/contextmenu.tsx","./src/components/customconnectionline.test.tsx","./src/components/customconnectionline.tsx","./src/components/customedge.tsx","./src/components/customnode.handleinteraction.test.tsx","./src/components/customnode.tsx","./src/components/customnodecontent.tsx","./src/components/designsystem.integration.test.tsx","./src/components/diagramviewer.tsx","./src/components/errorboundary.tsx","./src/components/exportmenu.tsx","./src/components/exportmenupanel.test.tsx","./src/components/exportmenupanel.tsx","./src/components/flowcanvas.tsx","./src/components/floweditor.tsx","./src/components/floweditoremptystate.tsx","./src/components/floweditorlayoutoverlay.tsx","./src/components/floweditorpanels.test.tsx","./src/components/floweditorpanels.tsx","./src/components/flowtabs.test.tsx","./src/components/flowtabs.tsx","./src/components/groupnode.tsx","./src/components/homepage.integration.test.tsx","./src/components/homepage.tsx","./src/components/iconassetnodebody.tsx","./src/components/iconmap.test.tsx","./src/components/iconmap.ts","./src/components/imagenode.tsx","./src/components/importrecoverydialog.test.tsx","./src/components/importrecoverydialog.tsx","./src/components/inlinetexteditsurface.test.tsx","./src/components/inlinetexteditsurface.tsx","./src/components/keyboardshortcutsmodal.tsx","./src/components/languageselector.tsx","./src/components/markdownrenderer.tsx","./src/components/memoizedmarkdown.tsx","./src/components/navigationcontrols.tsx","./src/components/nodebadges.tsx","./src/components/nodechrome.tsx","./src/components/nodequickcreatebuttons.tsx","./src/components/nodeshapesvg.tsx","./src/components/nodetransformcontrols.tsx","./src/components/playbackcontrols.tsx","./src/components/propertiespanel.tsx","./src/components/rightrail.tsx","./src/components/sectionnode.tsx","./src/components/shareembedmodal.tsx","./src/components/sharemodal.test.tsx","./src/components/sharemodal.tsx","./src/components/sidebarshell.tsx","./src/components/snapshotspanel.test.tsx","./src/components/snapshotspanel.tsx","./src/components/studioaipanel.test.tsx","./src/components/studioaipanel.tsx","./src/components/studioaipanelsections.tsx","./src/components/studiocodepanel.test.tsx","./src/components/studiocodepanel.tsx","./src/components/studiopanel.test.tsx","./src/components/studiopanel.tsx","./src/components/studioplaybackpanel.test.tsx","./src/components/studioplaybackpanel.tsx","./src/components/swimlanenode.tsx","./src/components/textnode.tsx","./src/components/themetoggle.tsx","./src/components/toolbar.tsx","./src/components/tooltip.tsx","./src/components/topnav.tsx","./src/components/welcomemodal.tsx","./src/components/annotationtheme.ts","./src/components/container-nodes.handleinteraction.test.tsx","./src/components/editorsurfacetiers.ts","./src/components/handleinteraction.test.ts","./src/components/handleinteraction.ts","./src/components/handleinteractionusage.test.ts","./src/components/lightweight-nodes.handleinteraction.test.tsx","./src/components/markdownsyntax.ts","./src/components/nodehelpers.test.ts","./src/components/nodehelpers.ts","./src/components/sharemodalcontent.ts","./src/components/studioaicopy.ts","./src/components/studioaipanelexamples.ts","./src/components/transformdiagnostics.test.ts","./src/components/transformdiagnostics.ts","./src/components/useactivenodeselection.ts","./src/components/useexportmenu.test.tsx","./src/components/useexportmenu.ts","./src/components/settingsmodal/aisettings.tsx","./src/components/settingsmodal/canvassettings.tsx","./src/components/settingsmodal/generalsettings.test.tsx","./src/components/settingsmodal/generalsettings.tsx","./src/components/settingsmodal/settingsmodal.test.tsx","./src/components/settingsmodal/settingsmodal.tsx","./src/components/settingsmodal/shortcutssettings.tsx","./src/components/settingsmodal/ai/advancedendpointsection.tsx","./src/components/settingsmodal/ai/customheaderseditor.tsx","./src/components/settingsmodal/ai/privacysection.tsx","./src/components/settingsmodal/ai/providericon.tsx","./src/components/settingsmodal/ai/providersection.tsx","./src/components/settingsmodal/ai/step.tsx","./src/components/add-items/additemregistry.tsx","./src/components/app/docssiteredirect.test.tsx","./src/components/app/docssiteredirect.tsx","./src/components/app/mobileworkspacegate.test.tsx","./src/components/app/mobileworkspacegate.tsx","./src/components/app/routeloadingfallback.test.tsx","./src/components/app/routeloadingfallback.tsx","./src/components/app/mobileworkspacegatecopy.ts","./src/components/app/routeloadingcopy.ts","./src/components/architecture-lint/lintrulespanel.tsx","./src/components/architecture-lint/ruleform.tsx","./src/components/architecture-lint/visualeditor.tsx","./src/components/command-bar/assetsview.test.tsx","./src/components/command-bar/assetsview.tsx","./src/components/command-bar/codebaseimportpanels.tsx","./src/components/command-bar/codebaseimportsection.tsx","./src/components/command-bar/designsystemeditor.tsx","./src/components/command-bar/designsystemview.tsx","./src/components/command-bar/diagramminipreview.tsx","./src/components/command-bar/figmaimportpanel.tsx","./src/components/command-bar/importsurfaceprimitives.tsx","./src/components/command-bar/importview.tsx","./src/components/command-bar/importviewpanels.tsx","./src/components/command-bar/layersview.tsx","./src/components/command-bar/layoutview.tsx","./src/components/command-bar/pagesview.tsx","./src/components/command-bar/rootview.test.tsx","./src/components/command-bar/rootview.tsx","./src/components/command-bar/searchview.tsx","./src/components/command-bar/templatesview.test.tsx","./src/components/command-bar/templatesview.tsx","./src/components/command-bar/viewheader.tsx","./src/components/command-bar/visualsview.tsx","./src/components/command-bar/applycodechanges.ts","./src/components/command-bar/assetsviewconstants.ts","./src/components/command-bar/importdetection.test.ts","./src/components/command-bar/importdetection.ts","./src/components/command-bar/importnativeparsers.ts","./src/components/command-bar/importviewmodel.test.ts","./src/components/command-bar/importviewmodel.ts","./src/components/command-bar/mermaidimportparser.ts","./src/components/command-bar/searchquery.test.ts","./src/components/command-bar/searchquery.ts","./src/components/command-bar/types.ts","./src/components/command-bar/useaiviewstate.test.tsx","./src/components/command-bar/useaiviewstate.ts","./src/components/command-bar/usecloudassetcatalog.ts","./src/components/command-bar/usecommandbarcommands.test.tsx","./src/components/command-bar/usecommandbarcommands.tsx","./src/components/custom-edge/customedgewrapper.tsx","./src/components/custom-edge/edgemarkerdefs.tsx","./src/components/custom-edge/sequencemessageedge.test.tsx","./src/components/custom-edge/sequencemessageedge.tsx","./src/components/custom-edge/animatededgepresentation.test.ts","./src/components/custom-edge/animatededgepresentation.ts","./src/components/custom-edge/classrelationsemantics.test.ts","./src/components/custom-edge/classrelationsemantics.ts","./src/components/custom-edge/edgerendermode.ts","./src/components/custom-edge/pathutils.test.ts","./src/components/custom-edge/pathutils.ts","./src/components/custom-edge/pathutilsgeometry.ts","./src/components/custom-edge/pathutilssiblingrouting.ts","./src/components/custom-edge/pathutilstypes.ts","./src/components/custom-edge/relationroutingsemantics.test.ts","./src/components/custom-edge/relationroutingsemantics.ts","./src/components/custom-edge/standardedgemarkers.test.ts","./src/components/custom-edge/standardedgemarkers.ts","./src/components/custom-nodes/architecturenode.handleinteraction.test.tsx","./src/components/custom-nodes/architecturenode.tsx","./src/components/custom-nodes/browsernode.tsx","./src/components/custom-nodes/classentitynode.handleinteraction.test.tsx","./src/components/custom-nodes/classnode.tsx","./src/components/custom-nodes/entitynode.tsx","./src/components/custom-nodes/journeynode.tsx","./src/components/custom-nodes/mindmapnode.tsx","./src/components/custom-nodes/mobilenode.tsx","./src/components/custom-nodes/sequencenotenode.tsx","./src/components/custom-nodes/sequenceparticipantnode.tsx","./src/components/custom-nodes/structurednodehandles.tsx","./src/components/custom-nodes/visualnodes.handleinteraction.test.tsx","./src/components/custom-nodes/browservariantrenderer.tsx","./src/components/custom-nodes/mobilevariantrenderer.tsx","./src/components/custom-nodes/structuredlistnavigation.test.ts","./src/components/custom-nodes/structuredlistnavigation.ts","./src/components/custom-nodes/variantconstants.ts","./src/components/diagram-diff/diffmodebanner.tsx","./src/components/flow-canvas/flowcanvasalignmentguidesoverlay.tsx","./src/components/flow-canvas/flowcanvasoverlays.tsx","./src/components/flow-canvas/flowcanvassurface.test.tsx","./src/components/flow-canvas/flowcanvassurface.tsx","./src/components/flow-canvas/streamingoverlay.tsx","./src/components/flow-canvas/alignmentguides.test.ts","./src/components/flow-canvas/alignmentguides.ts","./src/components/flow-canvas/flowcanvasreactflowcontracts.ts","./src/components/flow-canvas/flowcanvastypes.test.ts","./src/components/flow-canvas/flowcanvastypes.tsx","./src/components/flow-canvas/largegraphsafetymode.test.ts","./src/components/flow-canvas/largegraphsafetymode.ts","./src/components/flow-canvas/nodechromecoverage.test.ts","./src/components/flow-canvas/pastehelpers.test.ts","./src/components/flow-canvas/pastehelpers.ts","./src/components/flow-canvas/useflowcanvasalignmentguides.ts","./src/components/flow-canvas/useflowcanvasconnectionstate.ts","./src/components/flow-canvas/useflowcanvascontextactions.ts","./src/components/flow-canvas/useflowcanvasdragdrop.ts","./src/components/flow-canvas/useflowcanvasinteractionlod.ts","./src/components/flow-canvas/useflowcanvasmenus.ts","./src/components/flow-canvas/useflowcanvasmenusandactions.test.tsx","./src/components/flow-canvas/useflowcanvasmenusandactions.ts","./src/components/flow-canvas/useflowcanvasnodedraghandlers.ts","./src/components/flow-canvas/useflowcanvaspaste.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.test.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.ts","./src/components/flow-canvas/useflowcanvasselectiontools.test.tsx","./src/components/flow-canvas/useflowcanvasselectiontools.ts","./src/components/flow-canvas/useflowcanvasviewstate.test.ts","./src/components/flow-canvas/useflowcanvasviewstate.ts","./src/components/flow-canvas/useflowcanvaszoomlod.ts","./src/components/flow-editor/collaborationpresenceoverlay.test.tsx","./src/components/flow-editor/collaborationpresenceoverlay.tsx","./src/components/flow-editor/floweditorchrome.tsx","./src/components/flow-editor/buildfloweditorcontrollerparams.ts","./src/components/flow-editor/buildfloweditorscreencontrollerparams.ts","./src/components/flow-editor/chromeproptypes.ts","./src/components/flow-editor/floweditorchromeprops.ts","./src/components/flow-editor/infradslapply.test.ts","./src/components/flow-editor/infradslapply.ts","./src/components/flow-editor/panelprops.test.ts","./src/components/flow-editor/panelprops.ts","./src/components/flow-editor/shouldexitstudioonselection.test.ts","./src/components/flow-editor/shouldexitstudioonselection.ts","./src/components/flow-editor/usecollaborationnodepositions.ts","./src/components/flow-editor/usefloweditorcontroller.ts","./src/components/flow-editor/usefloweditorinteractionbindings.ts","./src/components/flow-editor/usefloweditorpanelactions.ts","./src/components/flow-editor/usefloweditorpanelprops.ts","./src/components/flow-editor/usefloweditorruntime.ts","./src/components/flow-editor/usefloweditorscreenbehavior.ts","./src/components/flow-editor/usefloweditorscreenmodel.ts","./src/components/flow-editor/usefloweditorscreenstate.ts","./src/components/flow-editor/usefloweditorshellcontroller.test.tsx","./src/components/flow-editor/usefloweditorshellcontroller.ts","./src/components/flow-editor/usefloweditorstudiocontroller.test.tsx","./src/components/flow-editor/usefloweditorstudiocontroller.ts","./src/components/flow-editor/useinfradslapply.ts","./src/components/home/githubcard.tsx","./src/components/home/homedashboard.tsx","./src/components/home/homeflowdialogs.tsx","./src/components/home/homesettingsview.tsx","./src/components/home/homesidebar.tsx","./src/components/home/hometemplatesview.tsx","./src/components/home/sidebarfooter.tsx","./src/components/home/welcomemodalstate.ts","./src/components/icons/assetsicon.tsx","./src/components/icons/openflowlogo.tsx","./src/components/infra-sync/infrasyncpanel.test.tsx","./src/components/infra-sync/infrasyncpanel.tsx","./src/components/journey/journeyscorecontrol.test.tsx","./src/components/journey/journeyscorecontrol.tsx","./src/components/properties/bulknodeproperties.test.tsx","./src/components/properties/bulknodeproperties.tsx","./src/components/properties/bulknodepropertiesfamilysections.tsx","./src/components/properties/bulknodepropertiessections.tsx","./src/components/properties/bulknodepropertiesutilitysections.tsx","./src/components/properties/colorpicker.test.tsx","./src/components/properties/colorpicker.tsx","./src/components/properties/customcolorpopover.tsx","./src/components/properties/diagramnodepropertiesrouter.test.ts","./src/components/properties/diagramnodepropertiesrouter.tsx","./src/components/properties/edgeproperties.tsx","./src/components/properties/iconpicker.tsx","./src/components/properties/icontilepickerprimitives.tsx","./src/components/properties/imageupload.tsx","./src/components/properties/inspectorprimitives.tsx","./src/components/properties/nodeactionbuttons.tsx","./src/components/properties/nodecontentsection.tsx","./src/components/properties/nodeimagesettingssection.tsx","./src/components/properties/nodeproperties.test.tsx","./src/components/properties/nodeproperties.tsx","./src/components/properties/nodewireframevariantsection.tsx","./src/components/properties/propertysliderrow.tsx","./src/components/properties/segmentedchoice.tsx","./src/components/properties/shapeselector.tsx","./src/components/properties/swatchpicker.tsx","./src/components/properties/bulknodepropertiesmodel.test.ts","./src/components/properties/bulknodepropertiesmodel.ts","./src/components/properties/colorpickerutils.ts","./src/components/properties/propertyinputbehavior.test.ts","./src/components/properties/propertyinputbehavior.ts","./src/components/properties/sectionactionbuilder.ts","./src/components/properties/shared.ts","./src/components/properties/wireframevariants.ts","./src/components/properties/edge/architectureedgesemanticssection.tsx","./src/components/properties/edge/edgecolorsection.test.tsx","./src/components/properties/edge/edgecolorsection.tsx","./src/components/properties/edge/edgeconditionsection.tsx","./src/components/properties/edge/edgelabelsection.tsx","./src/components/properties/edge/edgerelationsection.test.tsx","./src/components/properties/edge/edgerelationsection.tsx","./src/components/properties/edge/edgeroutesection.test.tsx","./src/components/properties/edge/edgeroutesection.tsx","./src/components/properties/edge/edgestylesection.tsx","./src/components/properties/edge/sequencemessagesection.tsx","./src/components/properties/edge/architecturesemantics.test.ts","./src/components/properties/edge/architecturesemantics.ts","./src/components/properties/edge/edgecolorutils.ts","./src/components/properties/edge/edgelabelmodel.test.ts","./src/components/properties/edge/edgelabelmodel.ts","./src/components/properties/edge/errelationoptions.ts","./src/components/properties/families/architecturenodeproperties.test.tsx","./src/components/properties/families/architecturenodeproperties.tsx","./src/components/properties/families/architecturenodesection.tsx","./src/components/properties/families/classdiagramnodeproperties.tsx","./src/components/properties/families/classmemberlisteditor.tsx","./src/components/properties/families/classnodesection.tsx","./src/components/properties/families/erdiagramnodeproperties.tsx","./src/components/properties/families/entityfieldlisteditor.tsx","./src/components/properties/families/entitynodesection.tsx","./src/components/properties/families/journeynodeproperties.tsx","./src/components/properties/families/journeynodesection.tsx","./src/components/properties/families/mindmapnodeproperties.test.tsx","./src/components/properties/families/mindmapnodeproperties.tsx","./src/components/properties/families/sequencenodeproperties.tsx","./src/components/properties/families/sequencenodesection.tsx","./src/components/properties/families/specializednodecolorsections.test.tsx","./src/components/properties/families/structuredtextlisteditor.tsx","./src/components/properties/families/architectureoptions.ts","./src/components/studio-code-panel/usestudiocodepanelcontroller.test.tsx","./src/components/studio-code-panel/usestudiocodepanelcontroller.ts","./src/components/templates/templatepresentation.tsx","./src/components/toolbar/toolbaraddmenu.test.tsx","./src/components/toolbar/toolbaraddmenu.tsx","./src/components/toolbar/toolbaraddmenupanel.tsx","./src/components/toolbar/toolbarhistorycontrols.tsx","./src/components/toolbar/toolbarmodecontrols.tsx","./src/components/toolbar/toolbarbuttonstyles.ts","./src/components/top-nav/savestatusindicator.tsx","./src/components/top-nav/topnavactions.tsx","./src/components/top-nav/topnavbrand.tsx","./src/components/top-nav/topnavmenu.test.tsx","./src/components/top-nav/topnavmenu.tsx","./src/components/top-nav/topnavmenupanel.tsx","./src/components/top-nav/usetopnavstate.ts","./src/components/ui/button.tsx","./src/components/ui/collapsiblesection.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/searchfield.tsx","./src/components/ui/segmentedtabs.tsx","./src/components/ui/select.test.tsx","./src/components/ui/select.tsx","./src/components/ui/sidebaritem.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toastcontext.tsx","./src/components/ui/editorfieldstyles.ts","./src/config/aiproviders.test.ts","./src/config/aiproviders.ts","./src/config/rolloutflags.ts","./src/context/architecturelintcontext.tsx","./src/context/cinematicexportcontext.tsx","./src/context/diagramdiffcontext.tsx","./src/context/themecontext.tsx","./src/diagram-types/bootstrap.test.ts","./src/diagram-types/bootstrap.ts","./src/diagram-types/builtinplugins.ts","./src/diagram-types/builtinpropertypanels.ts","./src/diagram-types/registerbuiltinplugins.test.ts","./src/diagram-types/registerbuiltinplugins.ts","./src/diagram-types/registerbuiltinpropertypanels.test.ts","./src/diagram-types/registerbuiltinpropertypanels.ts","./src/diagram-types/architecture/fuzzcorpus.test.ts","./src/diagram-types/architecture/plugin.test.ts","./src/diagram-types/architecture/plugin.ts","./src/diagram-types/classdiagram/fuzzcorpus.test.ts","./src/diagram-types/classdiagram/plugin.test.ts","./src/diagram-types/classdiagram/plugin.ts","./src/diagram-types/core/contracts.ts","./src/diagram-types/core/index.ts","./src/diagram-types/core/propertypanels.test.ts","./src/diagram-types/core/propertypanels.ts","./src/diagram-types/core/registry.ts","./src/diagram-types/erdiagram/fuzzcorpus.test.ts","./src/diagram-types/erdiagram/plugin.test.ts","./src/diagram-types/erdiagram/plugin.ts","./src/diagram-types/flowchart/plugin.ts","./src/diagram-types/journey/fuzzcorpus.test.ts","./src/diagram-types/journey/plugin.test.ts","./src/diagram-types/journey/plugin.ts","./src/diagram-types/mindmap/fuzzcorpus.test.ts","./src/diagram-types/mindmap/plugin.test.ts","./src/diagram-types/mindmap/plugin.ts","./src/diagram-types/sequence/plugin.test.ts","./src/diagram-types/sequence/plugin.ts","./src/diagram-types/statediagram/plugin.test.ts","./src/diagram-types/statediagram/plugin.ts","./src/docs/docsroutes.ts","./src/docs/publicdocscatalog.js","./src/hooks/edgeconnectinteractions.test.ts","./src/hooks/edgeconnectinteractions.ts","./src/hooks/flowexportviewport.test.ts","./src/hooks/flowexportviewport.ts","./src/hooks/mindmaptopicactionrequest.ts","./src/hooks/nodelabeleditrequest.test.ts","./src/hooks/nodelabeleditrequest.ts","./src/hooks/nodequickcreaterequest.test.ts","./src/hooks/nodequickcreaterequest.ts","./src/hooks/snapshotpolicy.test.ts","./src/hooks/snapshotpolicy.ts","./src/hooks/useaigeneration.ts","./src/hooks/useanalyticspreference.ts","./src/hooks/useanimatededgeperformancewarning.test.ts","./src/hooks/useanimatededgeperformancewarning.ts","./src/hooks/useassetcatalog.ts","./src/hooks/usecinematicexport.ts","./src/hooks/useclipboardoperations.ts","./src/hooks/usedesignsystem.ts","./src/hooks/useedgeinteractions.ts","./src/hooks/useedgeoperations.ts","./src/hooks/usefloweditoractions.test.ts","./src/hooks/usefloweditoractions.ts","./src/hooks/usefloweditorcallbacks.ts","./src/hooks/usefloweditorcollaboration.test.ts","./src/hooks/usefloweditorcollaboration.ts","./src/hooks/usefloweditoruistate.ts","./src/hooks/useflowexport.ts","./src/hooks/useflowhistory.test.ts","./src/hooks/useflowhistory.ts","./src/hooks/useflowoperations.ts","./src/hooks/usegithubstars.ts","./src/hooks/useinfrasync.ts","./src/hooks/useinlinenodetextedit.test.ts","./src/hooks/useinlinenodetextedit.ts","./src/hooks/usekeyboardshortcuts.test.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/uselayoutoperations.ts","./src/hooks/usemarkdowneditor.ts","./src/hooks/usemenukeyboardnavigation.ts","./src/hooks/usemodifierkeys.test.ts","./src/hooks/usemodifierkeys.ts","./src/hooks/usenodeoperations.ts","./src/hooks/useplayback.ts","./src/hooks/useprovidershapepreview.ts","./src/hooks/userecentimports.ts","./src/hooks/useshiftheld.ts","./src/hooks/usesnapshots.ts","./src/hooks/usestaticexport.ts","./src/hooks/usestoragepressureguard.ts","./src/hooks/usestructuredlisteditor.ts","./src/hooks/usestyleclipboard.test.ts","./src/hooks/usestyleclipboard.ts","./src/hooks/ai-generation/chathistorystorage.test.ts","./src/hooks/ai-generation/chathistorystorage.ts","./src/hooks/ai-generation/codetoarchitecture.test.ts","./src/hooks/ai-generation/codetoarchitecture.ts","./src/hooks/ai-generation/codebaseanalyzer.test.ts","./src/hooks/ai-generation/codebaseanalyzer.ts","./src/hooks/ai-generation/codebaseparser.test.ts","./src/hooks/ai-generation/codebaseparser.ts","./src/hooks/ai-generation/codebasetonativediagram.test.ts","./src/hooks/ai-generation/codebasetonativediagram.ts","./src/hooks/ai-generation/graphcomposer.test.ts","./src/hooks/ai-generation/graphcomposer.ts","./src/hooks/ai-generation/nodeactionprompts.test.ts","./src/hooks/ai-generation/nodeactionprompts.ts","./src/hooks/ai-generation/openapiparser.test.ts","./src/hooks/ai-generation/openapiparser.ts","./src/hooks/ai-generation/openapitosequence.ts","./src/hooks/ai-generation/positionpreservingapply.test.ts","./src/hooks/ai-generation/positionpreservingapply.ts","./src/hooks/ai-generation/readiness.test.ts","./src/hooks/ai-generation/readiness.ts","./src/hooks/ai-generation/requestlifecycle.test.ts","./src/hooks/ai-generation/requestlifecycle.ts","./src/hooks/ai-generation/sqlparser.test.ts","./src/hooks/ai-generation/sqlparser.ts","./src/hooks/ai-generation/sqltoerd.ts","./src/hooks/ai-generation/streamingparser.ts","./src/hooks/ai-generation/streamingstore.ts","./src/hooks/ai-generation/terraformtocloud.ts","./src/hooks/edge-operations/utils.test.ts","./src/hooks/edge-operations/utils.ts","./src/hooks/flow-editor-actions/exporthandlers.test.ts","./src/hooks/flow-editor-actions/exporthandlers.ts","./src/hooks/flow-editor-actions/helpers.ts","./src/hooks/flow-editor-actions/layouthandlers.test.ts","./src/hooks/flow-editor-actions/layouthandlers.ts","./src/hooks/flow-export/diagramdocumenttransfer.test.ts","./src/hooks/flow-export/diagramdocumenttransfer.ts","./src/hooks/flow-export/exportcapture.test.ts","./src/hooks/flow-export/exportcapture.ts","./src/hooks/flow-operations/useflowcoreactions.ts","./src/hooks/node-operations/createconnectedsibling.ts","./src/hooks/node-operations/dragstopreconcilepolicy.test.ts","./src/hooks/node-operations/dragstopreconcilepolicy.ts","./src/hooks/node-operations/nodefactories.ts","./src/hooks/node-operations/routingduringdrag.test.ts","./src/hooks/node-operations/routingduringdrag.ts","./src/hooks/node-operations/sectionbounds.ts","./src/hooks/node-operations/sectionhittesting.ts","./src/hooks/node-operations/sectionoperations.ts","./src/hooks/node-operations/usearchitecturenodeoperations.test.ts","./src/hooks/node-operations/usearchitecturenodeoperations.ts","./src/hooks/node-operations/usemindmapnodeoperations.test.ts","./src/hooks/node-operations/usemindmapnodeoperations.ts","./src/hooks/node-operations/usenodedragoperations.test.ts","./src/hooks/node-operations/usenodedragoperations.ts","./src/hooks/node-operations/usenodeoperationadders.ts","./src/hooks/node-operations/utils.test.ts","./src/hooks/node-operations/utils.ts","./src/i18n/config.test.ts","./src/i18n/config.ts","./src/i18n/strictmodelocalecoverage.test.ts","./src/i18n/usedlocalecoverage.test.ts","./src/lib/aiiconspipeline.test.ts","./src/lib/architecturetemplatedata.ts","./src/lib/architecturetemplates.test.ts","./src/lib/architecturetemplates.ts","./src/lib/brand.ts","./src/lib/classmembers.ts","./src/lib/colorutils.ts","./src/lib/connectcreationpolicy.test.ts","./src/lib/connectcreationpolicy.ts","./src/lib/date.ts","./src/lib/designtokens.ts","./src/lib/devperformance.ts","./src/lib/entityfields.ts","./src/lib/ertoclassconversion.test.ts","./src/lib/ertoclassconversion.ts","./src/lib/exportfilename.test.ts","./src/lib/exportfilename.ts","./src/lib/flowminddslparserv2.test.ts","./src/lib/flowminddslparserv2.ts","./src/lib/fuzzymatch.ts","./src/lib/genericshapepolicy.ts","./src/lib/iconmatcher.test.ts","./src/lib/iconmatcher.ts","./src/lib/iconresolver.test.ts","./src/lib/iconresolver.ts","./src/lib/id.ts","./src/lib/index.ts","./src/lib/legacybranding.ts","./src/lib/logger.ts","./src/lib/mermaidenrichmentpipeline.test.ts","./src/lib/mermaidparser.ts","./src/lib/mermaidparserhelpers.ts","./src/lib/mermaidparsermodel.ts","./src/lib/mindmaplayout.test.ts","./src/lib/mindmaplayout.ts","./src/lib/mindmaplayoutengine.ts","./src/lib/mindmaptree.ts","./src/lib/nodebulkediting.test.ts","./src/lib/nodebulkediting.ts","./src/lib/nodeenricher.test.ts","./src/lib/nodeenricher.ts","./src/lib/nodehandles.ts","./src/lib/nodeparent.ts","./src/lib/nodestyledata.test.ts","./src/lib/nodestyledata.ts","./src/lib/openflowdslparser.ts","./src/lib/openflowdslparserv2.ts","./src/lib/reactflowcompat.ts","./src/lib/reconnectedge.test.ts","./src/lib/reconnectedge.ts","./src/lib/relationsemantics.test.ts","./src/lib/relationsemantics.ts","./src/lib/releasestaleelkroutes.test.ts","./src/lib/releasestaleelkroutes.ts","./src/lib/result.ts","./src/lib/semanticclassifier.test.ts","./src/lib/semanticclassifier.ts","./src/lib/storagepressure.test.ts","./src/lib/storagepressure.ts","./src/lib/types.ts","./src/lib/xsssafety.test.tsx","./src/services/aligndistribute.ts","./src/services/aiservice.test.ts","./src/services/aiservice.ts","./src/services/aiserviceschemas.ts","./src/services/animatedexport.test.ts","./src/services/animatedexport.ts","./src/services/architectureroundtrip.test.ts","./src/services/assetcatalog.test.ts","./src/services/assetcatalog.ts","./src/services/assetpresentation.ts","./src/services/canonicalserialization.test.ts","./src/services/canonicalserialization.ts","./src/services/composediagramfordisplay.test.ts","./src/services/composediagramfordisplay.ts","./src/services/diagramdocument.test.ts","./src/services/diagramdocument.ts","./src/services/diagramdocumentschemas.ts","./src/services/domainlibrary.test.ts","./src/services/domainlibrary.ts","./src/services/elklayout.test.ts","./src/services/elklayout.ts","./src/services/exportservice.test.ts","./src/services/exportservice.ts","./src/services/figmaexportservice.ts","./src/services/flowchartroundtrip.test.ts","./src/services/geminiservice.ts","./src/services/geminisysteminstruction.ts","./src/services/gifencoder.test.ts","./src/services/gifencoder.ts","./src/services/githubfetcher.test.ts","./src/services/githubfetcher.ts","./src/services/iconassetcatalog.ts","./src/services/importfidelity.test.ts","./src/services/importfidelity.ts","./src/services/mermaidparser.test.ts","./src/services/openflowdslexporter.test.ts","./src/services/openflowdslexporter.ts","./src/services/openflowdslparser.test.ts","./src/services/openflowroundtripgolden.test.ts","./src/services/openflowroundtripgoldenfixtures.ts","./src/services/operationfeedback.ts","./src/services/remainingfamiliesroundtrip.test.ts","./src/services/smartedgerouting.test.ts","./src/services/smartedgerouting.ts","./src/services/statediagramroundtrip.test.ts","./src/services/templates.selector.test.ts","./src/services/templates.ts","./src/services/zipextractor.ts","./src/services/ai/contextserializer.test.ts","./src/services/ai/contextserializer.ts","./src/services/analytics/analytics.ts","./src/services/analytics/analyticssettings.test.ts","./src/services/analytics/analyticssettings.ts","./src/services/analytics/surfaceanalyticsclient.ts","./src/services/architecturelint/defaultrules.ts","./src/services/architecturelint/ruleengine.test.ts","./src/services/architecturelint/ruleengine.ts","./src/services/architecturelint/rulelibrary.ts","./src/services/architecturelint/types.ts","./src/services/architecturelint/workspacerules.ts","./src/services/collaboration/bootstrap.test.ts","./src/services/collaboration/bootstrap.ts","./src/services/collaboration/canvasdiff.test.ts","./src/services/collaboration/canvasdiff.ts","./src/services/collaboration/comments.test.ts","./src/services/collaboration/comments.ts","./src/services/collaboration/contracts.test.ts","./src/services/collaboration/contracts.ts","./src/services/collaboration/hookutils.ts","./src/services/collaboration/operationlog.test.ts","./src/services/collaboration/operationlog.ts","./src/services/collaboration/presenceviewmodel.test.ts","./src/services/collaboration/presenceviewmodel.ts","./src/services/collaboration/reducer.test.ts","./src/services/collaboration/reducer.ts","./src/services/collaboration/roomconfig.ts","./src/services/collaboration/roomlink.test.ts","./src/services/collaboration/roomlink.ts","./src/services/collaboration/runtimecontroller.test.ts","./src/services/collaboration/runtimecontroller.ts","./src/services/collaboration/runtimehookutils.test.ts","./src/services/collaboration/runtimehookutils.ts","./src/services/collaboration/schemas.ts","./src/services/collaboration/session.test.ts","./src/services/collaboration/session.ts","./src/services/collaboration/storebridge.test.ts","./src/services/collaboration/storebridge.ts","./src/services/collaboration/transport.test.ts","./src/services/collaboration/transport.ts","./src/services/collaboration/transportfactory.test.ts","./src/services/collaboration/transportfactory.ts","./src/services/collaboration/types.ts","./src/services/collaboration/versioning.test.ts","./src/services/collaboration/versioning.ts","./src/services/collaboration/yjspeertransport.test.ts","./src/services/collaboration/yjspeertransport.ts","./src/services/diagramdiff/diffengine.ts","./src/services/elk-layout/boundaryfanout.ts","./src/services/elk-layout/determinism.ts","./src/services/elk-layout/options.ts","./src/services/elk-layout/types.ts","./src/services/export/cinematicbuildplan.test.ts","./src/services/export/cinematicbuildplan.ts","./src/services/export/cinematicexport.ts","./src/services/export/cinematicexporttheme.test.ts","./src/services/export/cinematicexporttheme.ts","./src/services/export/cinematicrenderstate.test.ts","./src/services/export/cinematicrenderstate.ts","./src/services/export/formatting.ts","./src/services/export/mermaidbuilder.test.ts","./src/services/export/mermaidbuilder.ts","./src/services/export/mermaidexportquality.test.ts","./src/services/export/pdfdocument.test.ts","./src/services/export/pdfdocument.ts","./src/services/export/plantumlbuilder.ts","./src/services/export/mermaid/architecturemermaid.ts","./src/services/export/mermaid/classdiagrammermaid.ts","./src/services/export/mermaid/erdiagrammermaid.ts","./src/services/export/mermaid/journeymermaid.ts","./src/services/export/mermaid/mindmapmermaid.ts","./src/services/export/mermaid/sequencemermaid.ts","./src/services/export/mermaid/statediagrammermaid.ts","./src/services/figma/edgehelpers.ts","./src/services/figma/iconhelpers.ts","./src/services/figma/nodelayers.ts","./src/services/figma/themehelpers.ts","./src/services/figmaimport/figmaapiclient.ts","./src/services/flowpilot/assetgrounding.test.ts","./src/services/flowpilot/assetgrounding.ts","./src/services/flowpilot/prompting.ts","./src/services/flowpilot/responsepolicy.test.ts","./src/services/flowpilot/responsepolicy.ts","./src/services/flowpilot/skills.ts","./src/services/flowpilot/thread.ts","./src/services/flowpilot/types.ts","./src/services/infrasync/dockercomposeparser.test.ts","./src/services/infrasync/dockercomposeparser.ts","./src/services/infrasync/infratodsl.test.ts","./src/services/infrasync/infratodsl.ts","./src/services/infrasync/kubernetesparser.test.ts","./src/services/infrasync/kubernetesparser.ts","./src/services/infrasync/terraformstateparser.test.ts","./src/services/infrasync/terraformstateparser.ts","./src/services/infrasync/types.ts","./src/services/mermaid/detectdiagramtype.test.ts","./src/services/mermaid/detectdiagramtype.ts","./src/services/mermaid/diagnosticformatting.test.ts","./src/services/mermaid/diagnosticformatting.ts","./src/services/mermaid/parsemermaidbytype.test.ts","./src/services/mermaid/parsemermaidbytype.ts","./src/services/mermaid/strictmodediagnosticspresentation.test.ts","./src/services/mermaid/strictmodediagnosticspresentation.ts","./src/services/mermaid/strictmodeguidance.test.ts","./src/services/mermaid/strictmodeguidance.ts","./src/services/mermaid/strictmodeuxregression.test.ts","./src/services/offline/registerappshellserviceworker.test.ts","./src/services/offline/registerappshellserviceworker.ts","./src/services/onboarding/config.ts","./src/services/onboarding/eventschemas.ts","./src/services/onboarding/events.test.ts","./src/services/onboarding/events.ts","./src/services/playback/contracts.test.ts","./src/services/playback/contracts.ts","./src/services/playback/model.test.ts","./src/services/playback/model.ts","./src/services/playback/studio.test.ts","./src/services/playback/studio.ts","./src/services/sequence/sequencemessage.test.ts","./src/services/sequence/sequencemessage.ts","./src/services/shapelibrary/bootstrap.test.ts","./src/services/shapelibrary/bootstrap.ts","./src/services/shapelibrary/ingestionpipeline.test.ts","./src/services/shapelibrary/ingestionpipeline.ts","./src/services/shapelibrary/manifestvalidation.test.ts","./src/services/shapelibrary/manifestvalidation.ts","./src/services/shapelibrary/providercatalog.test.ts","./src/services/shapelibrary/providercatalog.ts","./src/services/shapelibrary/registry.test.ts","./src/services/shapelibrary/registry.ts","./src/services/shapelibrary/starterpacks.ts","./src/services/shapelibrary/types.ts","./src/services/storage/fallbackstorage.ts","./src/services/storage/flowdocumentmodel.test.ts","./src/services/storage/flowdocumentmodel.ts","./src/services/storage/flowpersiststorage.test.ts","./src/services/storage/flowpersiststorage.ts","./src/services/storage/indexeddbhelpers.ts","./src/services/storage/indexeddbschema.test.ts","./src/services/storage/indexeddbschema.ts","./src/services/storage/indexeddbstatestorage.test.ts","./src/services/storage/indexeddbstatestorage.ts","./src/services/storage/localfirstrepository.ts","./src/services/storage/localfirstruntime.ts","./src/services/storage/persisteddocumentadapters.test.ts","./src/services/storage/persisteddocumentadapters.ts","./src/services/storage/persistencetypes.ts","./src/services/storage/snapshotstorage.test.ts","./src/services/storage/snapshotstorage.ts","./src/services/storage/storageruntime.test.ts","./src/services/storage/storageruntime.ts","./src/services/storage/storageschemas.test.ts","./src/services/storage/storageschemas.ts","./src/services/storage/storagetelemetry.test.ts","./src/services/storage/storagetelemetry.ts","./src/services/storage/storagetelemetrysink.test.ts","./src/services/storage/storagetelemetrysink.ts","./src/services/storage/uilocalstorage.test.ts","./src/services/storage/uilocalstorage.ts","./src/services/templatelibrary/registry.test.ts","./src/services/templatelibrary/registry.ts","./src/services/templatelibrary/startertemplates.assets.test.ts","./src/services/templatelibrary/startertemplates.test.ts","./src/services/templatelibrary/startertemplates.ts","./src/services/templatelibrary/templatefactories.ts","./src/services/templatelibrary/types.ts","./src/store/actionfactory.ts","./src/store/aisettings.test.ts","./src/store/aisettings.ts","./src/store/aisettingspersistence.ts","./src/store/aisettingsschemas.test.ts","./src/store/aisettingsschemas.ts","./src/store/canvashooks.ts","./src/store/createflowstore.test.ts","./src/store/createflowstore.ts","./src/store/createflowstorepersistoptions.ts","./src/store/createflowstorestate.ts","./src/store/defaults.ts","./src/store/designsystemhooks.ts","./src/store/documenthooks.ts","./src/store/documentstatesync.test.ts","./src/store/documentstatesync.ts","./src/store/editorpagehooks.ts","./src/store/historyhooks.ts","./src/store/historystate.ts","./src/store/persistence.test.ts","./src/store/persistence.ts","./src/store/persistenceschemas.ts","./src/store/selectionhooks.ts","./src/store/selectors.test.ts","./src/store/selectors.ts","./src/store/tabhooks.ts","./src/store/types.ts","./src/store/viewhooks.ts","./src/store/workspacedocumentmodel.test.ts","./src/store/workspacedocumentmodel.ts","./src/store/actions/createaiandselectionactions.ts","./src/store/actions/createcanvasactions.ts","./src/store/actions/createdesignsystemactions.ts","./src/store/actions/createhistoryactions.test.ts","./src/store/actions/createhistoryactions.ts","./src/store/actions/createlayeractions.ts","./src/store/actions/createtabactions.ts","./src/store/actions/createviewactions.ts","./src/store/actions/createworkspacedocumentactions.ts","./src/store/actions/synctabnodesedges.ts","./src/store/slices/createcanvaseditorslice.ts","./src/store/slices/createexperienceslice.ts","./src/store/slices/createworkspaceslice.ts"],"version":"5.8.3"} \ No newline at end of file From a69d676c9e26020cf664c5b11728147300743672 Mon Sep 17 00:00:00 2001 From: Varun Date: Wed, 8 Apr 2026 10:50:36 +0530 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20production-quality=20mermaid=20impo?= =?UTF-8?q?rt=20=E2=80=94=20icons,=20layout,=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Icon enrichment: - Raise import threshold to 0.92; add isTrustedImportMatch gate for substring matches - Skip enrichment entirely for stateDiagram, sequence, classDiagram, erDiagram, journey - For flowchart: only enrich on specific technology proper nouns, not generic English words - No Lucide fallback in import mode — generic nodes get no icon rather than a random one - Add CNCF coverage: Istio, Cilium, Linkerd, Helm, ArgoCD, FluxCD, Dapr, Prometheus, Jaeger, OpenTelemetry, Grafana, Datadog, Sentry, Vault, Keycloak, etcd, Harbor, Falco - Add Azure coverage: Cosmos DB, Service Bus, Event Hubs, AKS, Functions, OpenAI, Monitor, Key Vault, Container Apps, API Management, Cognitive Services, DevOps, Front Door Layout: - Auto-select algorithm per graph topology: cyclic → force/stress, wide fan-out → mrtree, hierarchical → layered; removes hardcoded 'layered' from import path - ELK compound layout for groups (elk.hierarchyHandling: INCLUDE_CHILDREN) - Normalize ELK absolute→relative coords for children inside sections - Dynamic layer ordering for architecture diagrams from subgraph labels - Spacing by node count: ≤10 → loose, ≤25 → normal, >25 → compact - Stagger parallel edge labels across 0.3–0.7 range to prevent midpoint pile-up - Self-loop safety in applyElkHandles - Unified handleSideFromVector helper shared between elkLayout and smartEdgeRouting Parser: - Ampersand edge expansion: A & B --> C & D produces all source×target pairs - Subgraph end guard: pop only when parentStack is non-empty - context-aware arrow detection: skips arrows inside quotes, pipes, brackets - normalizeEdgeLabels: collapse extended arrows, inline-label forms Plugin quality: - stateDiagram: note syntax, [*] initial/final states, fork/join - sequence: fragment (alt/loop/opt/par) and note nodes rendered visually - journey: score-based color coding - classDiagram: generics , visibility modifiers, cardinality - erDiagram: PK/FK/UK field type and constraint parsing - architecture: dynamic semantic layer order Co-Authored-By: Claude Sonnet 4.6 --- GROUPS_AUDIT.md | 267 +++++ MERMAID_IMPORT_AUDIT.md | 971 ++++++++++++++++++ .../flow-canvas/useFlowCanvasPaste.ts | 41 +- src/diagram-types/architecture/plugin.ts | 36 + src/diagram-types/classDiagram/plugin.test.ts | 17 + src/diagram-types/classDiagram/plugin.ts | 90 +- src/diagram-types/erDiagram/plugin.test.ts | 41 +- src/diagram-types/erDiagram/plugin.ts | 57 +- src/diagram-types/journey/plugin.test.ts | 22 +- src/diagram-types/journey/plugin.ts | 13 +- src/diagram-types/sequence/plugin.test.ts | 21 +- src/diagram-types/sequence/plugin.ts | 80 +- src/diagram-types/stateDiagram/plugin.test.ts | 38 +- src/diagram-types/stateDiagram/plugin.ts | 156 ++- src/lib/iconMatcher.test.ts | 21 + src/lib/iconMatcher.ts | 276 ++++- src/lib/mermaidEnrichmentPipeline.test.ts | 7 +- src/lib/mermaidParser.ts | 7 +- src/lib/mermaidParserHelpers.ts | 224 +++- src/lib/nodeEnricher.test.ts | 78 ++ src/lib/nodeEnricher.ts | 221 +++- src/lib/nodeIconState.test.ts | 68 ++ src/lib/nodeIconState.ts | 146 +++ src/lib/semanticClassifier.test.ts | 37 +- src/lib/semanticClassifier.ts | 378 ++++++- src/services/composeDiagramForDisplay.ts | 92 +- src/services/elk-layout/options.ts | 372 +++---- src/services/elkLayout.test.ts | 233 ++++- src/services/elkLayout.ts | 433 ++++++-- .../mermaid/parseMermaidByType.test.ts | 73 +- src/services/smartEdgeRouting.ts | 84 +- 31 files changed, 3994 insertions(+), 606 deletions(-) create mode 100644 GROUPS_AUDIT.md create mode 100644 MERMAID_IMPORT_AUDIT.md create mode 100644 src/lib/nodeIconState.test.ts create mode 100644 src/lib/nodeIconState.ts diff --git a/GROUPS_AUDIT.md b/GROUPS_AUDIT.md new file mode 100644 index 00000000..af8373ac --- /dev/null +++ b/GROUPS_AUDIT.md @@ -0,0 +1,267 @@ +# Groups & Sections — Audit & Fix Plan +**Date:** 2026-04-07 + +--- + +## The Three Problems + +1. **Nodes can't escape groups** — once inside a group, you're trapped +2. **Nodes overlap inside groups** — layout doesn't space children properly +3. **Groups don't resize correctly** — wrong padding, too small, floating header ignored + +All three have clear root causes in the code. None require architectural changes. + +--- + +## Root Causes + +### Problem 1: Can't Escape Groups + +**File:** `src/lib/nodeParent.ts:15-21` + +`setNodeParent()` unconditionally sets `extent: 'parent'` on every parented node: + +```typescript +export function setNodeParent(node: T, parentId: string): T { + return { + ...node, + parentId, + extent: 'parent' as const, // always set — physically locks node in React Flow + } as T; +} +``` + +React Flow enforces `extent: 'parent'` at the DOM level during drag. There's no escape hatch. `clearNodeParent()` exists but is only called in specific delete/ungroup scenarios — not when a user drags to the boundary. + +The drag-stop handler (`useNodeDragOperations.ts:98-180`) calls `applySectionParenting()` which re-parents nodes when they land inside a section, but never un-parents them when they land outside. So moving outward just snaps back. + +**Also:** Mermaid-parsed subgraph children get `extent: 'parent'` immediately at parse time (`mermaidParser.ts:273`) — before the user ever interacts. + +--- + +### Problem 2: Nodes Overlap Inside Groups + +**Three compounding causes:** + +**2A. ELK doesn't lay out children of parent nodes** + +`src/services/elkLayout.ts:104-112` — `buildElkNode()` sets `width` and `height` to `undefined` for nodes that have children: + +```typescript +return { + id: node.id, + width: children.length === 0 ? width : undefined, // undefined for groups + height: children.length === 0 ? height : undefined, // undefined for groups + children: children.map(...), + layoutOptions: { 'elk.padding': '[top=40,left=20,bottom=20,right=20]' }, +}; +``` + +ELK receives a group node with `width/height = undefined` and children that also have their own positions. ELK auto-sizes the group to wrap the children, but it **doesn't re-layout the children** — it just wraps around wherever they are. If children have positions from a previous pass or default to 0,0, they all stack at the top-left. + +**2B. ELK outputs absolute coordinates, React Flow expects relative** + +`src/services/elkLayout.ts:164-196` — `buildPositionMap()` collects positions as **absolute canvas coordinates** from ELK. But React Flow requires child nodes to have **positions relative to their parent's top-left corner**. + +The apply step (`elkLayout.ts:482`) uses these coordinates as-is: +```typescript +position: { x: position.x, y: position.y }, // absolute, not relative to parent +``` + +So a child at absolute position (500, 300) inside a parent at (400, 200) gets placed at (500, 300) relative to the parent — which puts it at absolute position (900, 500) on the canvas. Completely wrong. + +**2C. No compound layout options in ELK** + +`src/services/elk-layout/options.ts:91-144` — ELK config has no hierarchical layout options: +- No `elk.hierarchyHandling` +- No per-group layout direction +- No `elk.separateConnectedComponents` + +ELK is configured as a flat graph layouter, not a compound graph layouter. + +--- + +### Problem 3: Groups Don't Resize Correctly + +**3A. Padding mismatch** + +ELK padding (set in `elkLayout.ts:110`): `top=40, left=20, bottom=20, right=20` +Section rendering padding (`sectionBounds.ts:5-11`): `SECTION_PADDING_X = 32`, `SECTION_PADDING_BOTTOM = 32` +Global ELK padding (`options.ts:190`): `top=50, left=50, bottom=50, right=50` + +Three different values in three different places. ELK sizes the group with 20px padding, the UI renders with 32px padding, and the hit-testing uses 32px. Every calculation is off. + +**3B. Floating header not in ELK's model** + +`src/components/SectionNode.tsx:46-97` — the section title floats **above** the border with `top: -36`. ELK doesn't know about this. Children get positioned without the 36px header offset, so they render under the title. + +**3C. Mermaid-parsed sections start undersized** + +`src/lib/mermaidParser.ts:267`: sections created with `style: { width: 400, height: 300 }`. +`src/hooks/node-operations/sectionBounds.ts`: `SECTION_MIN_WIDTH = 500`, `SECTION_MIN_HEIGHT = 400`. + +Parsed sections start smaller than the minimum. The first render always looks cramped. + +**3D. `fitSectionToChildren()` is reactive, not automatic** + +`sectionOperations.ts:25-85` — `fitSectionToChildren()` only runs when explicitly called (after drag-drop). It doesn't run after ELK layout. So after a Mermaid import, sections aren't fitted — children sit inside an undersized parent. + +--- + +## Fix Plan + +### Fix 1 — Allow Nodes to Escape Groups (drag to boundary = exit) + +**File:** `src/hooks/node-operations/useNodeDragOperations.ts` + `sectionOperations.ts` + +In `onNodeDragStop()`, after the drag ends: +1. Check if the dragged node's **drop position is outside its current parent's bounds** +2. If yes → call `clearNodeParent(node)` to remove `parentId` and `extent` +3. If no → current `applySectionParenting()` logic stays the same + +The check: use `getSectionContentBounds(parentSection, allNodes)` and test if the node's center is outside it. If outside → un-parent. + +Also fix mermaid-parsed nodes: don't set `extent: 'parent'` at parse time. Set it only when the node is actually placed inside a section during layout (`mermaidParser.ts:273`). Let ELK position them, then apply parenting after layout positions are resolved. + +**What this enables:** drag a node to the edge of a group and release → it pops out. The group auto-resizes to exclude it. + +--- + +### Fix 2 — Fix Child Positioning (absolute → relative conversion) + +**File:** `src/services/elkLayout.ts` — `buildPositionMap()` and the apply step + +ELK outputs absolute coordinates. React Flow needs relative. The fix is to subtract the parent's absolute position from each child's position: + +```typescript +// After collecting positionMap from ELK: +for (const node of nodes) { + if (node.parentId) { + const parentPos = positionMap.get(node.parentId); + const childPos = positionMap.get(node.id); + if (parentPos && childPos) { + positionMap.set(node.id, { + ...childPos, + x: childPos.x - parentPos.x, + y: childPos.y - parentPos.y, + }); + } + } +} +``` + +This needs to handle nested parents recursively (grandchild positions need both parent and grandparent subtracted). Use a depth-first traversal: process parents before children, accumulate offset. + +--- + +### Fix 3 — Enable ELK Compound Layout for Groups + +**File:** `src/services/elkLayout.ts` — `buildElkNode()` + +Add ELK hierarchical layout options to group nodes: + +```typescript +// For nodes with children: +layoutOptions: { + 'elk.padding': `[top=${SECTION_HEADER_HEIGHT + SECTION_PADDING_TOP},left=${SECTION_PADDING_X},bottom=${SECTION_PADDING_BOTTOM},right=${SECTION_PADDING_X}]`, + 'elk.algorithm': 'layered', // lay out children with layered algorithm + 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', // treat group as compound node + 'elk.direction': resolvedDirection, // inherit diagram direction +}, +``` + +With `elk.hierarchyHandling: INCLUDE_CHILDREN`, ELK will actually space children inside the group instead of wrapping around their existing positions. + +--- + +### Fix 4 — Unify Padding Constants + +**Files:** `src/services/elkLayout.ts`, `src/services/elk-layout/options.ts`, `src/hooks/node-operations/sectionBounds.ts` + +Import `SECTION_PADDING_X`, `SECTION_PADDING_BOTTOM`, `SECTION_HEADER_HEIGHT` from `sectionBounds.ts` into the ELK layout files. Use these constants everywhere instead of hardcoded numbers. + +```typescript +// In elkLayout.ts, replace: +'elk.padding': '[top=40,left=20,bottom=20,right=20]' + +// With: +import { SECTION_PADDING_X, SECTION_PADDING_BOTTOM, SECTION_HEADER_HEIGHT } from '@/hooks/node-operations/sectionBounds'; +// top = header height (36) + content top padding (16) +`'elk.padding': '[top=${SECTION_HEADER_HEIGHT + 16},left=${SECTION_PADDING_X},bottom=${SECTION_PADDING_BOTTOM},right=${SECTION_PADDING_X}]'` +``` + +Single source of truth. Change it once, applies everywhere. + +--- + +### Fix 5 — Fix Initial Section Size from Mermaid + +**File:** `src/lib/mermaidParser.ts:267` + +Change initial section dimensions to match the actual minimums: + +```typescript +// Before: +style: { width: 400, height: 300 } + +// After: +style: { width: SECTION_MIN_WIDTH, height: SECTION_MIN_HEIGHT } +``` + +Import `SECTION_MIN_WIDTH`, `SECTION_MIN_HEIGHT` from `sectionBounds.ts`. + +--- + +### Fix 6 — Auto-fit Sections After ELK Layout + +**File:** wherever `getElkLayout()` result is applied (likely `useFlowCanvasPaste.ts` or equivalent) + +After ELK positions are applied to nodes, call `autoFitSectionsToChildren()` automatically. This resizes each section to wrap its children with correct padding — regardless of whether ELK got the size exactly right. + +```typescript +const { nodes: laidOutNodes, edges: laidOutEdges } = await getElkLayout(enrichedNodes, edges, options); +const fittedNodes = autoFitSectionsToChildren(laidOutNodes); // add this +setNodes(fittedNodes); +setEdges(laidOutEdges); +``` + +This is a safety net — even if ELK's group sizing is slightly off, the final result is always correctly fitted. + +--- + +## Fix Priority & Order + +Do in this order — each fix makes the next one safer: + +| # | Fix | File(s) | Risk | Impact | +|---|-----|---------|------|--------| +| 1 | Unify padding constants (Fix 4) | `elkLayout.ts`, `options.ts` | Low | Foundation for all sizing fixes | +| 2 | Fix initial mermaid section size (Fix 5) | `mermaidParser.ts` | Low | Sections no longer start undersized | +| 3 | Auto-fit after layout (Fix 6) | Import orchestration | Low | Sections always wrap children correctly | +| 4 | Absolute → relative coordinate conversion (Fix 2) | `elkLayout.ts` | Medium | Fixes overlap completely | +| 5 | ELK compound layout options (Fix 3) | `elkLayout.ts` | Medium | ELK properly spaces children | +| 6 | Allow escaping groups (Fix 1) | `useNodeDragOperations.ts`, `sectionOperations.ts`, `mermaidParser.ts` | Medium | Drag-out-of-group works | + +--- + +## Expected Result After All Fixes + +- Paste a mermaid diagram with subgraphs → children are spaced correctly inside groups, no overlap +- Drag a node to the edge of a group → it escapes, group shrinks to fit remaining children +- Groups always show the correct size — header visible, no children hidden under title +- Padding is consistent: ELK, hit-testing, and rendering all use the same numbers +- Section node titles never overlap with children + +--- + +## Files Touch Map + +| File | What Changes | +|---|---| +| `src/lib/nodeParent.ts` | Don't set `extent: 'parent'` at parse time; set only post-layout | +| `src/lib/mermaidParser.ts` | Use `SECTION_MIN_WIDTH/HEIGHT` for initial sizes; remove `extent` from parse | +| `src/services/elkLayout.ts` | Absolute→relative coordinate fix; compound layout options; import shared padding | +| `src/services/elk-layout/options.ts` | Use shared padding constants | +| `src/hooks/node-operations/useNodeDragOperations.ts` | Detect drag-outside-parent, call `clearNodeParent` | +| `src/hooks/node-operations/sectionOperations.ts` | Call `autoFitSectionsToChildren` after layout | +| `src/hooks/node-operations/sectionBounds.ts` | Export constants for reuse (may already export) | diff --git a/MERMAID_IMPORT_AUDIT.md b/MERMAID_IMPORT_AUDIT.md new file mode 100644 index 00000000..e79a6828 --- /dev/null +++ b/MERMAID_IMPORT_AUDIT.md @@ -0,0 +1,971 @@ +# Mermaid Import Audit, Research, and Execution Plan +**Date:** 2026-04-08 +**Product Goal:** Import Mermaid diagrams reliably, make them fully editable, make them look excellent, and use icons only when they are genuinely correct. +**Standard:** We should aim to be as reliable as Mermaid for supported syntax, and better than Mermaid in editability, diagnostics, and visual polish. + +--- + +## Executive Summary + +OpenFlowKit is not currently using Mermaid's official parser/runtime for import. We use a custom Mermaid detection and plugin parsing pipeline that converts Mermaid text into our own editable graph model. + +That is not automatically wrong. In fact, for a product whose output must be editable canvas nodes, edges, sections, handles, icons, and property panels, a custom parser is a valid and often necessary architecture. + +The real problem is not "custom parser vs official parser" in isolation. The real problem is that we currently have: + +- a custom editable import pipeline +- no official Mermaid parser installed as a validator or compatibility oracle +- selective support for eight Mermaid families +- uneven syntax coverage between those families +- good diagnostics in many places, but no systematic compatibility benchmarking against official Mermaid +- post-parse enrichment and layout logic that can improve the output, but can also reduce trust when it overreaches + +My strongest recommendation is: + +1. Keep the custom editable parser pipeline. +2. Add official Mermaid as a syntax oracle and compatibility gate. +3. Treat import as a two-layer system: + - Layer A: official Mermaid compatibility validation + - Layer B: OpenFlowKit editable AST conversion +4. Define a strict "supported editable subset" per diagram family. +5. Add a fallback mode for valid Mermaid we cannot yet map cleanly into editable nodes. + +That gives us the best chance of becoming: + +- as reliable as Mermaid for accepted syntax +- better than Mermaid at editability +- better than many competitors at diagnostics and visual outcomes +- less annoying than current auto-enrichment behavior + +--- + +## Short Answer to the Core Question + +### Do we currently have the official Mermaid parser in the app? + +No. + +Evidence: + +- [package.json](/Users/varun/Desktop/Dev_projects/flowmind-ai/package.json) does not include a `mermaid` dependency. +- Mermaid import enters through [parseMermaidByType.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/parseMermaidByType.ts). +- That dispatcher routes to our plugin registry in [builtInPlugins.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/builtInPlugins.ts). +- Flowchart and part of state parsing ultimately rely on our local parser in [mermaidParser.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParser.ts). + +### Should we replace our parser with the official Mermaid parser? + +No, not as a full replacement. + +The official Mermaid stack is optimized to parse and render Mermaid diagrams. Our product needs to parse, normalize, enrich, lay out, and convert Mermaid into an editable internal graph model. That requires product-specific structure that Mermaid does not directly provide as a ready-made editable canvas AST. + +### Should we still use the official Mermaid parser? + +Yes. + +We should use official Mermaid for: + +- syntax validation +- compatibility benchmarking +- diagram type confirmation +- regression corpus testing +- fallback behavior for valid Mermaid we cannot yet edit faithfully + +That hybrid model is the strongest path. + +--- + +## Current Architecture in This Repo + +### Current Import Flow + +For paste/import, the current path is effectively: + +1. Detect diagram type via [detectDiagramType.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/detectDiagramType.ts) +2. Dispatch by family via [parseMermaidByType.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/parseMermaidByType.ts) +3. Parse through a custom plugin: + - `flowchart` + - `stateDiagram` + - `classDiagram` + - `erDiagram` + - `mindmap` + - `journey` + - `architecture` + - `sequence` +4. Enrich nodes via [nodeEnricher.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/nodeEnricher.ts) +5. Normalize icon state via `nodeIconState` +6. Compose layout and smart handles in [useFlowCanvasPaste.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/flow-canvas/useFlowCanvasPaste.ts) + +### Important Architectural Strength + +This architecture is already aligned with an editable-diagram product: + +- parsing is family-aware +- diagnostics are often surfaced without hard-failing +- result is converted to product-native nodes and edges +- import already feeds directly into editable canvas state + +This is the right overall shape for "editable Mermaid import." + +### Important Architectural Gap + +We currently do not have an external source of truth validating whether: + +- the Mermaid input is valid according to official Mermaid +- our type detection is correct +- our parser is silently under-parsing valid Mermaid +- our diagnostics match real Mermaid behavior +- a failing import is due to invalid Mermaid or our unsupported editable subset + +That makes trust weaker than it should be. + +--- + +## What the Codebase Is Good At Today + +### 1. Editable-native import + +This is the biggest advantage of the current approach. + +We do not just render Mermaid. We convert it into: + +- editable nodes +- editable edges +- editable sections/containers +- product-specific node types +- downstream layout and icon enrichment + +Many Mermaid-capable tools stop at "render from text." We already go further. + +### 2. Plugin-per-family architecture + +The family plugin model is a strong foundation: + +- [stateDiagram/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/stateDiagram/plugin.ts) +- [sequence/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/sequence/plugin.ts) +- [classDiagram/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/classDiagram/plugin.ts) +- [erDiagram/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/erDiagram/plugin.ts) +- [mindmap/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/mindmap/plugin.ts) +- [journey/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/journey/plugin.ts) +- [architecture/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/architecture/plugin.ts) + +That makes targeted fixes possible without a full rewrite. + +### 3. Diagnostics instead of just throwing + +Multiple plugins emit warnings and continue parsing where possible. That is good product behavior because users care about import usefulness, not parser purity. + +### 4. Supported family scope is explicit + +[parseMermaidByType.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/parseMermaidByType.ts) clearly defines supported editable families. That is better than pretending full Mermaid support and failing unpredictably. + +### 5. Conservative icon work is already moving in the right direction + +The recent tightening in: + +- [nodeEnricher.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/nodeEnricher.ts) +- [iconMatcher.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/iconMatcher.ts) + +is the right direction. "No icon" is better than a wrong icon. + +--- + +## Where the Codebase Is Not Good Enough Yet + +## 1. No official Mermaid compatibility oracle + +This is the largest strategic gap. + +Without official Mermaid in the loop: + +- we cannot distinguish "invalid Mermaid" from "valid Mermaid we do not support" +- we cannot benchmark fidelity against Mermaid itself +- we are vulnerable to spec drift +- we lack a robust parser acceptance corpus tied to the upstream project + +## 2. Our flowchart parser is spec-shaped only in part + +[mermaidParser.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParser.ts) and [mermaidParserHelpers.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParserHelpers.ts) are pragmatic and useful, but they are still handwritten rule-based parsing. + +That means: + +- edge forms can be missed +- special syntax quirks can drift from Mermaid behavior +- new Mermaid syntax will not arrive automatically +- ambiguous lines are interpreted by our rules, not the official grammar + +## 3. Support is broad by family but not deep by syntax + +Current support covers the right high-level families, but several plugins still support only a practical subset of their official syntax. + +Examples visible in code and tests: + +- state diagrams still rely partly on generic parsing plus plugin augmentation +- journey parsing warns and skips malformed lines rather than fully modeling richer journey semantics +- class and ER diagrams parse enough for editability, but not full language richness +- sequence diagrams parse structured messages, but the rendering/edit model still has room to better represent fragments and advanced constructs + +## 4. Import trust is still fragile when enrichment is too opinionated + +Even after improvements, enrichment remains a product risk area. + +Users trust imports when: + +- the structure is correct +- the text is preserved +- icons are accurate when present +- absence of icon feels intentional, not broken + +Wrong icons are more damaging than missing icons. + +## 5. We do not yet separate product guarantees clearly enough + +We need to explicitly define: + +- valid Mermaid +- supported Mermaid +- editable Mermaid +- render-only Mermaid +- unsupported Mermaid + +Right now those boundaries exist in code but are not formalized as product contracts. + +--- + +## External Research and Competitive Landscape + +## 1. Official Mermaid + +Official Mermaid's main strength is rendering Mermaid text into diagrams. The Mermaid documentation positions Mermaid.js as the JavaScript library that renders diagrams from text, and the Mermaid Live Editor as a text-first editor for writing and previewing Mermaid diagrams in real time. + +What this implies: + +- Mermaid itself is excellent as a syntax and rendering engine +- Mermaid is not, by default, an editable whiteboard/canvas product in the way OpenFlowKit is trying to be +- Mermaid's own live editor is code-first, not a full node-dragging GUI editor for canvas-native editing + +Source: +- Mermaid docs: https://mermaid.js.org/intro/getting-started.html + +## 2. Mermaid Chart + +Mermaid Chart explicitly describes the open-source Mermaid stack as having a limited editing experience because the live editor renders Mermaid code but has no GUI for adding nodes or dragging them without writing code. It positions its own product around visual editing, collaboration, enhanced design, and AI on top of Mermaid syntax. + +What this implies: + +- the market sees "editable Mermaid" as a meaningful upgrade over Mermaid itself +- visual editing on top of Mermaid is a real product category +- OpenFlowKit is directionally pursuing a real and valuable problem + +Source: +- Mermaid Chart blog: https://mermaid.ai/docs/blog/posts/mermaid-chart-the-evolution-of-mermaid + +## 3. Lucidchart + +Lucid's help content describes "Diagram as code with Mermaid in Lucidchart," which indicates Lucid treats Mermaid as an import/generation path into Lucidchart diagrams. + +What this implies: + +- enterprise diagram tools are using Mermaid as an ingestion format +- the product expectation is not just rendering but diagram creation within a richer editor +- OpenFlowKit is competing in a real workflow, not inventing a niche problem + +Source: +- Lucid help center article listing: https://help.lucid.co/hc/en-us/profiles/395718981451-Shanna-S + +## Research Takeaway + +There are roughly three classes of Mermaid products: + +1. Render-first + - Mermaid OSS + - best at syntax-to-rendering + +2. Visual-editing-on-top-of-Mermaid + - Mermaid Chart + - likely treats Mermaid as source plus enhanced editing surface + +3. Diagram-suite importers + - Lucidchart and similar tools + - treat Mermaid as one input into a broader diagramming system + +OpenFlowKit should compete as: + +- editable importer +- better diagnostics than render-only tools +- better visual polish than plain Mermaid +- more trustworthy than "magic conversion" tools + +--- + +## Strategic Product Positioning + +We should not try to beat Mermaid by becoming a better raw Mermaid renderer. + +We should try to beat Mermaid by being: + +- as syntax-compatible as possible for supported families +- more editable after import +- more visually polished after import +- more explicit and trustworthy about what was imported faithfully vs approximated + +This is a crucial difference. + +### Wrong target + +"Support every Mermaid feature exactly like Mermaid and also make everything editable immediately." + +That is expensive, brittle, and likely unrealistic. + +### Right target + +"For a clearly-defined supported subset, import with very high reliability and editability. For valid Mermaid outside that subset, fail gracefully or offer render-only/fallback behavior." + +That is shippable, honest, and strong. + +--- + +## Core Decision: What Should We Do About the Official Parser? + +## Recommendation + +Add official Mermaid as a dependency and use it in four roles. + +### Role 1: Syntax oracle + +Before editable conversion, validate the source with official Mermaid. + +Possible outcomes: + +- valid Mermaid, supported editable family +- valid Mermaid, unsupported editable family +- invalid Mermaid + +This gives much better diagnostics and product trust. + +### Role 2: Compatibility benchmark + +Use official Mermaid examples and acceptance cases as a test corpus. + +We should continuously answer: + +- does Mermaid accept this? +- do we accept it? +- if we accept it, do we preserve semantics faithfully? + +### Role 3: Fallback renderer or snapshot oracle + +For valid Mermaid that we cannot edit faithfully yet, give one of these paths: + +- render-only preview +- import with warning and partial editability +- offer "convert what is supported" vs "render as locked group" + +### Role 4: Type confirmation and future-proofing + +Use official Mermaid detection/parse behavior where possible to reduce drift from our own detection heuristics. + +## Recommendation Against Full Replacement + +Do not replace our editable parser pipeline with official Mermaid runtime as the main model source. + +Reason: + +- official Mermaid is not our internal node graph +- it does not directly solve our editable canvas modeling problem +- we still need semantic mapping into OpenFlowKit nodes, sections, icon metadata, property panels, and layout + +The right architecture is hybrid, not replacement. + +--- + +## Proposed Target Architecture + +```text +Mermaid Input + -> Pre-normalization + -> Official Mermaid validation + -> Diagram type detection confirmation + -> Editable support gate + -> if supported: family plugin conversion to OpenFlowKit AST + -> if partially supported: import with structured diagnostics + -> if valid but unsupported: render-only or locked fallback + -> Semantic enrichment + -> Layout + -> Editable canvas + -> Round-trip metadata and diagnostics +``` + +## New Contracts We Need + +Every Mermaid import should end in one of these explicit states: + +- `editable_full` +- `editable_partial` +- `render_only_valid` +- `invalid_source` +- `unsupported_family` +- `unsupported_construct` + +This contract should be visible in diagnostics and analytics. + +--- + +## Family-by-Family Audit + +## Flowchart + +### Strengths + +- Most mature path +- backed by the generic parser +- already editable +- compatible with current icon enrichment strategy + +### Risks + +- rule-based edge parsing is still vulnerable to syntax edge cases +- special token handling can drift from Mermaid +- some valid Mermaid flowchart features may be accepted differently than official Mermaid + +### Recommendation + +- use official Mermaid as acceptance oracle for flowchart corpus +- keep our editable flowchart parser +- expand parity tests aggressively + +## State Diagram + +### Strengths + +- plugin exists +- diagnostics are relatively strong +- notes and composite handling are partially modeled + +### Risks + +- relies in part on the generic parser, which is not truly state-diagram-native +- state semantics are richer than flowchart semantics +- initial/final states and advanced constructs need explicit fidelity rules + +### Recommendation + +- continue plugin-specific modeling +- reduce reliance on generic flowchart-like parsing +- benchmark against official stateDiagram examples + +## Class Diagram + +### Strengths + +- plugin exists +- block parsing and diagnostics exist + +### Risks + +- class syntax richness is high +- relationship semantics can outpace current editable model +- generics, visibility, annotations, stereotypes, and richer relationship metadata need careful mapping + +### Recommendation + +- formally define supported editable subset +- preserve unsupported tokens in metadata even when not visually modeled + +## ER Diagram + +### Strengths + +- entities and relations are modeled +- diagnostics exist + +### Risks + +- fields are not yet treated as rich schema objects to the extent needed for best-in-class editing +- key constraints and relation/cardinality fidelity matter a lot here + +### Recommendation + +- move from string-ish field handling to structured field AST +- make ER import one of the flagship "better than Mermaid" families + +## Mindmap + +### Strengths + +- relatively contained syntax +- good candidate for high reliability + +### Risks + +- indentation sensitivity can be brittle +- wrapper syntax and formatting need parity with Mermaid expectations + +### Recommendation + +- this should become one of the highest-confidence editable imports + +## Journey + +### Strengths + +- supported +- diagnostics exist + +### Risks + +- current editable semantics may undershoot user expectations around actors and scoring +- visual differentiation is important for usefulness + +### Recommendation + +- elevate journey beyond "parsed lines" into a richer native journey model + +## Sequence + +### Strengths + +- plugin exists +- good foundational parsing path + +### Risks + +- advanced sequence constructs are where many products become unreliable +- visual semantics matter a lot + +### Recommendation + +- define exact supported fragment subset +- add richer visual fidelity for fragments, notes, activations, and participant semantics + +## Architecture + +### Strengths + +- likely our best strategic family +- close fit with product value +- strict mode already exists + +### Risks + +- architecture users are the most sensitive to wrong icons and wrong semantics +- this family can look amazing or feel fake depending on icon quality + +### Recommendation + +- make architecture the gold-standard import family +- require highest trust threshold for icons +- consider provider-aware validation and stronger architecture linting + +--- + +## Icons: How We Should Think About Them + +Icons should never be treated as decoration during Mermaid import. + +Icons are semantic claims. + +If we add an AWS Lambda icon, we are claiming: + +- this node is Lambda +- not just "compute" +- not just "serverless" +- not just "some backend thing" + +That means our icon policy should be: + +- exact product match -> use icon +- strong alias match -> use icon +- trusted vendor/product compound match -> use icon +- generic concept -> do not use icon +- ambiguous concept -> do not use icon +- uncertain variant/wordmark match -> do not use icon + +Best practice for Mermaid import: + +- no icon is better than wrong icon +- structural defaults are okay for start/end/decision +- product icons must be earned, not guessed + +This matches the recent direction in: + +- [nodeEnricher.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/nodeEnricher.ts) +- [iconMatcher.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/iconMatcher.ts) + +### Additional Recommendation + +Persist skip reasons in import metadata: + +- `generic_term` +- `ambiguous_match` +- `runner_up_too_close` +- `variant_only` +- `no_trusted_candidate` + +Then surface those gently in the inspector rather than forcing icons silently. + +--- + +## Reliability Standard We Should Adopt + +If we want to be "as good as or better than Mermaid," we need to stop defining success as: + +"Did we create some nodes and edges?" + +We should define success across five dimensions: + +## 1. Syntax compatibility + +For supported families, official Mermaid accepts it and we accept it too. + +## 2. Semantic fidelity + +We preserve the meaning of: + +- node identity +- labels +- edge direction +- relationship type +- hierarchy +- family-specific semantics + +## 3. Editability + +After import, the user can actually work with the diagram as native OpenFlowKit content. + +## 4. Visual quality + +Layout, spacing, handles, typography, colors, and icons should improve the result rather than making it noisier. + +## 5. Honesty + +When we approximate or ignore something, we say so. + +This last one is how we beat many competitors in trust. + +--- + +## Recommended Roadmap + +## Phase 0: Product Contract and Instrumentation + +### Goal + +Define what "supported editable Mermaid" means. + +### Work + +- Add import result status enum +- Add structured diagnostics object, not just strings +- Add analytics for: + - valid official Mermaid / invalid official Mermaid + - editable full / editable partial / render-only + - family-level success rate + - icon assignment rate + - icon skip reason rate + +### Why first + +Without this, we cannot measure progress honestly. + +## Phase 1: Add Official Mermaid as Syntax Oracle + +### Goal + +Introduce official Mermaid without disrupting editable import. + +### Work + +- add `mermaid` dependency +- build a validation adapter service +- validate source before plugin conversion +- classify failures into: + - invalid Mermaid + - valid but unsupported family + - valid but unsupported construct + +### Output + +Users finally get trustworthy diagnostics. + +## Phase 2: Build a Compatibility Harness + +### Goal + +Measure ourselves against Mermaid continuously. + +### Work + +- create an upstream-style Mermaid fixture corpus +- import official Mermaid examples for supported families +- for each fixture, assert: + - Mermaid accepts it + - we accept it + - we preserve key semantics +- add snapshot tests for OpenFlowKit AST and visible output + +### Output + +A real compatibility scorecard instead of vibes. + +## Phase 3: Define Editable Subsets Per Family + +### Goal + +Stop pretending support is binary. + +### Work + +- publish per-family support matrix +- define: + - fully editable constructs + - partially editable constructs + - render-only constructs + - unsupported constructs + +### Output + +Clear engineering and product boundaries. + +## Phase 4: Strengthen Family Parsers + +### Goal + +Deepen syntax support where it matters most. + +### Priority order + +1. flowchart +2. architecture +3. sequence +4. stateDiagram +5. erDiagram +6. classDiagram +7. mindmap +8. journey + +### Why this order + +- flowchart is the broadest usage surface +- architecture is the highest strategic differentiator for OpenFlowKit +- sequence/state/ER/class are high-value technical diagrams + +## Phase 5: Fallback Modes + +### Goal + +Never force a bad editable conversion. + +### Work + +For valid Mermaid that we cannot edit faithfully: + +- offer render-only locked import +- or import with warnings and explicit unsupported markers +- preserve original Mermaid source in metadata + +### Output + +Trust improves because we stop pretending. + +## Phase 6: Visual Quality and Icon Excellence + +### Goal + +Make imports look polished without making them dishonest. + +### Work + +- keep strict sparse icon policy +- family-aware icon rules +- architecture-specific provider icon rules +- sequence/state/class/journey default to minimal iconing +- layout tuned per family and graph density +- typography and spacing presets by diagram family + +### Output + +Imported diagrams look upgraded, not over-decorated. + +--- + +## Concrete Engineering Recommendations + +## Recommendation A + +Add a new service: + +`src/services/mermaid/officialMermaidValidation.ts` + +Responsibilities: + +- run official Mermaid validation +- normalize validation result into our diagnostics format +- expose: + - `isValid` + - `detectedType` + - `errors` + - `unsupportedByOpenFlowKit` + +## Recommendation B + +Add an import compatibility report generator + +Suggested path: + +`scripts/mermaid-compat-report.mjs` + +Responsibilities: + +- run fixture corpus through official Mermaid and OpenFlowKit +- produce per-family compatibility stats +- output CI artifact + +## Recommendation C + +Add structured diagnostics model + +Current string diagnostics are useful, but we should move toward: + +```ts +type MermaidImportDiagnostic = { + code: string; + severity: 'info' | 'warning' | 'error'; + family?: string; + line?: number; + message: string; + officialMermaidAccepted?: boolean; + editableImpact?: 'none' | 'partial' | 'blocked'; +}; +``` + +## Recommendation D + +Store original Mermaid source with imported diagram metadata + +This enables: + +- re-validation +- re-import upgrades later +- compare/editability troubleshooting +- future round-trip tooling + +## Recommendation E + +Separate parsing from semantic decoration more cleanly + +The parser's job: + +- detect +- tokenize +- model structure +- preserve semantics + +The decoration layer's job: + +- layout +- icon enrichment +- color defaults +- visual polish + +These should remain cleanly separated. + +--- + +## Strong Suggestions and Non-Negotiables + +## 1. Do not make icon enrichment more aggressive again + +That would hurt trust. + +## 2. Do not market "full Mermaid support" + +Market: + +- "editable Mermaid import for supported diagram families" +- "strong diagnostics for unsupported or partially supported constructs" + +## 3. Add official Mermaid before broadening syntax claims + +Otherwise we will keep guessing at compatibility. + +## 4. Make architecture import best-in-class + +This is where OpenFlowKit can feel truly better than Mermaid: + +- provider-aware icons +- clearer structure +- better layout +- editable cloud/infra semantics + +## 5. Make fallback behavior a feature, not an embarrassment + +"Valid Mermaid, but not yet editable as native nodes. Imported as locked render with source preserved." + +That is much better than mangling a diagram. + +--- + +## Success Metrics + +We should start tracking these: + +- official-valid Mermaid acceptance rate by family +- editable-full import rate by family +- editable-partial import rate by family +- render-only fallback rate by family +- import diagnostic rate by category +- user correction rate after import +- manual icon override rate +- icon false-positive complaints +- round-trip retention rate for supported families + +Target standards: + +- flowchart editable-full on supported corpus: 95%+ +- architecture editable-full on supported corpus: 95%+ +- state/sequence/class/ER supported-corpus semantic fidelity: 90%+ +- wrong-icon rate on import: near zero + +--- + +## Final Recommendation + +OpenFlowKit should not try to become "Mermaid but with a different parser." + +OpenFlowKit should become: + +- Mermaid-compatible where it claims compatibility +- more editable than Mermaid +- more visually polished than Mermaid +- more honest than magical importers + +The winning strategy is: + +- keep our editable parser architecture +- add official Mermaid as a validation and compatibility layer +- formalize supported editable subsets +- introduce graceful fallback for valid-but-not-editable Mermaid +- continue strict sparse iconing +- measure fidelity against official Mermaid continuously + +That is the strongest path to being reliable, credible, and genuinely better for the user's actual workflow. + +--- + +## Source Links + +- Mermaid documentation: https://mermaid.js.org/intro/getting-started.html +- Mermaid Chart product/positioning blog: https://mermaid.ai/docs/blog/posts/mermaid-chart-the-evolution-of-mermaid +- Lucid help center profile listing showing Mermaid article: https://help.lucid.co/hc/en-us/profiles/395718981451-Shanna-S + +## Internal Code References + +- [package.json](/Users/varun/Desktop/Dev_projects/flowmind-ai/package.json) +- [parseMermaidByType.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/parseMermaidByType.ts) +- [detectDiagramType.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/detectDiagramType.ts) +- [mermaidParser.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParser.ts) +- [mermaidParserHelpers.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParserHelpers.ts) +- [useFlowCanvasPaste.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/flow-canvas/useFlowCanvasPaste.ts) +- [nodeEnricher.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/nodeEnricher.ts) +- [iconMatcher.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/iconMatcher.ts) +- [builtInPlugins.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/builtInPlugins.ts) diff --git a/src/components/flow-canvas/useFlowCanvasPaste.ts b/src/components/flow-canvas/useFlowCanvasPaste.ts index 8f6fb830..92e3bdac 100644 --- a/src/components/flow-canvas/useFlowCanvasPaste.ts +++ b/src/components/flow-canvas/useFlowCanvasPaste.ts @@ -10,8 +10,11 @@ import { import { detectMermaidDiagramType } from '@/services/mermaid/detectDiagramType'; import { normalizeParseDiagnostics } from '@/services/mermaid/diagnosticFormatting'; import { parseMermaidByType } from '@/services/mermaid/parseMermaidByType'; +import { composeDiagramForDisplay } from '@/services/composeDiagramForDisplay'; import { enrichNodesWithIcons } from '@/lib/nodeEnricher'; +import { normalizeNodeIconData } from '@/lib/nodeIconState'; import { assignSmartHandles } from '@/services/smartEdgeRouting'; +import type { LayoutOptions } from '@/services/elk-layout/types'; type SetFlowNodes = (payload: FlowNode[] | ((nodes: FlowNode[]) => FlowNode[])) => void; type SetFlowEdges = (payload: FlowEdge[] | ((edges: FlowEdge[]) => FlowEdge[])) => void; @@ -56,6 +59,33 @@ export function useFlowCanvasPaste({ getLastInteractionFlowPosition, getCanvasCenterFlowPosition, }: UseFlowCanvasPasteParams) { + const getImportSpacing = (nodeCount: number): LayoutOptions['spacing'] => { + if (nodeCount <= 10) return 'loose'; + if (nodeCount <= 25) return 'normal'; + return 'compact'; + }; + + const safelyEnrichImportedNodes = useCallback( + (nodes: FlowNode[], diagramType: MermaidDiagnosticsSnapshot['diagramType']): FlowNode[] => { + try { + return enrichNodesWithIcons(nodes, { + diagramType, + mode: 'mermaid-import', + }).map((node) => ({ + ...node, + data: normalizeNodeIconData(node.data), + })); + } catch { + addToast( + 'Imported diagram rendered without icon enrichment due to an enrichment error.', + 'warning' + ); + return nodes; + } + }, + [addToast] + ); + const handleCanvasPaste = useCallback( async (event: React.ClipboardEvent): Promise => { if (isEditablePasteTarget(event.target)) return; @@ -91,18 +121,18 @@ export function useFlowCanvasPaste({ recordHistory(); if (result.nodes.length > 0) { - const enrichedNodes = await enrichNodesWithIcons(result.nodes); + const enrichedNodes = safelyEnrichImportedNodes(result.nodes, result.diagramType); try { - const { getElkLayout, clearLayoutCache } = await import('@/services/elkLayout'); + const { clearLayoutCache } = await import('@/services/elkLayout'); clearLayoutCache(); const layoutDirection = resolveLayoutDirection(result); - const { nodes: layoutedNodes, edges: layoutedEdges } = await getElkLayout( + const { nodes: layoutedNodes, edges: layoutedEdges } = await composeDiagramForDisplay( enrichedNodes, result.edges, { direction: layoutDirection, - algorithm: 'layered', - spacing: 'normal', + spacing: getImportSpacing(enrichedNodes.length), + diagramType: result.diagramType, } ); const smartEdges = assignSmartHandles(layoutedNodes, layoutedEdges); @@ -172,6 +202,7 @@ export function useFlowCanvasPaste({ setMermaidDiagnostics, setNodes, setSelectedNodeId, + safelyEnrichImportedNodes, strictModePasteBlockedMessage, updateTab, ] diff --git a/src/diagram-types/architecture/plugin.ts b/src/diagram-types/architecture/plugin.ts index 3bb95740..45930fd8 100644 --- a/src/diagram-types/architecture/plugin.ts +++ b/src/diagram-types/architecture/plugin.ts @@ -24,6 +24,38 @@ interface ParsedArchEdge { targetSide?: 'L' | 'R' | 'T' | 'B'; } +function buildArchitectureLayerRanks(nodes: ParsedArchNode[]): Map { + const ranks = new Map(); + let nextRank = 0; + + for (const node of nodes) { + if (node.kind === 'group' && !node.parentId && !ranks.has(node.id)) { + ranks.set(node.id, nextRank++); + } + } + + for (const node of nodes) { + if (node.kind !== 'group' && !node.parentId && !ranks.has(node.id)) { + ranks.set(node.id, nextRank++); + } + } + + return ranks; +} + +function resolveArchitectureLayerRank( + node: ParsedArchNode, + layerRanks: Map +): number | undefined { + if (layerRanks.has(node.id)) { + return layerRanks.get(node.id); + } + if (node.parentId && layerRanks.has(node.parentId)) { + return layerRanks.get(node.parentId); + } + return undefined; +} + function sideToHandleId(side: ParsedArchEdge['sourceSide']): string | undefined { if (side === 'L') return 'left'; if (side === 'R') return 'right'; @@ -257,7 +289,9 @@ function parseArchitecture(input: string): { nodes: FlowNode[]; edges: FlowEdge[ } const nodeIds = new Set(parsedNodes.map((node) => node.id)); + const layerRanks = buildArchitectureLayerRanks(parsedNodes); const nodes: FlowNode[] = parsedNodes.map((node, index) => { + const layerRank = resolveArchitectureLayerRank(node, layerRanks); let mappedNode: FlowNode = { id: node.id, type: 'architecture', @@ -273,6 +307,8 @@ function parseArchitecture(input: string): { nodes: FlowNode[]; edges: FlowEdge[ archProvider: node.icon || (node.kind === 'group' ? 'group' : 'custom'), archResourceType: node.kind, archBoundaryId: node.parentId, + archLayerRank: layerRank, + archLayerLabel: node.parentId || node.id, }, }; diff --git a/src/diagram-types/classDiagram/plugin.test.ts b/src/diagram-types/classDiagram/plugin.test.ts index e0e8fa16..9749f3fa 100644 --- a/src/diagram-types/classDiagram/plugin.test.ts +++ b/src/diagram-types/classDiagram/plugin.test.ts @@ -46,6 +46,23 @@ describe('CLASS_DIAGRAM_PLUGIN', () => { expect(result.nodes.find((node) => node.id === 'Domain.Account')?.data.classMethods).toContain('+balance(): Money'); }); + it('normalizes generic class identifiers and preserves relation cardinality metadata', () => { + const input = ` + classDiagram + class Repository~T~ { + +findById(id: UUID): T + } + class User + Repository~T~ "1" --> "*" User : stores + `; + + const result = CLASS_DIAGRAM_PLUGIN.parseMermaid(input); + expect(result.error).toBeUndefined(); + expect(result.nodes.find((node) => node.id === 'Repository')?.data.label).toBe('Repository'); + expect(result.edges[0].data.classRelationSourceCardinality).toBe('1'); + expect(result.edges[0].data.classRelationTargetCardinality).toBe('*'); + }); + it('emits diagnostics for malformed class lines and relation syntax', () => { const input = ` classDiagram diff --git a/src/diagram-types/classDiagram/plugin.ts b/src/diagram-types/classDiagram/plugin.ts index 35972de5..3a760c20 100644 --- a/src/diagram-types/classDiagram/plugin.ts +++ b/src/diagram-types/classDiagram/plugin.ts @@ -19,9 +19,15 @@ interface RelationRecord { target: string; relation: ClassRelationToken; label?: string; + sourceCardinality?: string; + targetCardinality?: string; } -const CLASS_ID_PATTERN = '[A-Za-z_][\\w.]*'; +const CLASS_ID_PATTERN = '[A-Za-z_][\\w.<>~,]*'; + +function normalizeClassIdentifier(value: string): string { + return value.trim().replace(/~([^~]+)~/g, '<$1>'); +} function createEmptyClass(id: string): ClassRecord { return { @@ -52,18 +58,33 @@ function parseClassBodyLine(line: string, record: ClassRecord): void { function parseRelation(line: string): RelationRecord | null { const relationTokenPattern = buildClassRelationTokenRegexPattern(); const relationMatch = line.match( - new RegExp(`^(${CLASS_ID_PATTERN})\\s+(${relationTokenPattern})\\s+(${CLASS_ID_PATTERN})(?:\\s*:\\s*(.+))?$`) + new RegExp( + `^(${CLASS_ID_PATTERN})(?:\\s+"([^"]+)")?\\s+(${relationTokenPattern})\\s+(?:"([^"]+)"\\s+)?(${CLASS_ID_PATTERN})(?:\\s*:\\s*(.+))?$` + ) ); if (!relationMatch) return null; return { - source: relationMatch[1], - relation: relationMatch[2] as ClassRelationToken, - target: relationMatch[3], - label: relationMatch[4]?.trim(), + source: normalizeClassIdentifier(relationMatch[1]), + sourceCardinality: relationMatch[2]?.trim(), + relation: relationMatch[3] as ClassRelationToken, + targetCardinality: relationMatch[4]?.trim(), + target: normalizeClassIdentifier(relationMatch[5]), + label: relationMatch[6]?.trim(), }; } +function ensureClassRecord(classes: Map, id: string): ClassRecord { + const existing = classes.get(id); + if (existing) { + return existing; + } + + const created = createEmptyClass(id); + classes.set(id, created); + return created; +} + function parseClassDiagram(input: string): { nodes: FlowNode[]; edges: FlowEdge[]; error?: string; diagnostics?: string[] } { const lines = input.replace(/\r\n/g, '\n').split('\n'); const classes = new Map(); @@ -98,9 +119,8 @@ function parseClassDiagram(input: string): { nodes: FlowNode[]; edges: FlowEdge[ const inlineBlock = line.match(new RegExp(`^class\\s+(${CLASS_ID_PATTERN})\\s*\\{\\s*(.*?)\\s*\\}$`)); if (inlineBlock) { - const id = inlineBlock[1]; - const existing = classes.get(id) || createEmptyClass(id); - classes.set(id, existing); + const id = normalizeClassIdentifier(inlineBlock[1]); + const existing = ensureClassRecord(classes, id); const members = inlineBlock[2] .split(';') .map((member) => member.trim()) @@ -111,9 +131,8 @@ function parseClassDiagram(input: string): { nodes: FlowNode[]; edges: FlowEdge[ const blockStart = line.match(new RegExp(`^class\\s+(${CLASS_ID_PATTERN})\\s*\\{\\s*$`)); if (blockStart) { - const id = blockStart[1]; - const existing = classes.get(id) || createEmptyClass(id); - classes.set(id, existing); + const id = normalizeClassIdentifier(blockStart[1]); + const existing = ensureClassRecord(classes, id); activeClass = existing; activeClassLine = lineNumber; continue; @@ -121,45 +140,37 @@ function parseClassDiagram(input: string): { nodes: FlowNode[]; edges: FlowEdge[ const classWithStereotype = line.match(new RegExp(`^class\\s+(${CLASS_ID_PATTERN})\\s*<<\\s*(.+?)\\s*>>\\s*$`)); if (classWithStereotype) { - const id = classWithStereotype[1]; - const existing = classes.get(id) || createEmptyClass(id); + const id = normalizeClassIdentifier(classWithStereotype[1]); + const existing = ensureClassRecord(classes, id); existing.stereotype = classWithStereotype[2]; - classes.set(id, existing); continue; } const standaloneClass = line.match(new RegExp(`^class\\s+(${CLASS_ID_PATTERN})\\s*$`)); if (standaloneClass) { - const id = standaloneClass[1]; - if (!classes.has(id)) { - classes.set(id, createEmptyClass(id)); - } + const id = normalizeClassIdentifier(standaloneClass[1]); + ensureClassRecord(classes, id); continue; } const classMemberInline = line.match(new RegExp(`^(${CLASS_ID_PATTERN})\\s*:\\s*(.+)$`)); if (classMemberInline) { - const id = classMemberInline[1]; + const id = normalizeClassIdentifier(classMemberInline[1]); const member = classMemberInline[2].trim(); - const existing = classes.get(id) || createEmptyClass(id); + const existing = ensureClassRecord(classes, id); if (/\(.*\)/.test(member)) { existing.methods.push(member); } else { existing.attributes.push(member); } - classes.set(id, existing); continue; } const relation = parseRelation(line); if (relation) { relations.push(relation); - if (!classes.has(relation.source)) { - classes.set(relation.source, createEmptyClass(relation.source)); - } - if (!classes.has(relation.target)) { - classes.set(relation.target, createEmptyClass(relation.target)); - } + ensureClassRecord(classes, relation.source); + ensureClassRecord(classes, relation.target); continue; } @@ -212,15 +223,20 @@ function parseClassDiagram(input: string): { nodes: FlowNode[]; edges: FlowEdge[ })); const edges: FlowEdge[] = relations.map((relation, index) => ({ - id: createId(`e-class-${index}`), - source: relation.source, - target: relation.target, - label: relation.label || relation.relation, - type: 'smoothstep', - data: { - classRelation: relation.relation, - classRelationLabel: relation.label, - }, + id: createId(`e-class-${index}`), + source: relation.source, + target: relation.target, + label: + relation.label + || [relation.sourceCardinality, relation.targetCardinality].filter(Boolean).join(' ') + || relation.relation, + type: 'smoothstep', + data: { + classRelation: relation.relation, + classRelationLabel: relation.label, + classRelationSourceCardinality: relation.sourceCardinality, + classRelationTargetCardinality: relation.targetCardinality, + }, })); return diagnostics.length > 0 ? { nodes, edges, diagnostics } : { nodes, edges }; diff --git a/src/diagram-types/erDiagram/plugin.test.ts b/src/diagram-types/erDiagram/plugin.test.ts index 26a7f701..d26fdf42 100644 --- a/src/diagram-types/erDiagram/plugin.test.ts +++ b/src/diagram-types/erDiagram/plugin.test.ts @@ -20,7 +20,15 @@ describe('ER_DIAGRAM_PLUGIN', () => { expect(result.error).toBeUndefined(); expect(result.nodes).toHaveLength(2); expect(result.edges).toHaveLength(1); - expect(result.nodes.find((node) => node.id === 'CUSTOMER')?.data.erFields).toContain('string id PK'); + expect(result.nodes.find((node) => node.id === 'CUSTOMER')?.data.erFields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'id', + dataType: 'string', + isPrimaryKey: true, + }), + ]) + ); }); it('returns error when header is missing', () => { @@ -78,4 +86,35 @@ describe('ER_DIAGRAM_PLUGIN', () => { expect(result.error).toBeUndefined(); expect(result.diagnostics?.some((message) => message.includes('Unclosed entity block started at line'))).toBe(true); }); + + it('parses ER field uniqueness and references metadata', () => { + const input = ` + erDiagram + ORDER { + uuid id PK + uuid customer_id FK REFERENCES CUSTOMER.id + string external_id UK + } + `; + + const result = ER_DIAGRAM_PLUGIN.parseMermaid(input); + expect(result.error).toBeUndefined(); + expect(result.nodes).toHaveLength(1); + + const fields = result.nodes[0].data.erFields ?? []; + expect(fields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'customer_id', + isForeignKey: true, + referencesTable: 'CUSTOMER', + referencesField: 'id', + }), + expect.objectContaining({ + name: 'external_id', + isUnique: true, + }), + ]) + ); + }); }); diff --git a/src/diagram-types/erDiagram/plugin.ts b/src/diagram-types/erDiagram/plugin.ts index 15a39199..81ba4ba5 100644 --- a/src/diagram-types/erDiagram/plugin.ts +++ b/src/diagram-types/erDiagram/plugin.ts @@ -3,13 +3,15 @@ import { buildERRelationTokenRegexPattern, type ERRelationToken, } from '@/lib/relationSemantics'; +import { createDefaultErField } from '@/lib/entityFields'; +import type { ErField } from '@/lib/types'; import type { FlowEdge, FlowNode } from '@/lib/types'; import type { DiagramPlugin } from '@/diagram-types/core'; interface EntityRecord { id: string; label: string; - fields: string[]; + fields: ErField[]; } interface RelationRecord { @@ -29,6 +31,57 @@ function createEmptyEntity(id: string): EntityRecord { }; } +function parseMermaidErField(line: string): ErField { + const trimmed = line.trim(); + if (!trimmed) { + return createDefaultErField(); + } + + const tokens = trimmed.split(/\s+/).filter(Boolean); + if (tokens.length < 2) { + return { + ...createDefaultErField(), + name: trimmed, + }; + } + + const [dataType, name, ...rawConstraints] = tokens; + const field: ErField = { + ...createDefaultErField(), + name, + dataType, + }; + + for (let index = 0; index < rawConstraints.length; index += 1) { + const token = rawConstraints[index].toUpperCase(); + if (token === 'PK' || token === 'PRIMARY') { + field.isPrimaryKey = true; + continue; + } + if (token === 'FK' || token === 'FOREIGN') { + field.isForeignKey = true; + continue; + } + if (token === 'UK' || token === 'UNIQUE' || token === 'UQ') { + field.isUnique = true; + continue; + } + if (token === 'NN' || token === 'NOTNULL' || token === 'NOT') { + field.isNotNull = true; + continue; + } + if (token === 'REFERENCES' && rawConstraints[index + 1]) { + const reference = rawConstraints[index + 1]; + const [referencesTable, referencesField] = reference.split('.'); + field.referencesTable = referencesTable; + field.referencesField = referencesField; + index += 1; + } + } + + return field; +} + function parseRelation(line: string): RelationRecord | null { const relationTokenPattern = buildERRelationTokenRegexPattern(); const match = line.match( @@ -77,7 +130,7 @@ function parseERDiagram(input: string): { nodes: FlowNode[]; edges: FlowEdge[]; activeEntityLine = -1; continue; } - activeEntity.fields.push(line); + activeEntity.fields.push(parseMermaidErField(line)); continue; } diff --git a/src/diagram-types/journey/plugin.test.ts b/src/diagram-types/journey/plugin.test.ts index a256d0a2..7ea78a3f 100644 --- a/src/diagram-types/journey/plugin.test.ts +++ b/src/diagram-types/journey/plugin.test.ts @@ -23,7 +23,7 @@ describe('JOURNEY_PLUGIN', () => { expect(result.nodes[0].data.journeyActor).toBe('Buyer'); }); - it('returns diagnostics for malformed score while keeping valid parse', () => { + it('returns diagnostics for malformed score and skips invalid steps', () => { const input = ` journey section Support @@ -33,11 +33,25 @@ describe('JOURNEY_PLUGIN', () => { const result = JOURNEY_PLUGIN.parseMermaid(input); expect(result.error).toBeUndefined(); - expect(result.nodes).toHaveLength(2); + expect(result.nodes).toHaveLength(1); expect(result.diagnostics?.some((message) => message.includes('Invalid journey score at line'))).toBe(true); }); - it('returns diagnostics for malformed section and invalid step syntax while preserving valid steps', () => { + it('color-codes steps by score for quick satisfaction scanning', () => { + const input = ` + journey + section Checkout + Browse: 5: Buyer + Retry payment: 1: Buyer + `; + + const result = JOURNEY_PLUGIN.parseMermaid(input); + expect(result.error).toBeUndefined(); + expect(result.nodes[0].data.color).toBe('emerald'); + expect(result.nodes[1].data.color).toBe('red'); + }); + + it('returns diagnostics for malformed section and malformed score-like steps while preserving valid steps', () => { const input = ` journey section @@ -49,7 +63,7 @@ describe('JOURNEY_PLUGIN', () => { expect(result.error).toBeUndefined(); expect(result.nodes).toHaveLength(1); expect(result.diagnostics?.some((message) => message.includes('Invalid journey section syntax at line'))).toBe(true); - expect(result.diagnostics?.some((message) => message.includes('Invalid journey step syntax at line'))).toBe(true); + expect(result.diagnostics?.some((message) => message.includes('Invalid journey score at line'))).toBe(true); }); it('returns error when header is missing', () => { diff --git a/src/diagram-types/journey/plugin.ts b/src/diagram-types/journey/plugin.ts index d6354084..60dfe072 100644 --- a/src/diagram-types/journey/plugin.ts +++ b/src/diagram-types/journey/plugin.ts @@ -21,6 +21,14 @@ function normalizeScore(input: string): number | null { return rounded; } +function getJourneyScoreColor(score: number | undefined): string { + if (typeof score !== 'number') return 'slate'; + if (score >= 4) return 'emerald'; + if (score === 3) return 'amber'; + if (score === 2) return 'orange'; + return 'red'; +} + interface ParsedJourneyStep { task: string; actor?: string; @@ -42,7 +50,7 @@ function parseJourneyStep(line: string): ParsedJourneyStep | null { if (parts.length === 2) { const score = normalizeScore(parts[1]); if (score === null) { - return null; + return { task, scoreMalformed: true }; } return { task, score, scoreMalformed: false }; } @@ -109,6 +117,7 @@ function parseJourney(input: string): { nodes: FlowNode[]; edges: FlowEdge[]; er } if (parsedStep.scoreMalformed) { diagnostics.push(`Invalid journey score at line ${lineNumber}: "${line}" (expected 0-5)`); + continue; } steps.push({ @@ -159,7 +168,7 @@ function parseJourney(input: string): { nodes: FlowNode[]; edges: FlowEdge[]; er data: { label: step.task, subLabel: step.actor, - color: 'violet', + color: getJourneyScoreColor(step.score), shape: 'rounded', journeySection: step.section, journeyTask: step.task, diff --git a/src/diagram-types/sequence/plugin.test.ts b/src/diagram-types/sequence/plugin.test.ts index 3e1e97e0..965e779c 100644 --- a/src/diagram-types/sequence/plugin.test.ts +++ b/src/diagram-types/sequence/plugin.test.ts @@ -98,7 +98,8 @@ describe('SEQUENCE_PLUGIN', () => { end`; const result = SEQUENCE_PLUGIN.parseMermaid(input); - expect(result.nodes).toHaveLength(2); + expect(result.nodes).toHaveLength(3); + expect(result.nodes.some((node) => node.type === 'annotation')).toBe(true); expect(result.edges).toHaveLength(1); }); @@ -109,4 +110,22 @@ describe('SEQUENCE_PLUGIN', () => { const result = SEQUENCE_PLUGIN.parseMermaid(input); expect(result.edges[0].data?.seqMessageKind).toBe('async'); }); + + it('creates visible note and fragment nodes for sequence annotations', () => { + const input = `sequenceDiagram + participant A + participant B + note right of A: warm cache + alt success + A->>B: Request + else failure + B-->>A: Error + end`; + + const result = SEQUENCE_PLUGIN.parseMermaid(input); + expect(result.error).toBeUndefined(); + expect(result.nodes.some((node) => node.type === 'sequence_note')).toBe(true); + expect(result.nodes.some((node) => node.type === 'annotation')).toBe(true); + expect(result.edges[0].data?.seqFragment?.type).toBe('alt'); + }); }); diff --git a/src/diagram-types/sequence/plugin.ts b/src/diagram-types/sequence/plugin.ts index cfb03f17..56ea2d3e 100644 --- a/src/diagram-types/sequence/plugin.ts +++ b/src/diagram-types/sequence/plugin.ts @@ -16,10 +16,11 @@ interface ParsedMessage { } interface ParsedFragment { + id: string; type: 'alt' | 'loop' | 'opt' | 'par' | 'break' | 'critical'; condition: string; startOrder: number; - elseOrder?: number; + endOrder: number; } interface ParsedActivation { @@ -35,6 +36,24 @@ interface ParsedNote { order: number; } +function getSequenceFragmentColor(fragmentType: ParsedFragment['type']): string { + switch (fragmentType) { + case 'loop': + return 'blue'; + case 'opt': + return 'amber'; + case 'critical': + return 'red'; + default: + return 'violet'; + } +} + +function getParticipantLaneIndex(participants: ParsedParticipant[], participantId: string): number { + const laneIndex = participants.findIndex((participant) => participant.id === participantId); + return Math.max(0, laneIndex); +} + function resolveMessageKind(arrow: string): ParsedMessage['kind'] { if (arrow === '-->' || arrow === '-->>' || arrow === '-->>>' || arrow === '--x') return 'return'; if (arrow === '-)' || arrow === '--)') return 'async'; @@ -59,6 +78,7 @@ function parseSequence(input: string): { let hasHeader = false; let messageOrder = 0; const fragmentStack: Array<{ + id: string; type: ParsedFragment['type']; condition: string; startOrder: number; @@ -108,6 +128,7 @@ function parseSequence(input: string): { const fragmentMatch = line.match(/^(alt|loop|opt|par|break|critical)\s+(.+)$/i); if (fragmentMatch) { fragmentStack.push({ + id: `seq-fragment-${fragmentStack.length + fragments.length + 1}`, type: fragmentMatch[1].toLowerCase() as ParsedFragment['type'], condition: fragmentMatch[2].trim(), startOrder: messageOrder, @@ -120,10 +141,11 @@ function parseSequence(input: string): { const top = fragmentStack[fragmentStack.length - 1]; if (top.type === 'alt') { fragments.push({ + id: `${top.id}-branch-${fragments.length + 1}`, type: top.type, condition: top.condition, startOrder: top.startOrder, - elseOrder: messageOrder, + endOrder: messageOrder, }); top.condition = elseMatch[1].trim(); top.startOrder = messageOrder; @@ -131,10 +153,33 @@ function parseSequence(input: string): { continue; } + const andMatch = line.match(/^and\s+(.+)$/i); + if (andMatch && fragmentStack.length > 0) { + const top = fragmentStack[fragmentStack.length - 1]; + if (top.type === 'par') { + fragments.push({ + id: `${top.id}-branch-${fragments.length + 1}`, + type: top.type, + condition: top.condition, + startOrder: top.startOrder, + endOrder: messageOrder, + }); + top.condition = andMatch[1].trim(); + top.startOrder = messageOrder; + } + continue; + } + if (/^end\b/i.test(line)) { if (fragmentStack.length > 0) { const top = fragmentStack.pop()!; - fragments.push({ type: top.type, condition: top.condition, startOrder: top.startOrder }); + fragments.push({ + id: `${top.id}-branch-${fragments.length + 1}`, + type: top.type, + condition: top.condition, + startOrder: top.startOrder, + endOrder: messageOrder, + }); } continue; } @@ -217,10 +262,7 @@ function parseSequence(input: string): { })); const edges: FlowEdge[] = messages.map((msg, i) => { - const frag = fragments.find((f) => { - const end = f.elseOrder !== undefined ? f.elseOrder : f.startOrder; - return i >= f.startOrder && i <= end; - }); + const frag = fragments.find((f) => i >= f.startOrder && i <= f.endOrder); return { id: `e-seq-${i + 1}`, @@ -243,7 +285,11 @@ function parseSequence(input: string): { const noteNodes: FlowNode[] = notes.map((note, i) => ({ id: `seq-note-${i + 1}`, type: 'sequence_note', - position: { x: 0, y: 0 }, + position: { + x: getParticipantLaneIndex(participants, note.target) * LANE_WIDTH + + (note.position === 'left' ? -160 : note.position === 'right' ? 160 : 0), + y: 110 + note.order * 110, + }, data: { label: note.text, seqNoteTarget: note.target, @@ -252,8 +298,24 @@ function parseSequence(input: string): { }, })); + const fragmentNodes: FlowNode[] = fragments.map((fragment, index) => ({ + id: fragment.id, + type: 'annotation', + position: { + x: -260, + y: 80 + fragment.startOrder * 110 + index * 12, + }, + data: { + label: fragment.type.toUpperCase(), + subLabel: fragment.condition, + color: getSequenceFragmentColor(fragment.type), + seqFragmentId: fragment.id, + seqMessageOrder: fragment.startOrder, + }, + })); + return { - nodes: [...nodes, ...noteNodes], + nodes: [...nodes, ...noteNodes, ...fragmentNodes], edges, ...(diagnostics.length > 0 ? { diagnostics } : {}), }; diff --git a/src/diagram-types/stateDiagram/plugin.test.ts b/src/diagram-types/stateDiagram/plugin.test.ts index 553c21f6..ab53bca1 100644 --- a/src/diagram-types/stateDiagram/plugin.test.ts +++ b/src/diagram-types/stateDiagram/plugin.test.ts @@ -51,19 +51,51 @@ describe('STATE_DIAGRAM_PLUGIN', () => { expect(busyNode?.parentId).toBe('Working'); }); - it('returns deterministic diagnostics for unsupported note, invalid direction, and malformed transition arrows', () => { + it('renders state notes as annotation nodes instead of rejecting them', () => { + const input = ` + stateDiagram-v2 + [*] --> Idle + note right of Idle: Waiting for input + Idle --> Running + `; + + const result = STATE_DIAGRAM_PLUGIN.parseMermaid(input); + expect(result.error).toBeUndefined(); + expect(result.nodes.some((node) => node.type === 'annotation')).toBe(true); + expect(result.edges.some((edge) => edge.source.startsWith('state-note-') && edge.target === 'Idle')).toBe(true); + expect(result.diagnostics?.some((message) => message.includes('note syntax'))).not.toBe(true); + }); + + it('parses explicit fork and join control states', () => { + const input = ` + stateDiagram-v2 + state FanOut <> + state FanIn <> + [*] --> FanOut + FanOut --> BranchA + FanOut --> BranchB + BranchA --> FanIn + BranchB --> FanIn + FanIn --> [*] + `; + + const result = STATE_DIAGRAM_PLUGIN.parseMermaid(input); + expect(result.error).toBeUndefined(); + expect(result.nodes.find((node) => node.id === 'FanOut')?.data.stateControlKind).toBe('fork'); + expect(result.nodes.find((node) => node.id === 'FanIn')?.data.stateControlKind).toBe('join'); + }); + + it('returns deterministic diagnostics for invalid direction and malformed transition arrows', () => { const input = ` stateDiagram-v2 [*] --> Idle direction RL - note right of Idle: unsupported Idle -> Running `; const result = STATE_DIAGRAM_PLUGIN.parseMermaid(input); expect(result.error).toBeUndefined(); expect(result.diagnostics?.some((message) => message.includes('Invalid stateDiagram direction syntax at line'))).toBe(true); - expect(result.diagnostics?.some((message) => message.includes('Unsupported stateDiagram note syntax at line'))).toBe(true); expect(result.diagnostics?.some((message) => message.includes('Invalid stateDiagram transition syntax at line'))).toBe(true); }); }); diff --git a/src/diagram-types/stateDiagram/plugin.ts b/src/diagram-types/stateDiagram/plugin.ts index e9cdb06c..06281142 100644 --- a/src/diagram-types/stateDiagram/plugin.ts +++ b/src/diagram-types/stateDiagram/plugin.ts @@ -2,6 +2,20 @@ import { parseMermaid } from '@/lib/mermaidParser'; import type { DiagramPlugin } from '@/diagram-types/core'; import type { FlowNode } from '@/lib/types'; import { setNodeParent } from '@/lib/nodeParent'; +import { createId } from '@/lib/id'; + +interface StateNoteRecord { + id: string; + target: string; + text: string; + position: 'left' | 'right' | 'over'; +} + +interface StateControlRecord { + id: string; + label: string; + kind: 'fork' | 'join'; +} function normalizeStateTransitionLabels(input: string): string { const lines = input.replace(/\r\n/g, '\n').split('\n'); @@ -51,7 +65,10 @@ function collectStateDiagramDiagnostics(input: string): { diagnostics: string[]; } if (/^note\b/i.test(line)) { - diagnostics.push(`Unsupported stateDiagram note syntax at line ${lineNumber}: "${line}"`); + const noteMatch = line.match(/^note\s+(left of|right of|over)\s+\S+\s*:\s*.+$/i); + if (!noteMatch) { + diagnostics.push(`Invalid stateDiagram note syntax at line ${lineNumber}: "${line}"`); + } continue; } @@ -86,7 +103,21 @@ function parseStateDiagram(input: string) { const normalizedInput = normalizeStateTransitionLabels(input); const parsed = parseMermaid(normalizedInput); const withCompositeParents = applyCompositeStateParenting(parsed.nodes as FlowNode[], input); - parsed.nodes = withCompositeParents; + const notes = parseStateDiagramNotes(input, withCompositeParents); + const controls = parseStateDiagramControls(input); + parsed.nodes = applyStateDiagramEnhancements(withCompositeParents, notes, controls); + parsed.edges = [ + ...parsed.edges, + ...notes.map((note) => ({ + id: createId(`e-state-note-${note.id}-${note.target}`), + source: note.id, + target: note.target, + type: 'straight', + data: { + dashPattern: 'dashed' as const, + }, + })), + ]; if (direction) { parsed.direction = direction; } @@ -159,6 +190,127 @@ function applyCompositeStateParenting(nodes: FlowNode[], input: string): FlowNod return nextNodes; } +function parseStateDiagramNotes(input: string, nodes: FlowNode[]): StateNoteRecord[] { + const knownNodeIds = new Set(nodes.map((node) => node.id)); + const notes: StateNoteRecord[] = []; + + input + .replace(/\r\n/g, '\n') + .split('\n') + .forEach((rawLine, index) => { + const line = rawLine.trim(); + const match = line.match(/^note\s+(left of|right of|over)\s+(\S+)\s*:\s*(.+)$/i); + if (!match || !knownNodeIds.has(match[2])) { + return; + } + + notes.push({ + id: `state-note-${index + 1}`, + target: match[2], + text: match[3].trim(), + position: match[1].toLowerCase().replace(' of', '') as StateNoteRecord['position'], + }); + }); + + return notes; +} + +function parseStateDiagramControls(input: string): StateControlRecord[] { + const controls: StateControlRecord[] = []; + + input + .replace(/\r\n/g, '\n') + .split('\n') + .forEach((rawLine) => { + const line = rawLine.trim(); + const aliasMatch = line.match( + /^state\s+"([^"]+)"\s+as\s+([A-Za-z_][\w.-]*)\s+<<(fork|join)>>\s*$/i + ); + if (aliasMatch) { + controls.push({ + id: aliasMatch[2], + label: aliasMatch[1], + kind: aliasMatch[3].toLowerCase() as StateControlRecord['kind'], + }); + return; + } + + const simpleMatch = line.match(/^state\s+([A-Za-z_][\w.-]*)\s+<<(fork|join)>>\s*$/i); + if (simpleMatch) { + controls.push({ + id: simpleMatch[1], + label: simpleMatch[2].toLowerCase() === 'fork' ? 'Fork' : 'Join', + kind: simpleMatch[2].toLowerCase() as StateControlRecord['kind'], + }); + } + }); + + return controls; +} + +function applyStateDiagramEnhancements( + nodes: FlowNode[], + notes: StateNoteRecord[], + controls: StateControlRecord[] +): FlowNode[] { + const nextNodes = [...nodes]; + const nodeIndexById = new Map(nextNodes.map((node, index) => [node.id, index])); + + controls.forEach((control) => { + const existingIndex = nodeIndexById.get(control.id); + const baseData = { + label: control.label, + color: control.kind === 'fork' ? 'slate' : 'blue', + shape: 'rectangle' as const, + width: 120, + height: 52, + stateControlKind: control.kind, + }; + + if (typeof existingIndex === 'number') { + nextNodes[existingIndex] = { + ...nextNodes[existingIndex], + type: 'process', + data: { + ...nextNodes[existingIndex].data, + ...baseData, + }, + }; + return; + } + + nodeIndexById.set(control.id, nextNodes.length); + nextNodes.push({ + id: control.id, + type: 'process', + position: { x: 0, y: 0 }, + data: baseData, + } as FlowNode); + }); + + notes.forEach((note) => { + const targetNode = nextNodes.find((node) => node.id === note.target); + const offsetX = note.position === 'left' ? -220 : note.position === 'right' ? 220 : 0; + const offsetY = note.position === 'over' ? -90 : 0; + nextNodes.push({ + id: note.id, + type: 'annotation', + position: { + x: (targetNode?.position.x ?? 0) + offsetX, + y: (targetNode?.position.y ?? 0) + offsetY, + }, + data: { + label: note.text, + color: 'yellow', + stateNotePosition: note.position, + stateNoteTarget: note.target, + }, + } as FlowNode); + }); + + return nextNodes; +} + export const STATE_DIAGRAM_PLUGIN: DiagramPlugin = { id: 'stateDiagram', displayName: 'State Diagram', diff --git a/src/lib/iconMatcher.test.ts b/src/lib/iconMatcher.test.ts index 08466713..0edb1d42 100644 --- a/src/lib/iconMatcher.test.ts +++ b/src/lib/iconMatcher.test.ts @@ -41,6 +41,27 @@ describe('iconMatcher', () => { expect(results[0].shapeId).toContain('redis'); }); + it('surfaces richer ranking metadata for trusted matches', () => { + const results = matchIcon('react'); + expect(results[0]?.confidence).toBeTruthy(); + expect(typeof results[0]?.reason).toBe('string'); + expect(typeof results[0]?.runnerUpDelta).toBe('number'); + expect(typeof results[0]?.wholeTokenMatch).toBe('boolean'); + }); + + it('prefers canonical icons over wordmark or light-dark variants', () => { + const results = matchIcon('nextjs'); + expect(results.length).toBeGreaterThan(0); + expect(results[0]?.isVariant).toBe(false); + }); + + it('marks generic matches as generic', () => { + const results = matchIcon('service'); + if (results.length > 0) { + expect(results[0]?.isGeneric).toBe(true); + } + }); + it('provider filter: "lambda" with provider "aws" finds AWS Lambda', () => { const results = matchIcon('lambda', 'aws'); expect(results.length).toBeGreaterThan(0); diff --git a/src/lib/iconMatcher.ts b/src/lib/iconMatcher.ts index 86b7afb9..55b240be 100644 --- a/src/lib/iconMatcher.ts +++ b/src/lib/iconMatcher.ts @@ -8,6 +8,12 @@ export interface IconMatch { category: string; score: number; matchType: 'exact' | 'alias' | 'substring' | 'category'; + confidence: 'high' | 'medium' | 'low'; + reason: string; + runnerUpDelta: number; + wholeTokenMatch: boolean; + isVariant: boolean; + isGeneric: boolean; } const ALIASES: Record = { @@ -28,6 +34,7 @@ const ALIASES: Record = { rb: 'ruby', njs: 'nodejs', node: 'nodejs', + react: 'frontend-reactjs', 'react.js': 'react', 'vue.js': 'vue', next: 'nextjs', @@ -40,15 +47,64 @@ const ALIASES: Record = { aks: 'azure-kubernetes-service', eks: 'amazon-elastic-kubernetes-service', rds: 'amazon-rds', - sqs: 'amazon-sqs', - sns: 'amazon-sns', - s3: 'amazon-s3', + sqs: 'app-integration-simple-queue-service', + sns: 'app-integration-simple-notification-service', + s3: 'storage-simple-storage-service', + 'amazon-s3': 'storage-simple-storage-service', + lambda: 'compute-lambda', + 'aws-lambda': 'compute-lambda', cf: 'cloudflare', kib: 'kibana', logstash: 'elastic-logstash', beat: 'elastic-beats', }; +const VARIANT_TOKENS = new Set(['wordmark', 'light', 'dark', 'logo', 'mark', 'filled', 'outline']); +const GENERIC_ENTRY_TOKENS = new Set([ + 'api', + 'app', + 'apps', + 'auth', + 'backend', + 'browser', + 'cache', + 'cdn', + 'client', + 'cloud', + 'compute', + 'container', + 'containers', + 'database', + 'databases', + 'delivery', + 'end', + 'frontend', + 'gateway', + 'identity', + 'integration', + 'mobile', + 'network', + 'networking', + 'process', + 'project', + 'projects', + 'proxy', + 'queue', + 'security', + 'server', + 'service', + 'services', + 'simple', + 'storage', + 'system', + 'tool', + 'tools', + 'user', + 'users', + 'web', + 'worker', +]); + function normalize(text: string): string { return text .toLowerCase() @@ -58,19 +114,107 @@ function normalize(text: string): string { .replace(/^-|-$/g, ''); } +function tokenizeNormalized(text: string): string[] { + return normalize(text) + .split('-') + .filter(Boolean); +} + +function hasTokenSequence(haystack: string[], needle: string[]): boolean { + if (needle.length === 0 || haystack.length < needle.length) { + return false; + } + + for (let start = 0; start <= haystack.length - needle.length; start += 1) { + let matched = true; + for (let index = 0; index < needle.length; index += 1) { + if (haystack[start + index] !== needle[index]) { + matched = false; + break; + } + } + if (matched) { + return true; + } + } + + return false; +} + +function tokensRoughlyMatch(queryToken: string, entryToken: string): boolean { + if (queryToken === entryToken) { + return true; + } + + if (queryToken.length < 4 || entryToken.length < 4) { + return false; + } + + return entryToken.startsWith(queryToken) || queryToken.startsWith(entryToken); +} + +function clampScore(value: number): number { + return Math.max(0, Math.min(0.99, value)); +} + +function toConfidence(score: number): IconMatch['confidence'] { + if (score >= 0.9) { + return 'high'; + } + if (score >= 0.75) { + return 'medium'; + } + return 'low'; +} + +function compareMatches(left: IconMatch, right: IconMatch): number { + if (right.score !== left.score) { + return right.score - left.score; + } + if (left.isGeneric !== right.isGeneric) { + return Number(left.isGeneric) - Number(right.isGeneric); + } + if (left.isVariant !== right.isVariant) { + return Number(left.isVariant) - Number(right.isVariant); + } + if (left.wholeTokenMatch !== right.wholeTokenMatch) { + return Number(right.wholeTokenMatch) - Number(left.wholeTokenMatch); + } + return left.label.localeCompare(right.label); +} + +function getRunnerUpDelta(matches: IconMatch[], index: number): number { + const nextScore = matches[index + 1]?.score ?? 0; + return Math.max(0, matches[index].score - nextScore); +} + function entries(): IconEntry[] { return SVG_SOURCES.map((s) => { const parts = s.shapeId.split('/'); const lastPathPart = parts[parts.length - 1]; const lastHyphenPart = lastPathPart.split('-').pop() ?? lastPathPart; + const normalizedName = normalize(s.shapeId); + const normalizedLabel = normalize(s.label); + const nameTokens = tokenizeNormalized(s.shapeId); + const labelTokens = tokenizeNormalized(s.label); + const lastSegmentTokens = tokenizeNormalized(lastHyphenPart); + const meaningfulTokens = lastSegmentTokens.filter((token) => !VARIANT_TOKENS.has(token)); return { packId: s.packId, shapeId: s.shapeId, label: s.label, provider: s.provider, category: s.category, - normalizedName: normalize(s.shapeId), + normalizedName, + normalizedLabel, normalizedLastSegment: normalize(lastHyphenPart), + nameTokens, + labelTokens, + lastSegmentTokens, + isVariant: lastSegmentTokens.some((token) => VARIANT_TOKENS.has(token)), + isGeneric: + meaningfulTokens.length > 0 + && meaningfulTokens.every((token) => GENERIC_ENTRY_TOKENS.has(token)), }; }); } @@ -82,7 +226,13 @@ interface IconEntry { provider: string; category: string; normalizedName: string; + normalizedLabel: string; normalizedLastSegment: string; + nameTokens: string[]; + labelTokens: string[]; + lastSegmentTokens: string[]; + isVariant: boolean; + isGeneric: boolean; } let cachedEntries: IconEntry[] | null = null; @@ -108,14 +258,27 @@ function getByNormalized(): Map { export function matchIcon(query: string, providerHint?: string): IconMatch[] { const normalizedQuery = normalize(query); if (!normalizedQuery) return []; + const queryTokens = tokenizeNormalized(query); const byNormalized = getByNormalized(); const all = getEntries(); - // 1. Exact match on shape ID + // 1. Exact match on shape ID or human label const exact = byNormalized.get(normalizedQuery); if (exact && (!providerHint || exact.provider === providerHint)) { - return [toMatch(exact, 0.99, 'exact')]; + return finalizeMatches([toMatch(exact, 0.99, 'exact', 'exact shape or segment match', true)]); + } + + const exactLabel = all.find((entry) => { + if (providerHint && entry.provider !== providerHint) { + return false; + } + return ( + entry.normalizedLabel === normalizedQuery || entry.normalizedLastSegment === normalizedQuery + ); + }); + if (exactLabel) { + return finalizeMatches([toMatch(exactLabel, 0.98, 'exact', 'exact icon label match', true)]); } // 2. Alias resolution @@ -123,26 +286,72 @@ export function matchIcon(query: string, providerHint?: string): IconMatch[] { if (aliasTarget) { const aliasEntry = byNormalized.get(normalize(aliasTarget)); if (aliasEntry && (!providerHint || aliasEntry.provider === providerHint)) { - return [toMatch(aliasEntry, 0.95, 'alias')]; + return finalizeMatches([toMatch(aliasEntry, 0.97, 'alias', 'known technology alias', true)]); } } - // 3. Substring match (query contained in name, or name contained in query) + // 3. Weighted token and label matching const substringMatches: IconMatch[] = []; for (const entry of all) { if (providerHint && entry.provider !== providerHint) continue; - if (entry.normalizedLastSegment.length < 3 || normalizedQuery.length < 3) continue; if ( - entry.normalizedName.includes(normalizedQuery) || - entry.normalizedLastSegment.includes(normalizedQuery) || - normalizedQuery.includes(entry.normalizedLastSegment) + entry.normalizedLastSegment.length < 3 + || normalizedQuery.length < 3 + || queryTokens.length === 0 ) { - substringMatches.push(toMatch(entry, 0.85, 'substring')); + continue; } + + const tokenHits = queryTokens.filter( + (token) => + entry.nameTokens.some((entryToken) => tokensRoughlyMatch(token, entryToken)) + || entry.labelTokens.some((entryToken) => tokensRoughlyMatch(token, entryToken)) + || entry.lastSegmentTokens.some((entryToken) => tokensRoughlyMatch(token, entryToken)) + ); + const wholeTokenMatch = tokenHits.length === queryTokens.length; + const hasPartialMatch = + entry.normalizedName.includes(normalizedQuery) + || entry.normalizedLabel.includes(normalizedQuery) + || entry.normalizedLastSegment.includes(normalizedQuery) + || normalizedQuery.includes(entry.normalizedLastSegment); + + if (!wholeTokenMatch && !hasPartialMatch) { + continue; + } + + const exactLastSegment = entry.normalizedLastSegment === normalizedQuery; + const exactLabelMatch = entry.normalizedLabel === normalizedQuery; + const nameSequenceMatch = hasTokenSequence(entry.nameTokens, queryTokens); + const labelSequenceMatch = hasTokenSequence(entry.labelTokens, queryTokens); + const overlapRatio = tokenHits.length / queryTokens.length; + let score = + 0.42 + + overlapRatio * 0.28 + + (exactLastSegment ? 0.14 : 0) + + (exactLabelMatch ? 0.12 : 0) + + (wholeTokenMatch ? 0.08 : 0) + + (nameSequenceMatch || labelSequenceMatch ? 0.06 : 0) + + (providerHint && entry.provider === providerHint ? 0.04 : 0) + - (entry.isVariant ? 0.18 : 0) + - (entry.isGeneric ? 0.16 : 0); + + if (!wholeTokenMatch && hasPartialMatch) { + score -= 0.08; + } + + score = clampScore(score); + const reason = exactLastSegment + ? 'exact canonical icon segment' + : exactLabelMatch + ? 'exact icon label match' + : wholeTokenMatch + ? 'all query tokens align to icon tokens' + : 'partial token overlap'; + + substringMatches.push(toMatch(entry, score, 'substring', reason, wholeTokenMatch)); } if (substringMatches.length > 0) { - substringMatches.sort((a, b) => b.score - a.score); - return substringMatches.slice(0, 5); + return finalizeMatches(substringMatches); } // 4. Category match @@ -151,18 +360,41 @@ export function matchIcon(query: string, providerHint?: string): IconMatch[] { for (const entry of all) { if (providerHint && entry.provider !== providerHint) continue; if (normalize(entry.category).replace(/-/g, '').includes(normalizedCategory)) { - categoryMatches.push(toMatch(entry, 0.7, 'category')); + categoryMatches.push( + toMatch( + entry, + clampScore(0.54 - (entry.isVariant ? 0.08 : 0) - (entry.isGeneric ? 0.1 : 0)), + 'category', + 'category-only fallback', + false + ) + ); } } if (categoryMatches.length > 0) { - categoryMatches.sort((a, b) => b.score - a.score); - return categoryMatches.slice(0, 5); + return finalizeMatches(categoryMatches); } return []; } -function toMatch(entry: IconEntry, score: number, matchType: IconMatch['matchType']): IconMatch { +function finalizeMatches(matches: IconMatch[]): IconMatch[] { + const sorted = [...matches].sort(compareMatches); + + return sorted.slice(0, 5).map((match, index, topMatches) => ({ + ...match, + confidence: toConfidence(match.score), + runnerUpDelta: getRunnerUpDelta(topMatches, index), + })); +} + +function toMatch( + entry: IconEntry, + score: number, + matchType: IconMatch['matchType'], + reason: string, + wholeTokenMatch: boolean +): IconMatch { return { packId: entry.packId, shapeId: entry.shapeId, @@ -171,6 +403,12 @@ function toMatch(entry: IconEntry, score: number, matchType: IconMatch['matchTyp category: entry.category, score, matchType, + confidence: toConfidence(score), + reason, + runnerUpDelta: 0, + wholeTokenMatch, + isVariant: entry.isVariant, + isGeneric: entry.isGeneric, }; } diff --git a/src/lib/mermaidEnrichmentPipeline.test.ts b/src/lib/mermaidEnrichmentPipeline.test.ts index e5be9d29..ceb48192 100644 --- a/src/lib/mermaidEnrichmentPipeline.test.ts +++ b/src/lib/mermaidEnrichmentPipeline.test.ts @@ -34,11 +34,13 @@ describe('Mermaid → Enrichment Pipeline (E2E)', () => { const dbNode = enriched.find((n) => n.id === 'db'); expect(dbNode?.data.color).toBe('violet'); - expect(dbNode?.data.icon).toBe('database'); + expect(dbNode?.data.archIconPackId).toBeTruthy(); + expect(dbNode?.data.assetProvider).toBeTruthy(); const redisNode = enriched.find((n) => n.id === 'redis'); expect(redisNode?.data.color).toBe('red'); - expect(redisNode?.data.icon).toBe('hard-drive'); + expect(redisNode?.data.archIconPackId).toBeTruthy(); + expect(redisNode?.data.assetProvider).toBeTruthy(); }); it('flowchart with subgraphs: creates section nodes with proper hierarchy', async () => { @@ -155,6 +157,7 @@ describe('Mermaid → Enrichment Pipeline (E2E)', () => { // Enricher should preserve existing archIconPackId expect(enriched[0].data.archIconPackId).toBe('aws-official-starter-v1'); expect(enriched[0].data.archIconShapeId).toBe('api-gateway'); + expect(enriched[0].data.assetProvider).toBe('aws'); expect(enriched[0].data.color).toBe('violet'); }); diff --git a/src/lib/mermaidParser.ts b/src/lib/mermaidParser.ts index ee31fcbc..806d8738 100644 --- a/src/lib/mermaidParser.ts +++ b/src/lib/mermaidParser.ts @@ -1,5 +1,6 @@ import { MarkerType } from '@/lib/reactflowCompat'; import { createDefaultEdge } from '@/constants'; +import { SECTION_MIN_HEIGHT, SECTION_MIN_WIDTH } from '@/hooks/node-operations/sectionBounds'; import { setNodeParent } from './nodeParent'; import { createMermaidParseState, @@ -264,13 +265,15 @@ function createFlowNodes(model: MermaidParseModel): FlowNode[] { }, ...(node.type === 'section' ? { - style: { width: 400, height: 300 }, + style: { width: SECTION_MIN_WIDTH, height: SECTION_MIN_HEIGHT }, } : {}), }; if (node.parentId) { - flowNode = setNodeParent(flowNode, node.parentId); + flowNode = setNodeParent(flowNode, node.parentId, { + constrainToParent: false, + }); } if (node.classes) { diff --git a/src/lib/mermaidParserHelpers.ts b/src/lib/mermaidParserHelpers.ts index a0a73ccd..0a8d4748 100644 --- a/src/lib/mermaidParserHelpers.ts +++ b/src/lib/mermaidParserHelpers.ts @@ -82,6 +82,15 @@ export function normalizeMultilineStrings(input: string): string { export function normalizeEdgeLabels(input: string): string { let result = input; + // Collapse extended arrows: ---> → -->, ====> → ==>, -..-> → -.-> + // Mermaid spec allows any number of repeated chars in the arrow body. + result = result.replace(/={3,}>/g, '==>'); + result = result.replace(/-{3,}>/g, '-->'); + result = result.replace(/-\.{2,}->/g, '-.->'); + result = result.replace(/<-{3,}>/g, '<-->'); + result = result.replace(/<={3,}>/g, '<==>'); + result = result.replace(/<-\.{2,}->/g, '<-.->'); + // Inline-label arrow forms: == text ==> and -- text --> result = result.replace(/==(?![>])\s*(.+?)\s*==>/g, ' ==>|$1|'); result = result.replace(/--(?![>-])\s*(.+?)\s*-->/g, ' -->|$1|'); result = result.replace(/-\.\s*(.+?)\s*\.->/g, ' -.->|$1|'); @@ -202,7 +211,7 @@ function tryParseWithShape( } export function parseNodeDeclaration(raw: string): RawNode | null { - const trimmed = raw.trim(); + const trimmed = raw.trim().replace(/;$/, ''); if (!trimmed) return null; const annotation = extractModernAnnotation(trimmed); @@ -257,68 +266,199 @@ export const ARROW_PATTERNS = [ '--', ]; -function findArrowInLine(line: string): { arrow: string; before: string; after: string } | null { - for (const arrow of ARROW_PATTERNS) { - const index = line.indexOf(arrow); - if (index >= 0) { - return { - arrow, - before: line.substring(0, index).trim(), - after: line.substring(index + arrow.length).trim(), - }; +function sanitizeEdgeEndpoint(raw: string): string { + return raw.trim().replace(/;$/, '').replace(/:::.*$/, '').trim(); +} + +function findArrowInLine( + line: string +): { arrow: string; index: number; before: string; after: string } | null { + let quoteChar: '"' | "'" | null = null; + let pipeOpen = false; + let squareDepth = 0; + let roundDepth = 0; + let curlyDepth = 0; + + for (let index = 0; index < line.length; index++) { + const char = line[index]; + const previousChar = line[index - 1]; + + if (quoteChar) { + if (char === quoteChar && previousChar !== '\\') { + quoteChar = null; + } + continue; + } + + if (char === '"' || char === "'") { + quoteChar = char; + continue; + } + + if (char === '|') { + pipeOpen = !pipeOpen; + continue; + } + if (pipeOpen) { + continue; + } + + if (char === '[') { + squareDepth += 1; + continue; + } + if (char === ']') { + squareDepth = Math.max(0, squareDepth - 1); + continue; + } + if (char === '(') { + roundDepth += 1; + continue; + } + if (char === ')') { + roundDepth = Math.max(0, roundDepth - 1); + continue; + } + if (char === '{') { + curlyDepth += 1; + continue; + } + if (char === '}') { + curlyDepth = Math.max(0, curlyDepth - 1); + continue; + } + + if (squareDepth > 0 || roundDepth > 0 || curlyDepth > 0) { + continue; + } + + for (const arrow of ARROW_PATTERNS) { + if (line.startsWith(arrow, index)) { + return { + arrow, + index, + before: line.substring(0, index).trim(), + after: line.substring(index + arrow.length).trim(), + }; + } } } + return null; } +function parseEdgeLabelSegment( + line: string, + startIndex: number +): { label: string; nextIndex: number } { + let index = startIndex; + while (index < line.length && /\s/.test(line[index])) { + index += 1; + } + + if (line[index] !== '|') { + return { label: '', nextIndex: index }; + } + + let label = ''; + let quoteChar: '"' | "'" | null = null; + index += 1; + + while (index < line.length) { + const char = line[index]; + const previousChar = line[index - 1]; + + if (quoteChar) { + if (char === quoteChar && previousChar !== '\\') { + quoteChar = null; + } else { + label += char; + } + index += 1; + continue; + } + + if (char === '"' || char === "'") { + quoteChar = char; + index += 1; + continue; + } + + if (char === '|') { + return { label: label.trim(), nextIndex: index + 1 }; + } + + label += char; + index += 1; + } + + return { label: label.trim(), nextIndex: index }; +} + +function expandAmpersandEdges(line: string): string[] { + if (!line.includes('&')) return [line]; + const arrowMatch = findArrowInLine(line); + if (!arrowMatch) return [line]; + + const { arrow, index } = arrowMatch; + const sourcePart = line.substring(0, index).trim(); + const afterArrow = line.substring(index + arrow.length).trim(); + + // Parse optional pipe label after arrow + const labelMatch = afterArrow.match(/^\|([^|]*)\|(.*)/); + const label = labelMatch ? `|${labelMatch[1]}|` : ''; + const targetPart = labelMatch ? labelMatch[2].trim() : afterArrow; + + const sources = sourcePart.split('&').map((s) => s.trim()).filter(Boolean); + const targets = targetPart.split('&').map((s) => s.trim()).filter(Boolean); + + if (sources.length <= 1 && targets.length <= 1) return [line]; + + const lines: string[] = []; + for (const src of sources) { + for (const tgt of targets) { + lines.push(`${src} ${arrow}${label} ${tgt}`); + } + } + return lines; +} + export function parseEdgeLine(line: string): Array<{ sourceRaw: string; targetRaw: string; label: string; arrowType: string; }> { + const expanded = expandAmpersandEdges(line); + if (expanded.length > 1) { + return expanded.flatMap(parseEdgeLine); + } + const edges: Array<{ sourceRaw: string; targetRaw: string; label: string; arrowType: string }> = []; - let remaining = line; + let remaining = line.trim(); let lastNodeRaw: string | null = null; while (remaining.trim()) { const arrowMatch = findArrowInLine(remaining); if (!arrowMatch) break; - const { arrow, before, after } = arrowMatch; - const sourceRaw = lastNodeRaw || before; - let label = ''; - let targetAndRest = after; - - const labelMatch = targetAndRest.match(/^\|"?([^"|]*)"?\|\s*/); - if (labelMatch) { - label = labelMatch[1].trim(); - targetAndRest = targetAndRest.substring(labelMatch[0].length); - } - - const nextArrowMatch = findArrowInLine(targetAndRest); - let targetRaw: string; - - if (nextArrowMatch) { - targetRaw = nextArrowMatch.before; - remaining = targetAndRest; - } else { - targetRaw = targetAndRest; - remaining = ''; - } - - let source = sourceRaw.trim(); - let target = targetRaw.trim(); - - if (source.includes(':::')) source = source.split(':::')[0]; - if (target.includes(':::')) target = target.split(':::')[0]; - - if (source && target) { - edges.push({ sourceRaw: source, targetRaw: target, label, arrowType: arrow }); + const { arrow } = arrowMatch; + const sourceRaw = sanitizeEdgeEndpoint(lastNodeRaw || arrowMatch.before); + const sourceOffset = arrowMatch.index + arrow.length; + const { label, nextIndex } = parseEdgeLabelSegment(remaining, sourceOffset); + const targetSegment = remaining.slice(nextIndex).trim(); + const nextArrowMatch = findArrowInLine(targetSegment); + const targetRaw = sanitizeEdgeEndpoint( + nextArrowMatch ? targetSegment.slice(0, nextArrowMatch.index) : targetSegment + ); + + if (sourceRaw && targetRaw) { + edges.push({ sourceRaw, targetRaw, label, arrowType: arrow }); } - lastNodeRaw = targetRaw.trim(); + lastNodeRaw = targetRaw; + remaining = nextArrowMatch ? targetSegment.slice(nextArrowMatch.index) : ''; if (!nextArrowMatch) break; } diff --git a/src/lib/nodeEnricher.test.ts b/src/lib/nodeEnricher.test.ts index 8e39467a..69ef4e48 100644 --- a/src/lib/nodeEnricher.test.ts +++ b/src/lib/nodeEnricher.test.ts @@ -43,11 +43,14 @@ describe('enrichNodesWithIcons', () => { // All three should get provider icons (any catalog) expect(enriched[0].data.archIconPackId).toBeTruthy(); expect(enriched[0].data.archIconShapeId).toContain('postgresql'); + expect(enriched[0].data.assetProvider).toBe('developer'); expect(enriched[1].data.archIconPackId).toBeTruthy(); expect(enriched[1].data.archIconShapeId).toContain('redis'); + expect(enriched[1].data.assetProvider).toBe('developer'); expect(enriched[2].data.archIconPackId).toBeTruthy(); + expect(enriched[2].data.assetProvider).toBeTruthy(); }); it('skips section and group nodes', async () => { @@ -146,6 +149,7 @@ describe('enrichNodesWithIcons', () => { const enriched = await enrichNodesWithIcons(nodes); expect(enriched[0].data.archIconPackId).toBeTruthy(); expect(enriched[0].data.archIconShapeId).toContain('redis'); + expect(enriched[0].data.assetProvider).toBe('developer'); }); it('uses provider filter when set', async () => { @@ -158,6 +162,80 @@ describe('enrichNodesWithIcons', () => { const enriched = await enrichNodesWithIcons(nodes); if (enriched[0].data.archIconPackId) { expect(enriched[0].data.archIconPackId).toBe('aws-official-starter-v1'); + expect(enriched[0].data.assetProvider).toBe('aws'); + } + }); + + it('disables aggressive label-based icon enrichment during mermaid import', async () => { + const nodes = [makeNode('payment', 'Payment Processing Flow')]; + + const enriched = await enrichNodesWithIcons(nodes, { + diagramType: 'flowchart', + mode: 'mermaid-import', + }); + + expect(enriched[0].data.archIconPackId).toBeUndefined(); + }); + + it('keeps technology icon enrichment enabled for flowchart imports', async () => { + const nodes = [ + makeNode('db', 'PostgreSQL'), + makeNode('cache', 'Redis Cache'), + makeNode('queue', 'Kafka'), + makeNode('proxy', 'Nginx'), + makeNode('app', 'React App'), + makeNode('bucket', 'Amazon S3'), + makeNode('fn', 'AWS Lambda'), + ]; + + const enriched = await enrichNodesWithIcons(nodes, { + diagramType: 'flowchart', + mode: 'mermaid-import', + }); + + expect(enriched[0].data.archIconShapeId).toContain('postgresql'); + expect(enriched[1].data.archIconShapeId).toContain('redis'); + expect(enriched[2].data.archIconPackId).toBeTruthy(); + expect(enriched[3].data.archIconPackId).toBeTruthy(); + expect(enriched[4].data.archIconPackId).toBeTruthy(); + expect(enriched[5].data.archIconPackId).toBeTruthy(); + expect(enriched[6].data.archIconPackId).toBeTruthy(); + }); + + it('skips import-time icon enrichment for diagram families with specialized visuals', async () => { + const nodes = [makeNode('idle', 'Idle')]; + + const enriched = await enrichNodesWithIcons(nodes, { + diagramType: 'stateDiagram', + mode: 'mermaid-import', + }); + + expect(enriched[0].data.archIconPackId).toBeUndefined(); + expect(enriched[0].data.icon).toBeUndefined(); + }); + + it('keeps ambiguous flowchart imports iconless', async () => { + const nodes = [ + makeNode('payment', 'Payment Processing Flow'), + makeNode('check', 'Check Conditions'), + makeNode('frontend', 'Frontend'), + makeNode('auth', 'Auth'), + makeNode('service', 'Service'), + makeNode('gateway', 'API Gateway'), + makeNode('oauth', 'OAuth'), + makeNode('jwt', 'JWT'), + makeNode('sso', 'SSO'), + makeNode('llm', 'LLM Router'), + ]; + + const enriched = await enrichNodesWithIcons(nodes, { + diagramType: 'flowchart', + mode: 'mermaid-import', + }); + + for (const node of enriched) { + expect(node.data.archIconPackId).toBeUndefined(); + expect(node.data.assetProvider).toBeUndefined(); } }); }); diff --git a/src/lib/nodeEnricher.ts b/src/lib/nodeEnricher.ts index 20eba472..c54449b8 100644 --- a/src/lib/nodeEnricher.ts +++ b/src/lib/nodeEnricher.ts @@ -1,12 +1,90 @@ -import type { FlowNode } from '@/lib/types'; -import { classifyNode } from '@/lib/semanticClassifier'; +import type { DiagramType, FlowNode } from '@/lib/types'; +import type { DomainLibraryCategory } from '@/services/domainLibrary'; +import { createProviderIconData, normalizeNodeIconData } from '@/lib/nodeIconState'; +import { + classifyNode, + isCommonEnglishIconTerm, + isSpecificTechnologyIconQuery, +} from '@/lib/semanticClassifier'; import { matchIcon, type IconMatch } from '@/lib/iconMatcher'; -export function enrichNodesWithIcons(nodes: FlowNode[]): FlowNode[] { - return nodes.map(enrichSingleNode); +export interface EnrichNodesWithIconsOptions { + diagramType?: DiagramType; + mode?: 'general' | 'mermaid-import'; } -function enrichSingleNode(node: FlowNode): FlowNode { +const IMPORT_ICON_MATCH_THRESHOLD = 0.92; +const DEFAULT_ICON_MATCH_THRESHOLD = 0.8; +const DIAGRAM_TYPES_WITHOUT_IMPORT_ICON_ENRICHMENT = new Set([ + 'stateDiagram', + 'sequence', + 'classDiagram', + 'erDiagram', + 'journey', +]); + +function withNormalizedNodeData( + node: FlowNode, + dataOverrides?: Record +): FlowNode { + return { + ...node, + data: normalizeNodeIconData({ + ...node.data, + ...dataOverrides, + }), + }; +} + +function applyProviderIcon(match: IconMatch, updates: Record): void { + Object.assign( + updates, + createProviderIconData({ + packId: match.packId, + shapeId: match.shapeId, + provider: match.provider as DomainLibraryCategory, + category: match.category, + }) + ); + updates.assetPresentation = 'icon'; +} + +function isTrustedImportMatch(match: IconMatch, query: string): boolean { + if (match.isVariant) { + return false; + } + + if (match.matchType === 'exact' || match.matchType === 'alias') { + return true; + } + + if (match.matchType !== 'substring') { + return false; + } + + return ( + match.confidence === 'high' + && match.wholeTokenMatch + && !match.isGeneric + && !isCommonEnglishIconTerm(query) + && match.runnerUpDelta >= 0.08 + ); +} + +export function enrichNodesWithIcons( + nodes: FlowNode[], + options: EnrichNodesWithIconsOptions = {} +): FlowNode[] { + return nodes.map((node) => { + try { + return enrichSingleNode(node, options); + } catch { + return node; + } + }); +} + +function enrichSingleNode(node: FlowNode, options: EnrichNodesWithIconsOptions): FlowNode { if (node.type === 'section' || node.type === 'group' || node.type === 'swimlane') { return node; } @@ -19,7 +97,7 @@ function enrichSingleNode(node: FlowNode): FlowNode { const hasAnyIcon = Boolean(node.data?.icon) || hasExplicitProviderIcon; if (hasExplicitColor && hasAnyIcon) { - return node; + return withNormalizedNodeData(node); } const hint = classifyNode({ id: node.id, label, shape: node.data?.shape }); @@ -30,20 +108,14 @@ function enrichSingleNode(node: FlowNode): FlowNode { } if (!hasExplicitProviderIcon) { - applyIcon(node, label, hint, dataUpdates); + applyIcon(node, label, hint, dataUpdates, options); } if (Object.keys(dataUpdates).length === 0) { - return node; + return withNormalizedNodeData(node); } - return { - ...node, - data: { - ...node.data, - ...dataUpdates, - }, - }; + return withNormalizedNodeData(node, dataUpdates); } function applyColor( @@ -66,49 +138,79 @@ function applyIcon( node: FlowNode, label: string, hint: { iconQuery: string; lucideFallback: string; category: string }, - updates: Record + updates: Record, + options: EnrichNodesWithIconsOptions ): void { + if (node.type === 'start' || node.type === 'end' || node.type === 'decision') { + applyLucideFallback(node, hint.lucideFallback, updates); + return; + } + const explicitIcon = node.data?.icon; const provider = node.data?.provider; const providerHint = typeof provider === 'string' ? provider : undefined; + const { iconMatchThreshold, iconEnrichmentAllowed } = getIconEnrichmentPolicy(options); - // Priority 1: Explicit icon attribute (e.g., icon: "redis") if (explicitIcon && typeof explicitIcon === 'string' && explicitIcon !== 'none') { - const match = findBestMatch(explicitIcon, providerHint); + const match = findBestMatch(explicitIcon, providerHint, iconMatchThreshold, options); if (match) { - updates.archIconPackId = match.packId; - updates.archIconShapeId = match.shapeId; - updates.assetPresentation = 'icon'; + applyProviderIcon(match, updates); } return; } - // Priority 2: Classifier icon query (e.g., label contains "PostgreSQL") - if (hint.iconQuery) { - const match = findBestMatch(hint.iconQuery, providerHint); + if (!iconEnrichmentAllowed) { + applyLucideFallback(node, hint.lucideFallback, updates); + return; + } + + if (hint.iconQuery && shouldUseClassifierIconQuery(hint.iconQuery, options)) { + const match = findBestMatch(hint.iconQuery, providerHint, iconMatchThreshold, options); if (match) { - updates.archIconPackId = match.packId; - updates.archIconShapeId = match.shapeId; - updates.assetPresentation = 'icon'; - updates.icon = hint.lucideFallback; + applyProviderIcon(match, updates); return; } } - // Priority 3: Label-based fallback (icons: auto — match by node label) - // Only when node has NO icon at all - if (label && !node.data?.icon) { - const match = findBestMatch(label, providerHint); + if (label && !node.data?.icon && shouldUseLabelFallback(label, options)) { + const match = findBestMatch(label, providerHint, iconMatchThreshold, options); if (match) { - updates.archIconPackId = match.packId; - updates.archIconShapeId = match.shapeId; - updates.assetPresentation = 'icon'; + applyProviderIcon(match, updates); } } - // Lucide icon fallback - if (hint.lucideFallback && hint.lucideFallback !== 'box') { - updates.icon = hint.lucideFallback; + // Lucide icon fallback — only in non-import mode, or for structural node types + if (!options.mode || options.mode !== 'mermaid-import') { + applyLucideFallback(node, hint.lucideFallback, updates); + } +} + +function getIconEnrichmentPolicy(options: EnrichNodesWithIconsOptions): { + iconMatchThreshold: number; + iconEnrichmentAllowed: boolean; +} { + const strictImportMode = options.mode === 'mermaid-import'; + const iconMatchThreshold = strictImportMode + ? IMPORT_ICON_MATCH_THRESHOLD + : DEFAULT_ICON_MATCH_THRESHOLD; + const iconEnrichmentAllowed = + !strictImportMode + || !options.diagramType + || !DIAGRAM_TYPES_WITHOUT_IMPORT_ICON_ENRICHMENT.has(options.diagramType); + + return { + iconMatchThreshold, + iconEnrichmentAllowed, + }; +} + +function applyLucideFallback( + node: FlowNode, + lucideFallback: string, + updates: Record +): void { + if (lucideFallback && lucideFallback !== 'box') { + updates.icon = lucideFallback; } else if (node.type === 'start') { updates.icon = 'play'; } else if (node.type === 'end') { @@ -118,8 +220,47 @@ function applyIcon( } } -function findBestMatch(query: string, providerHint?: string): IconMatch | undefined { +function shouldUseClassifierIconQuery( + iconQuery: string, + options: EnrichNodesWithIconsOptions +): boolean { + if (options.mode !== 'mermaid-import') { + return !isCommonEnglishIconTerm(iconQuery); + } + + if (options.diagramType === 'flowchart') { + return isSpecificTechnologyIconQuery(iconQuery); + } + + return !isCommonEnglishIconTerm(iconQuery); +} + +function shouldUseLabelFallback( + label: string, + options: EnrichNodesWithIconsOptions +): boolean { + if (options.mode === 'mermaid-import') { + return false; + } + + return !isCommonEnglishIconTerm(label); +} + +function findBestMatch( + query: string, + providerHint?: string, + threshold = DEFAULT_ICON_MATCH_THRESHOLD, + options: EnrichNodesWithIconsOptions = {} +): IconMatch | undefined { const matches = matchIcon(query, providerHint); const best = matches[0]; - return best && best.score >= 0.8 ? best : undefined; + if (!best || best.score < threshold) { + return undefined; + } + + if (options.mode === 'mermaid-import' && !isTrustedImportMatch(best, query)) { + return undefined; + } + + return best; } diff --git a/src/lib/nodeIconState.test.ts b/src/lib/nodeIconState.test.ts new file mode 100644 index 00000000..00d9962d --- /dev/null +++ b/src/lib/nodeIconState.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { + createBuiltInIconData, + createProviderIconData, + createUploadedIconData, + inferAssetProviderFromPackId, + normalizeNodeIconData, +} from './nodeIconState'; + +describe('nodeIconState', () => { + it('infers provider from known pack ids', () => { + expect(inferAssetProviderFromPackId('aws-official-starter-v1')).toBe('aws'); + expect(inferAssetProviderFromPackId('developer-icons-v1')).toBe('developer'); + }); + + it('normalizes pack and shape to a canonical provider icon payload', () => { + expect( + normalizeNodeIconData({ + archIconPackId: 'aws-official-starter-v1', + archIconShapeId: 'compute-lambda', + }) + ).toMatchObject({ + archIconPackId: 'aws-official-starter-v1', + archIconShapeId: 'compute-lambda', + assetProvider: 'aws', + }); + }); + + it('createBuiltInIconData clears provider and upload fields', () => { + expect(createBuiltInIconData('Database')).toEqual({ + icon: 'Database', + customIconUrl: undefined, + assetProvider: undefined, + assetCategory: undefined, + archIconPackId: undefined, + archIconShapeId: undefined, + }); + }); + + it('createProviderIconData clears built-in and upload fields', () => { + expect( + createProviderIconData({ + packId: 'aws-official-starter-v1', + shapeId: 'compute-lambda', + provider: 'aws', + category: 'Compute', + }) + ).toEqual({ + icon: undefined, + customIconUrl: undefined, + archIconPackId: 'aws-official-starter-v1', + archIconShapeId: 'compute-lambda', + assetProvider: 'aws', + assetCategory: 'Compute', + }); + }); + + it('createUploadedIconData clears built-in and provider fields', () => { + expect(createUploadedIconData('data:image/svg+xml;base64,abc')).toEqual({ + icon: undefined, + customIconUrl: 'data:image/svg+xml;base64,abc', + assetProvider: undefined, + assetCategory: undefined, + archIconPackId: undefined, + archIconShapeId: undefined, + }); + }); +}); diff --git a/src/lib/nodeIconState.ts b/src/lib/nodeIconState.ts new file mode 100644 index 00000000..5b38d421 --- /dev/null +++ b/src/lib/nodeIconState.ts @@ -0,0 +1,146 @@ +import type { NodeData } from '@/lib/types'; +import type { DomainLibraryCategory } from '@/services/domainLibrary'; +import { KNOWN_PROVIDER_PACK_IDS, SVG_SOURCES } from '@/services/shapeLibrary/providerCatalog'; + +export interface ResolvedProviderIconMetadata { + provider?: DomainLibraryCategory; + category?: string; + label?: string; +} + +const PACK_ID_TO_PROVIDER = new Map( + Object.entries(KNOWN_PROVIDER_PACK_IDS).map(([provider, packId]) => [ + packId.toLowerCase(), + provider as DomainLibraryCategory, + ]) +); + +const SHAPE_METADATA = new Map( + SVG_SOURCES.map((source) => [ + `${source.packId}:${source.shapeId}`, + { + provider: source.provider as DomainLibraryCategory, + category: source.category, + label: source.label, + }, + ]) +); + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +export function inferAssetProviderFromPackId( + packId: string | undefined +): DomainLibraryCategory | undefined { + if (!isNonEmptyString(packId)) { + return undefined; + } + + const normalizedPackId = packId.trim().toLowerCase(); + const exactMatch = PACK_ID_TO_PROVIDER.get(normalizedPackId); + if (exactMatch) { + return exactMatch; + } + + const prefixMatch = Array.from(PACK_ID_TO_PROVIDER.entries()).find(([, provider]) => + normalizedPackId.includes(provider) + ); + return prefixMatch?.[1]; +} + +export function getProviderIconMetadata( + packId: string | undefined, + shapeId: string | undefined +): ResolvedProviderIconMetadata { + if (!isNonEmptyString(packId) || !isNonEmptyString(shapeId)) { + return {}; + } + + return SHAPE_METADATA.get(`${packId}:${shapeId}`) ?? { + provider: inferAssetProviderFromPackId(packId), + }; +} + +export function createBuiltInIconData(icon: string): Partial { + return { + icon, + customIconUrl: undefined, + assetProvider: undefined, + assetCategory: undefined, + archIconPackId: undefined, + archIconShapeId: undefined, + }; +} + +export function createUploadedIconData(url?: string): Partial { + return { + icon: undefined, + customIconUrl: url, + assetProvider: undefined, + assetCategory: undefined, + archIconPackId: undefined, + archIconShapeId: undefined, + }; +} + +export function createProviderIconData(input: { + packId: string; + shapeId: string; + provider?: DomainLibraryCategory; + category?: string; +}): Partial { + const resolved = getProviderIconMetadata(input.packId, input.shapeId); + + return { + icon: undefined, + customIconUrl: undefined, + archIconPackId: input.packId, + archIconShapeId: input.shapeId, + assetProvider: input.provider ?? resolved.provider, + assetCategory: input.category ?? resolved.category, + }; +} + +export function normalizeNodeIconData | undefined>(data: T): T { + if (!data) { + return data; + } + + const next: Partial = { ...data }; + const hasProviderIcon = + isNonEmptyString(next.archIconPackId) && isNonEmptyString(next.archIconShapeId); + const hasUploadIcon = isNonEmptyString(next.customIconUrl); + const hasBuiltInIcon = isNonEmptyString(next.icon); + + if (hasProviderIcon) { + Object.assign( + next, + createProviderIconData({ + packId: next.archIconPackId as string, + shapeId: next.archIconShapeId as string, + provider: next.assetProvider as DomainLibraryCategory | undefined, + category: next.assetCategory as string | undefined, + }) + ); + return next as T; + } + + if (hasUploadIcon) { + Object.assign(next, createUploadedIconData(next.customIconUrl as string)); + return next as T; + } + + if (hasBuiltInIcon) { + Object.assign(next, createBuiltInIconData(next.icon as string)); + return next as T; + } + + next.icon = undefined; + next.customIconUrl = undefined; + next.assetProvider = undefined; + next.assetCategory = undefined; + next.archIconPackId = undefined; + next.archIconShapeId = undefined; + return next as T; +} diff --git a/src/lib/semanticClassifier.test.ts b/src/lib/semanticClassifier.test.ts index b77d9bbb..ace756af 100644 --- a/src/lib/semanticClassifier.test.ts +++ b/src/lib/semanticClassifier.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { classifyNode } from './semanticClassifier'; +import { + classifyNode, + isCommonEnglishIconTerm, + isSpecificTechnologyIconQuery, +} from './semanticClassifier'; describe('classifyNode', () => { it('classifies start nodes', () => { @@ -59,9 +63,11 @@ describe('classifyNode', () => { it('classifies gateway nodes', () => { const hint = classifyNode({ id: 'gw', label: 'API Gateway' }); expect(hint.category).toBe('gateway'); + expect(hint.iconQuery).toBe(''); const nginx = classifyNode({ id: 'proxy', label: 'Nginx' }); expect(nginx.category).toBe('gateway'); + expect(nginx.iconQuery).toMatch(/nginx/i); }); it('classifies frontend nodes', () => { @@ -82,6 +88,16 @@ describe('classifyNode', () => { it('classifies auth nodes', () => { const hint = classifyNode({ id: 'auth', label: 'OAuth Login' }); expect(hint.category).toBe('auth'); + expect(hint.iconQuery).toBe(''); + }); + + it('keeps generic categories color-aware but icon-query sparse', () => { + expect(classifyNode({ id: 'db', label: 'Database' }).iconQuery).toBe(''); + expect(classifyNode({ id: 'queue', label: 'Queue' }).iconQuery).toBe(''); + expect(classifyNode({ id: 'storage', label: 'Storage' }).iconQuery).toBe(''); + expect(classifyNode({ id: 'frontend', label: 'Frontend' }).iconQuery).toBe(''); + expect(classifyNode({ id: 'service', label: 'Service' }).iconQuery).toBe(''); + expect(classifyNode({ id: 'llm', label: 'LLM Router' }).iconQuery).toBe(''); }); it('returns process as default', () => { @@ -89,4 +105,23 @@ describe('classifyNode', () => { expect(hint.category).toBe('process'); expect(hint.color).toBe('slate'); }); + + it('detects generic icon terms conservatively', () => { + expect(isCommonEnglishIconTerm('service')).toBe(true); + expect(isCommonEnglishIconTerm('payment')).toBe(true); + expect(isCommonEnglishIconTerm('PostgreSQL')).toBe(false); + }); + + it('recognizes specific technology queries for import icon enrichment', () => { + expect(isSpecificTechnologyIconQuery('postgres')).toBe(true); + expect(isSpecificTechnologyIconQuery('nginx')).toBe(true); + expect(isSpecificTechnologyIconQuery('amazon s3')).toBe(true); + expect(isSpecificTechnologyIconQuery('aws lambda')).toBe(true); + expect(isSpecificTechnologyIconQuery('service')).toBe(false); + expect(isSpecificTechnologyIconQuery('check')).toBe(false); + expect(isSpecificTechnologyIconQuery('oauth')).toBe(false); + expect(isSpecificTechnologyIconQuery('jwt')).toBe(false); + expect(isSpecificTechnologyIconQuery('sso')).toBe(false); + expect(isSpecificTechnologyIconQuery('llm')).toBe(false); + }); }); diff --git a/src/lib/semanticClassifier.ts b/src/lib/semanticClassifier.ts index 4d3f04eb..ac3d198a 100644 --- a/src/lib/semanticClassifier.ts +++ b/src/lib/semanticClassifier.ts @@ -23,6 +23,171 @@ export interface SemanticHint { lucideFallback: string; } +const SPECIFIC_TECHNOLOGY_PATTERNS = [ + /\bpostgres(?:ql)?\b/i, + /\bmysql\b/i, + /\bmongo(?:db)?\b/i, + /\bdynamodb\b/i, + /\baurora\b/i, + /\bsqlite\b/i, + /\bmariadb\b/i, + /\bcockroach\b/i, + /\bsupabase\b/i, + /\bredis\b/i, + /\bmemcache(?:d)?\b/i, + /\belasticache\b/i, + /\bkafka\b/i, + /\brabbitmq\b/i, + /\bsqs\b/i, + /\bpulsar\b/i, + /\bnats\b/i, + /\bnginx\b/i, + /\bhaproxy\b/i, + /\balb\b/i, + /\bcloudfront\b/i, + /\bingress\b/i, + /\benvoy\b/i, + /\bcognito\b/i, + /\breact\b/i, + /\bvue\b/i, + /\bangular\b/i, + /\bsvelte\b/i, + /\bnext\.?js\b/i, + /\bnuxt\b/i, + /\bexpress\b/i, + /\bnode\.?js\b/i, + /\bdjango\b/i, + /\bflask\b/i, + /\bfastapi\b/i, + /\bspring\b/i, + /\brails\b/i, + /\blaravel\b/i, + /\bgin\b/i, + /\bactix\b/i, + /\bnest\.?js\b/i, + /\bdocker\b/i, + /\bkubernetes\b/i, + /\bk8s\b/i, + /\becs\b/i, + /\beks\b/i, + /\bcloud\s*run\b/i, + /\bs3\b/i, + /\bgemini\b/i, + /\bopenai\b/i, + /\banthropic\b/i, + /\bchatgpt\b/i, + /\bgpt-?[a-z0-9.]*\b/i, + /\bclaude(?:-[a-z0-9.]+)?\b/i, + /\bvertexai\b/i, + /\bbedrock\b/i, + /\bamazon\s*s3\b/i, + /\baws\s*lambda\b/i, + /\blambda\b/i, + // CNCF / cloud-native + /\bistio\b/i, + /\bcilium\b/i, + /\blinkerd\b/i, + /\bhelm\b/i, + /\bargo(?:\s*cd)?\b/i, + /\bdapr\b/i, + /\bprometheus\b/i, + /\bjaeger\b/i, + /\bopentelemetry\b/i, + /\botel\b/i, + /\bflux(?:cd)?\b/i, + /\bharbor\b/i, + /\betcd\b/i, + /\bcert-?manager\b/i, + /\bkeda\b/i, + /\bcrossplane\b/i, + /\bknative\b/i, + /\bvault\b/i, + /\bkeycloak\b/i, + /\bgrpc\b/i, + /\bcontainerd\b/i, + /\bfalco\b/i, + /\bgrafana\b/i, + /\bdatadog\b/i, + /\bnewrelic\b/i, + /\bsentry\b/i, + /\bsplunk\b/i, + /\bdynatrace\b/i, + // Azure + /\bcosmos\s*db\b/i, + /\bazure\s*cosmos\b/i, + /\bservice\s*bus\b/i, + /\bazure\s*service\s*bus\b/i, + /\bevent\s*hub(?:s)?\b/i, + /\bazure\s*event\s*hub\b/i, + /\baks\b/i, + /\bazure\s*kubernetes\b/i, + /\bazure\s*functions?\b/i, + /\bazure\s*openai\b/i, + /\bazure\s*monitor\b/i, + /\bkey\s*vault\b/i, + /\bazure\s*key\s*vault\b/i, + /\bcontainer\s*apps?\b/i, + /\bazure\s*container\b/i, + /\bapi\s*management\b/i, + /\bapim\b/i, + /\bevent\s*grid\b/i, + /\bcognitive\s*services?\b/i, + /\bazure\s*devops\b/i, + /\bazure\s*sql\b/i, + /\bazure\s*blob\b/i, + /\bfront\s*door\b/i, +]; + +const COMMON_ENGLISH_ICON_TERMS = new Set([ + 'action', + 'admin', + 'api', + 'app', + 'auth', + 'backend', + 'browser', + 'cache', + 'check', + 'client', + 'component', + 'compute', + 'condition', + 'data', + 'database', + 'db', + 'decision', + 'edge', + 'end', + 'external', + 'flow', + 'frontend', + 'gateway', + 'input', + 'job', + 'mobile', + 'node', + 'output', + 'payment', + 'process', + 'queue', + 'screen', + 'server', + 'service', + 'stage', + 'start', + 'state', + 'step', + 'storage', + 'system', + 'task', + 'ui', + 'user', + 'users', + 'validator', + 'view', + 'worker', +]); + interface ClassifierRule { patterns: RegExp[]; category: SemanticCategory; @@ -31,6 +196,15 @@ interface ClassifierRule { extractQuery?: (text: string, id: string) => string; } +function extractFirstMatch(text: string, pattern: RegExp): string { + const match = text.match(pattern); + return match?.[1] ?? ''; +} + +function createExtractQuery(pattern: RegExp): (text: string) => string { + return (text: string) => extractFirstMatch(text, pattern); +} + const RULES: ClassifierRule[] = [ { patterns: [/\bstart\b/i, /\bbegin\b/i, /\binit\b/i, /\bentry\b/i, /\blaunch\b/i], @@ -62,22 +236,16 @@ const RULES: ClassifierRule[] = [ category: 'database', color: 'violet', lucideFallback: 'database', - extractQuery: (text) => { - const m = text.match( - /(postgres(?:ql)?|mysql|mongo(?:db)?|dynamodb|aurora|sqlite|mariadb|cockroach|supabase)/i - ); - return m ? m[1] : text.split(/\s+/)[0]; - }, + extractQuery: createExtractQuery( + /(postgres(?:ql)?|mysql|mongo(?:db)?|dynamodb|aurora|sqlite|mariadb|cockroach|supabase)/i + ), }, { patterns: [/\bredis\b/i, /\bmemcache/i, /\bcache\b/i, /\belasticache\b/i], category: 'cache', color: 'red', lucideFallback: 'hard-drive', - extractQuery: (text) => { - const m = text.match(/(redis|memcache(?:d)?|elasticache)/i); - return m ? m[1] : 'cache'; - }, + extractQuery: createExtractQuery(/(redis|memcache(?:d)?|elasticache)/i), }, { patterns: [ @@ -92,10 +260,7 @@ const RULES: ClassifierRule[] = [ category: 'queue', color: 'amber', lucideFallback: 'layers', - extractQuery: (text) => { - const m = text.match(/(kafka|rabbitmq|sqs|pulsar|nats)/i); - return m ? m[1] : 'queue'; - }, + extractQuery: createExtractQuery(/(kafka|rabbitmq|sqs|pulsar|nats)/i), }, { patterns: [ @@ -126,10 +291,7 @@ const RULES: ClassifierRule[] = [ category: 'gateway', color: 'slate', lucideFallback: 'shield', - extractQuery: (text) => { - const m = text.match(/(api[- ]?gateway|nginx|haproxy|alb|cloudfront|ingress|envoy)/i); - return m ? m[1] : 'gateway'; - }, + extractQuery: createExtractQuery(/(nginx|haproxy|alb|cloudfront|ingress|envoy)/i), }, { patterns: [ @@ -145,12 +307,14 @@ const RULES: ClassifierRule[] = [ category: 'auth', color: 'amber', lucideFallback: 'key-round', + extractQuery: createExtractQuery(/(cognito|auth0|keycloak|oauth2)/i), }, { patterns: [/\bs3\b/i, /\bblob\b/i, /\bstorage\b/i, /\buploads?\b/i, /\bcdn\b/i], category: 'storage', color: 'yellow', lucideFallback: 'folder', + extractQuery: createExtractQuery(/(amazon\s*s3|s3)/i), }, { patterns: [ @@ -170,10 +334,25 @@ const RULES: ClassifierRule[] = [ category: 'frontend', color: 'blue', lucideFallback: 'monitor', - extractQuery: (text) => { - const m = text.match(/(react|vue|angular|svelte|next(?:\.?js)?|nuxt)/i); - return m ? m[1] : 'frontend'; - }, + extractQuery: createExtractQuery(/(react|vue|angular|svelte|next(?:\.?js)?|nuxt)/i), + }, + { + patterns: [ + /\bgemini\b/i, + /\bgpt\b/i, + /\bclaude\b/i, + /\bopenai\b/i, + /\bllm\b/i, + /\bvertexai\b/i, + /\bbedrock\b/i, + /\bmodel\b/i, + ], + category: 'service', + color: 'blue', + lucideFallback: 'cpu', + extractQuery: createExtractQuery( + /(gemini|openai|chatgpt|gpt-?[a-z0-9.]*|claude(?:-[a-z0-9.]+)?|vertexai|bedrock|anthropic)/i + ), }, { patterns: [ @@ -188,6 +367,8 @@ const RULES: ClassifierRule[] = [ /\bgin\b/i, /\bactix\b/i, /\bnest\.?js\b/i, + /\baws\s*lambda\b/i, + /\blambda\b/i, /\bapi\b/i, /\bservice\b/i, /\bbackend\b/i, @@ -197,12 +378,9 @@ const RULES: ClassifierRule[] = [ category: 'service', color: 'blue', lucideFallback: 'server', - extractQuery: (text) => { - const m = text.match( - /(express|node\.?js|django|flask|fastapi|spring|rails|laravel|gin|actix|nest\.?js)/i - ); - return m ? m[1] : text.split(/\s+/)[0]; - }, + extractQuery: createExtractQuery( + /(express|node\.?js|django|flask|fastapi|spring|rails|laravel|gin|actix|nest\.?js|aws\s*lambda|lambda)/i + ), }, { patterns: [ @@ -211,16 +389,127 @@ const RULES: ClassifierRule[] = [ /\bk8s\b/i, /\becs\b/i, /\beks\b/i, + /\baks\b/i, /\bcloud\s*run\b/i, /\bcontainer\b/i, + /\bcontainerd\b/i, ], category: 'service', color: 'blue', lucideFallback: 'container', - extractQuery: (text) => { - const m = text.match(/(docker|kubernetes|k8s|ecs|eks|cloud\s*run)/i); - return m ? m[1] : 'container'; - }, + extractQuery: createExtractQuery(/(docker|kubernetes|k8s|ecs|eks|aks|cloud\s*run|containerd)/i), + }, + { + patterns: [ + /\bistio\b/i, + /\bcilium\b/i, + /\blinkerd\b/i, + /\bkuma\b/i, + ], + category: 'gateway', + color: 'slate', + lucideFallback: 'shield', + extractQuery: createExtractQuery(/(istio|cilium|linkerd|kuma)/i), + }, + { + patterns: [ + /\bhelm\b/i, + /\bargo(?:\s*cd)?\b/i, + /\bflux(?:cd)?\b/i, + /\bcrossplane\b/i, + /\bkeda\b/i, + /\bknative\b/i, + /\bdapr\b/i, + /\bcert-?manager\b/i, + /\betcd\b/i, + /\bharbor\b/i, + /\bfalco\b/i, + ], + category: 'service', + color: 'blue', + lucideFallback: 'settings', + extractQuery: createExtractQuery(/(helm|argocd|argo|fluxcd|flux|crossplane|keda|knative|dapr|etcd|harbor|falco)/i), + }, + { + patterns: [ + /\bprometheus\b/i, + /\bjaeger\b/i, + /\bopentelemetry\b/i, + /\botel\b/i, + /\bgrafana\b/i, + /\bdatadog\b/i, + /\bnewrelic\b/i, + /\bsentry\b/i, + /\bsplunk\b/i, + /\bdynatrace\b/i, + ], + category: 'service', + color: 'slate', + lucideFallback: 'activity', + extractQuery: createExtractQuery(/(prometheus|jaeger|opentelemetry|grafana|datadog|newrelic|sentry|splunk|dynatrace)/i), + }, + { + patterns: [ + /\bcosmos\s*db\b/i, + /\bazure\s*cosmos\b/i, + /\bazure\s*sql\b/i, + /\bazure\s*postgres\b/i, + /\bazure\s*mysql\b/i, + ], + category: 'database', + color: 'violet', + lucideFallback: 'database', + extractQuery: createExtractQuery(/(azure-cosmos-db|cosmos|azure-database-postgresql|azure-database-mysql|azure-sql)/i), + }, + { + patterns: [ + /\bservice\s*bus\b/i, + /\bevent\s*hub(?:s)?\b/i, + /\bevent\s*grid\b/i, + ], + category: 'queue', + color: 'amber', + lucideFallback: 'layers', + extractQuery: createExtractQuery(/(azure-service-bus|event-hubs|event-grid)/i), + }, + { + patterns: [ + /\bazure\s*functions?\b/i, + /\bazure\s*container\s*apps?\b/i, + /\bcontainer\s*apps?\b/i, + /\bazure\s*openai\b/i, + /\bazure\s*monitor\b/i, + /\bazure\s*devops\b/i, + /\bapi\s*management\b/i, + /\bapim\b/i, + /\bcognitive\s*services?\b/i, + /\bfront\s*door\b/i, + ], + category: 'service', + color: 'blue', + lucideFallback: 'server', + extractQuery: createExtractQuery(/(azure-functions|container-apps|azure-openai|azure-monitor|azure-devops|api-management|cognitive-services|front-door)/i), + }, + { + patterns: [ + /\bkey\s*vault\b/i, + /\bazure\s*key\s*vault\b/i, + /\bvault\b/i, + ], + category: 'auth', + color: 'amber', + lucideFallback: 'key-round', + extractQuery: createExtractQuery(/(key-vault|vault)/i), + }, + { + patterns: [ + /\bazure\s*blob\b/i, + /\bazure\s*storage\b/i, + ], + category: 'storage', + color: 'yellow', + lucideFallback: 'folder', + extractQuery: createExtractQuery(/(azure-blob|blob-block|azure-storage)/i), }, ]; @@ -242,7 +531,7 @@ export function classifyNode(node: { id: string; label: string; shape?: string } return { category: 'database', color: 'violet', - iconQuery: m ? m[1] : node.label, + iconQuery: m ? m[1] : '', lucideFallback: 'database', }; } @@ -251,10 +540,11 @@ export function classifyNode(node: { id: string; label: string; shape?: string } for (const rule of RULES) { if (rule.patterns.some((p) => p.test(text))) { + const iconQuery = rule.extractQuery ? rule.extractQuery(text, node.id) : ''; return { category: rule.category, color: rule.color, - iconQuery: rule.extractQuery ? rule.extractQuery(text, node.id) : node.label, + iconQuery, lucideFallback: rule.lucideFallback, }; } @@ -263,6 +553,26 @@ export function classifyNode(node: { id: string; label: string; shape?: string } return DEFAULT_HINT; } +function normalizeIconQueryForGuard(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, ' '); +} + +export function isCommonEnglishIconTerm(value: string): boolean { + const normalized = normalizeIconQueryForGuard(value); + if (!normalized) { + return true; + } + return COMMON_ENGLISH_ICON_TERMS.has(normalized); +} + +export function isSpecificTechnologyIconQuery(value: string): boolean { + const normalized = normalizeIconQueryForGuard(value); + if (!normalized || isCommonEnglishIconTerm(normalized)) { + return false; + } + return SPECIFIC_TECHNOLOGY_PATTERNS.some((pattern) => pattern.test(normalized)); +} + export function classifyNodes( nodes: Array<{ id: string; label: string; shape?: string }> ): Map { diff --git a/src/services/composeDiagramForDisplay.ts b/src/services/composeDiagramForDisplay.ts index 9c2913d3..44a4d8a7 100644 --- a/src/services/composeDiagramForDisplay.ts +++ b/src/services/composeDiagramForDisplay.ts @@ -1,60 +1,70 @@ import type { DiagramType, FlowEdge, FlowNode } from '@/lib/types'; +import { autoFitSectionsToChildren } from '@/hooks/node-operations/sectionOperations'; import type { LayoutAlgorithm, LayoutOptions } from '@/services/elkLayout'; import { relayoutMindmapComponent, syncMindmapEdges } from '@/lib/mindmapLayout'; interface ComposeDiagramForDisplayOptions extends Pick { - direction?: LayoutOptions['direction']; - algorithm?: LayoutAlgorithm; - diagramType?: DiagramType | string; + direction?: LayoutOptions['direction']; + algorithm?: LayoutAlgorithm; + diagramType?: DiagramType | string; } function isMindmapDisplayTarget(nodes: FlowNode[], diagramType?: string): boolean { - if (diagramType === 'mindmap') { - return nodes.some((node) => node.type === 'mindmap'); - } + if (diagramType === 'mindmap') { + return nodes.some((node) => node.type === 'mindmap'); + } - const visibleNodes = nodes.filter((node) => !node.hidden); - return visibleNodes.length > 0 && visibleNodes.every((node) => node.type === 'mindmap'); + const visibleNodes = nodes.filter((node) => !node.hidden); + return visibleNodes.length > 0 && visibleNodes.every((node) => node.type === 'mindmap'); } -function relayoutAllMindmapComponents(nodes: FlowNode[], edges: FlowEdge[]): { nodes: FlowNode[]; edges: FlowEdge[] } { - const mindmapRootIds = nodes - .filter((node) => node.type === 'mindmap' && typeof node.data.mindmapParentId !== 'string') - .map((node) => node.id); +function relayoutAllMindmapComponents( + nodes: FlowNode[], + edges: FlowEdge[] +): { nodes: FlowNode[]; edges: FlowEdge[] } { + const mindmapRootIds = nodes + .filter((node) => node.type === 'mindmap' && typeof node.data.mindmapParentId !== 'string') + .map((node) => node.id); - const fallbackRootIds = mindmapRootIds.length > 0 - ? mindmapRootIds - : nodes.filter((node) => node.type === 'mindmap').map((node) => node.id); + const fallbackRootIds = + mindmapRootIds.length > 0 + ? mindmapRootIds + : nodes.filter((node) => node.type === 'mindmap').map((node) => node.id); - const layoutedNodes = fallbackRootIds.reduce( - (currentNodes, rootId) => relayoutMindmapComponent(currentNodes, edges, rootId), - nodes - ); + const layoutedNodes = fallbackRootIds.reduce( + (currentNodes, rootId) => relayoutMindmapComponent(currentNodes, edges, rootId), + nodes + ); - return { - nodes: layoutedNodes, - edges: syncMindmapEdges(layoutedNodes, edges), - }; + return { + nodes: layoutedNodes, + edges: syncMindmapEdges(layoutedNodes, edges), + }; } export async function composeDiagramForDisplay( - nodes: FlowNode[], - edges: FlowEdge[], - options: ComposeDiagramForDisplayOptions = {} + nodes: FlowNode[], + edges: FlowEdge[], + options: ComposeDiagramForDisplayOptions = {} ): Promise<{ nodes: FlowNode[]; edges: FlowEdge[] }> { - if (nodes.length === 0) { - return { nodes, edges }; - } - - if (isMindmapDisplayTarget(nodes, options.diagramType)) { - return relayoutAllMindmapComponents(nodes, edges); - } - - const { getElkLayout } = await import('@/services/elkLayout'); - return getElkLayout(nodes, edges, { - direction: options.direction ?? 'TB', - algorithm: options.algorithm ?? 'layered', - spacing: options.spacing ?? 'normal', - diagramType: options.diagramType, - }); + if (nodes.length === 0) { + return { nodes, edges }; + } + + if (isMindmapDisplayTarget(nodes, options.diagramType)) { + return relayoutAllMindmapComponents(nodes, edges); + } + + const { getElkLayout } = await import('@/services/elkLayout'); + const layouted = await getElkLayout(nodes, edges, { + direction: options.direction ?? 'TB', + algorithm: options.algorithm, + spacing: options.spacing ?? 'normal', + diagramType: options.diagramType, + }); + + return { + nodes: autoFitSectionsToChildren(layouted.nodes), + edges: layouted.edges, + }; } diff --git a/src/services/elk-layout/options.ts b/src/services/elk-layout/options.ts index 189d2b96..67cffd7f 100644 --- a/src/services/elk-layout/options.ts +++ b/src/services/elk-layout/options.ts @@ -1,204 +1,224 @@ -import type { LayoutAlgorithm, LayoutDirection, LayoutOptions, ResolvedLayoutConfiguration } from './types'; +import { + SECTION_CONTENT_PADDING_TOP, + SECTION_PADDING_BOTTOM, + SECTION_PADDING_X, +} from '@/hooks/node-operations/sectionBounds'; +import type { + LayoutAlgorithm, + LayoutDirection, + LayoutOptions, + ResolvedLayoutConfiguration, +} from './types'; // Map user-friendly direction codes to ELK direction values const DIRECTION_MAP: Record = { - TB: 'DOWN', - LR: 'RIGHT', - RL: 'LEFT', - BT: 'UP', + TB: 'DOWN', + LR: 'RIGHT', + RL: 'LEFT', + BT: 'UP', }; -function getSpacingDimensions(spacing: LayoutOptions['spacing'] = 'normal', isHorizontal: boolean): { - nodeNode: string; - nodeLayer: string; - component: string; +function getSpacingDimensions( + spacing: LayoutOptions['spacing'] = 'normal', + isHorizontal: boolean +): { + nodeNode: string; + nodeLayer: string; + component: string; } { - let nodeNode = 80; - let nodeLayer = 150; - - switch (spacing) { - case 'compact': - nodeNode = 40; - nodeLayer = 80; - break; - case 'loose': - nodeNode = 150; - nodeLayer = 250; - break; - case 'normal': - default: - nodeNode = 80; - nodeLayer = 150; - } - - if (isHorizontal) { - nodeLayer *= 1.2; - } - - return { - nodeNode: String(nodeNode), - nodeLayer: String(nodeLayer), - component: String(nodeLayer), - }; + let nodeNode = 80; + let nodeLayer = 150; + + switch (spacing) { + case 'compact': + nodeNode = 60; + nodeLayer = 120; + break; + case 'loose': + nodeNode = 150; + nodeLayer = 250; + break; + case 'normal': + default: + nodeNode = 80; + nodeLayer = 150; + } + + if (isHorizontal) { + nodeLayer *= 1.2; + } + + return { + nodeNode: String(nodeNode), + nodeLayer: String(nodeLayer), + component: String(nodeLayer), + }; } function isArchitectureLikeDiagram(diagramType: string | undefined): boolean { - return diagramType === 'architecture' || diagramType === 'infrastructure'; + return diagramType === 'architecture' || diagramType === 'infrastructure'; } function applyDiagramTypeSpacingHeuristics( - dims: { nodeNode: string; nodeLayer: string; component: string }, - options: LayoutOptions + dims: { nodeNode: string; nodeLayer: string; component: string }, + options: LayoutOptions ): { nodeNode: string; nodeLayer: string; component: string } { - if (!isArchitectureLikeDiagram(options.diagramType)) { - return dims; - } - - const nodeNode = Math.round(Number(dims.nodeNode) * 1.35); - const nodeLayer = Math.round(Number(dims.nodeLayer) * 1.3); - const component = Math.round(Number(dims.component) * 1.25); - - return { - nodeNode: String(nodeNode), - nodeLayer: String(nodeLayer), - component: String(component), - }; + if (!isArchitectureLikeDiagram(options.diagramType)) { + return dims; + } + + const nodeNode = Math.round(Number(dims.nodeNode) * 1.35); + const nodeLayer = Math.round(Number(dims.nodeLayer) * 1.3); + const component = Math.round(Number(dims.component) * 1.25); + + return { + nodeNode: String(nodeNode), + nodeLayer: String(nodeLayer), + component: String(component), + }; } function getAlgorithmOptions( - algorithm: LayoutAlgorithm, - layerSpacing: number, - options: LayoutOptions + algorithm: LayoutAlgorithm, + layerSpacing: number, + options: LayoutOptions ): Record { - const algorithmOptions: Record = {}; - - switch (algorithm) { - case 'mrtree': - algorithmOptions['elk.algorithm'] = 'org.eclipse.elk.mrtree'; - break; - case 'force': - algorithmOptions['elk.algorithm'] = 'org.eclipse.elk.force'; - break; - case 'stress': - algorithmOptions['elk.algorithm'] = 'org.eclipse.elk.stress'; - break; - case 'radial': - algorithmOptions['elk.algorithm'] = 'org.eclipse.elk.radial'; - break; - default: - algorithmOptions['elk.algorithm'] = `org.eclipse.elk.${algorithm}`; - } - if (algorithm === 'layered') { - const edgeNodeSpacing = String(Math.round(layerSpacing * 0.33)); - const architectureLike = isArchitectureLikeDiagram(options.diagramType); - Object.assign(algorithmOptions, { - 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP', - 'elk.layered.crossingMinimization.thoroughness': '64', - 'elk.layered.nodePlacement.strategy': architectureLike ? 'BRANDES_KOEPF' : 'NETWORK_SIMPLEX', - 'elk.layered.nodePlacement.favorStraightEdges': 'true', - 'elk.layered.mergeEdges': 'true', - 'elk.layered.unnecessaryBendpoints': 'true', - 'elk.edgeRouting': 'ORTHOGONAL', - 'elk.portConstraints': 'FIXED_SIDE', - 'elk.layered.spacing.edgeNodeBetweenLayers': edgeNodeSpacing, - 'elk.layered.spacing.edgeEdgeBetweenLayers': '30', - 'elk.spacing.edgeEdge': '12', - 'elk.separateConnectedComponents': 'true', - 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', - 'elk.layered.compaction.postCompaction.strategy': 'EDGE_LENGTH', - 'elk.layered.highDegreeNode.treatment': 'true', - 'elk.layered.highDegreeNode.threshold': '4', - 'elk.layered.highDegreeNode.treeHeight': '2', - ...(architectureLike - ? { - 'elk.spacing.edgeNode': '24', - 'elk.spacing.edgeEdge': '18', - 'elk.layered.spacing.edgeEdgeBetweenLayers': '42', - 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED', - 'elk.layered.priority.direction': '1', - } - : {}), - }); - } else if (algorithm === 'mrtree') { - Object.assign(algorithmOptions, { - 'elk.separateConnectedComponents': 'true', - 'elk.portConstraints': 'FIXED_SIDE', // Lock to centers to force centralized trunk grouping - 'elk.spacing.edgeEdge': '12', - }); - - } else if (algorithm === 'force') { - Object.assign(algorithmOptions, { - 'elk.force.iterations': '500', - 'elk.force.repulsivePower': String(layerSpacing / 20), - 'elk.portConstraints': 'FREE', // Force layout needs free ports - }); - } else if (algorithm === 'stress') { - algorithmOptions['elk.stress.desiredEdgeLength'] = String(layerSpacing); - algorithmOptions['elk.portConstraints'] = 'FREE'; - } else if (algorithm === 'radial') { - algorithmOptions['elk.radial.radius'] = String(layerSpacing); - algorithmOptions['elk.portConstraints'] = 'FREE'; - } - - return algorithmOptions; + const algorithmOptions: Record = {}; + + switch (algorithm) { + case 'mrtree': + algorithmOptions['elk.algorithm'] = 'org.eclipse.elk.mrtree'; + break; + case 'force': + algorithmOptions['elk.algorithm'] = 'org.eclipse.elk.force'; + break; + case 'stress': + algorithmOptions['elk.algorithm'] = 'org.eclipse.elk.stress'; + break; + case 'radial': + algorithmOptions['elk.algorithm'] = 'org.eclipse.elk.radial'; + break; + default: + algorithmOptions['elk.algorithm'] = `org.eclipse.elk.${algorithm}`; + } + if (algorithm === 'layered') { + const edgeNodeSpacing = String(Math.round(layerSpacing * 0.33)); + const architectureLike = isArchitectureLikeDiagram(options.diagramType); + Object.assign(algorithmOptions, { + 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP', + 'elk.layered.crossingMinimization.thoroughness': '64', + 'elk.layered.nodePlacement.strategy': architectureLike ? 'BRANDES_KOEPF' : 'NETWORK_SIMPLEX', + 'elk.layered.nodePlacement.favorStraightEdges': 'true', + 'elk.layered.mergeEdges': 'true', + 'elk.layered.unnecessaryBendpoints': 'true', + 'elk.edgeRouting': 'ORTHOGONAL', + 'elk.portConstraints': architectureLike ? 'FIXED_SIDE' : 'FIXED_ORDER', + 'elk.layered.spacing.edgeNodeBetweenLayers': edgeNodeSpacing, + 'elk.layered.spacing.edgeEdgeBetweenLayers': '30', + 'elk.spacing.edgeEdge': '12', + 'elk.separateConnectedComponents': 'true', + 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', + 'elk.layered.compaction.postCompaction.strategy': 'EDGE_LENGTH', + 'elk.layered.highDegreeNode.treatment': 'true', + 'elk.layered.highDegreeNode.threshold': '4', + 'elk.layered.highDegreeNode.treeHeight': '2', + ...(architectureLike + ? { + 'elk.spacing.edgeNode': '24', + 'elk.spacing.edgeEdge': '18', + 'elk.layered.spacing.edgeEdgeBetweenLayers': '42', + 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED', + 'elk.layered.priority.direction': '1', + } + : {}), + }); + } else if (algorithm === 'mrtree') { + Object.assign(algorithmOptions, { + 'elk.separateConnectedComponents': 'true', + 'elk.portConstraints': 'FIXED_SIDE', // Lock to centers to force centralized trunk grouping + 'elk.spacing.edgeEdge': '12', + }); + } else if (algorithm === 'force') { + Object.assign(algorithmOptions, { + 'elk.force.iterations': '500', + 'elk.force.repulsivePower': String(layerSpacing / 20), + 'elk.portConstraints': 'FREE', // Force layout needs free ports + }); + } else if (algorithm === 'stress') { + algorithmOptions['elk.stress.desiredEdgeLength'] = String(layerSpacing); + algorithmOptions['elk.portConstraints'] = 'FREE'; + } else if (algorithm === 'radial') { + algorithmOptions['elk.radial.radius'] = String(layerSpacing); + algorithmOptions['elk.portConstraints'] = 'FREE'; + } + + return algorithmOptions; } export function getDeterministicSeedOptions(algorithm: LayoutAlgorithm): Record { - if (algorithm === 'force' || algorithm === 'stress' || algorithm === 'radial') { - return { 'elk.randomSeed': '1337' }; - } - return {}; + if (algorithm === 'force' || algorithm === 'stress' || algorithm === 'radial') { + return { 'elk.randomSeed': '1337' }; + } + return {}; } -export function resolveLayoutPresetOptions(options: LayoutOptions): Pick { - if (!options.preset) { - return { - algorithm: options.algorithm ?? 'layered', - direction: options.direction ?? 'TB', - spacing: options.spacing ?? 'normal', - }; - } +export function resolveLayoutPresetOptions( + options: LayoutOptions +): Pick { + if (!options.preset) { + return { + algorithm: options.algorithm ?? 'layered', + direction: options.direction ?? 'TB', + spacing: options.spacing ?? 'normal', + }; + } - if (options.preset === 'hierarchical') { - return { algorithm: 'layered', direction: 'TB', spacing: 'normal' }; - } + if (options.preset === 'hierarchical') { + return { algorithm: 'layered', direction: 'TB', spacing: 'normal' }; + } - if (options.preset === 'orthogonal-compact') { - return { algorithm: 'layered', direction: 'LR', spacing: 'compact' }; - } + if (options.preset === 'orthogonal-compact') { + return { algorithm: 'layered', direction: 'LR', spacing: 'compact' }; + } - return { algorithm: 'layered', direction: 'LR', spacing: 'loose' }; + return { algorithm: 'layered', direction: 'LR', spacing: 'loose' }; } -export function buildResolvedLayoutConfiguration(options: LayoutOptions): ResolvedLayoutConfiguration { - const { - direction = 'TB', - algorithm = 'layered', - spacing = 'normal', - } = resolveLayoutPresetOptions(options); - const elkDirection = DIRECTION_MAP[direction] || 'DOWN'; - const isHorizontal = direction === 'LR' || direction === 'RL'; - - const dims = applyDiagramTypeSpacingHeuristics(getSpacingDimensions(spacing, isHorizontal), options); - const algoOptions = getAlgorithmOptions(algorithm, parseFloat(dims.nodeLayer), options); - const deterministicSeedOptions = getDeterministicSeedOptions(algorithm); - const layoutOptions = { - 'elk.direction': elkDirection, - 'elk.spacing.nodeNode': dims.nodeNode, - 'elk.layered.spacing.nodeNodeBetweenLayers': dims.nodeLayer, - 'elk.spacing.componentComponent': dims.component, - 'elk.padding': '[top=50,left=50,bottom=50,right=50]', - ...algoOptions, - ...deterministicSeedOptions, - }; - - return { - algorithm, - direction, - spacing, - elkDirection, - isHorizontal, - dims, - layoutOptions, - }; +export function buildResolvedLayoutConfiguration( + options: LayoutOptions +): ResolvedLayoutConfiguration { + const { + direction = 'TB', + algorithm = 'layered', + spacing = 'normal', + } = resolveLayoutPresetOptions(options); + const elkDirection = DIRECTION_MAP[direction] || 'DOWN'; + const isHorizontal = direction === 'LR' || direction === 'RL'; + + const dims = applyDiagramTypeSpacingHeuristics( + getSpacingDimensions(spacing, isHorizontal), + options + ); + const algoOptions = getAlgorithmOptions(algorithm, parseFloat(dims.nodeLayer), options); + const deterministicSeedOptions = getDeterministicSeedOptions(algorithm); + const layoutOptions = { + 'elk.direction': elkDirection, + 'elk.spacing.nodeNode': dims.nodeNode, + 'elk.layered.spacing.nodeNodeBetweenLayers': dims.nodeLayer, + 'elk.spacing.componentComponent': dims.component, + 'elk.padding': `[top=${SECTION_CONTENT_PADDING_TOP},left=${SECTION_PADDING_X},bottom=${SECTION_PADDING_BOTTOM},right=${SECTION_PADDING_X}]`, + 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', + ...algoOptions, + ...deterministicSeedOptions, + }; + + return { + algorithm, + direction, + spacing, + elkDirection, + isHorizontal, + dims, + layoutOptions, + }; } diff --git a/src/services/elkLayout.test.ts b/src/services/elkLayout.test.ts index f57ecfbb..395e74d4 100644 --- a/src/services/elkLayout.test.ts +++ b/src/services/elkLayout.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from 'vitest'; import type { FlowEdge, FlowNode } from '@/lib/types'; import { + applyElkLayoutToNodes, buildResolvedLayoutConfiguration, getDeterministicSeedOptions, normalizeElkEdgeBoundaryFanout, normalizeLayoutInputsForDeterminism, + normalizeParentedElkPositions, + resolveAutomaticLayoutAlgorithm, resolveLayoutedEdgeHandles, resolveLayoutPresetOptions, shouldUseLightweightLayoutPostProcessing, @@ -24,7 +27,9 @@ function createEdge(id: string, source: string, target: string): FlowEdge { return { id, source, target } as FlowEdge; } -function createPositionMap(entries: Array<[string, { x: number; y: number; width?: number; height?: number }]>) { +function createPositionMap( + entries: Array<[string, { x: number; y: number; width?: number; height?: number }]> +) { return new Map(entries); } @@ -59,16 +64,8 @@ describe('normalizeLayoutInputsForDeterminism', () => { }); it('uses deterministic component tie-break ordering for top-level nodes and edges', () => { - const nodes = [ - createNode('z1'), - createNode('b1'), - createNode('a1'), - createNode('c1'), - ]; - const edges = [ - createEdge('edge-bc', 'b1', 'c1'), - createEdge('edge-za', 'z1', 'a1'), - ]; + const nodes = [createNode('z1'), createNode('b1'), createNode('a1'), createNode('c1')]; + const edges = [createEdge('edge-bc', 'b1', 'c1'), createEdge('edge-za', 'z1', 'a1')]; const normalized = normalizeLayoutInputsForDeterminism(nodes, edges); @@ -93,8 +90,14 @@ describe('normalizeLayoutInputsForDeterminism', () => { const normalized = normalizeLayoutInputsForDeterminism(nodes, edges); expect(normalized.topLevelNodes.map((node) => node.id)).toEqual(['group-a', 'group-b']); - expect((normalized.childrenByParent.get('group-a') || []).map((node) => node.id)).toEqual(['a-child-1', 'a-child-2']); - expect((normalized.childrenByParent.get('group-b') || []).map((node) => node.id)).toEqual(['b-child-1', 'b-child-2']); + expect((normalized.childrenByParent.get('group-a') || []).map((node) => node.id)).toEqual([ + 'a-child-1', + 'a-child-2', + ]); + expect((normalized.childrenByParent.get('group-b') || []).map((node) => node.id)).toEqual([ + 'b-child-1', + 'b-child-2', + ]); }); }); @@ -160,6 +163,7 @@ describe('buildResolvedLayoutConfiguration', () => { expect(compact.layoutOptions['elk.layered.nodePlacement.favorStraightEdges']).toBe('true'); expect(compact.layoutOptions['elk.layered.mergeEdges']).toBe('true'); expect(compact.layoutOptions['elk.layered.unnecessaryBendpoints']).toBe('true'); + expect(Number(compact.dims.nodeNode)).toBe(60); }); it('applies more spacious layered heuristics for architecture diagrams', () => { @@ -182,7 +186,83 @@ describe('buildResolvedLayoutConfiguration', () => { expect(architecture.layoutOptions['elk.spacing.edgeNode']).toBe('24'); expect(architecture.layoutOptions['elk.spacing.edgeEdge']).toBe('18'); expect(architecture.layoutOptions['elk.layered.spacing.edgeEdgeBetweenLayers']).toBe('42'); - expect(architecture.layoutOptions['elk.layered.nodePlacement.bk.fixedAlignment']).toBe('BALANCED'); + expect(architecture.layoutOptions['elk.layered.nodePlacement.bk.fixedAlignment']).toBe( + 'BALANCED' + ); + }); + + it('enables compound hierarchy handling and shared root padding', () => { + const config = buildResolvedLayoutConfiguration({ + algorithm: 'layered', + direction: 'TB', + spacing: 'normal', + }); + + expect(config.layoutOptions['elk.hierarchyHandling']).toBe('INCLUDE_CHILDREN'); + expect(config.layoutOptions['elk.padding']).toBe('[top=16,left=32,bottom=32,right=32]'); + }); +}); + +describe('normalizeParentedElkPositions', () => { + it('converts child positions from absolute ELK coordinates to parent-relative flow coordinates', () => { + const nodes = [ + { + id: 'section-1', + type: 'section', + position: { x: 0, y: 0 }, + data: { label: 'Section' }, + style: { width: 500, height: 400 }, + } as FlowNode, + createNode('child-1', 'section-1'), + createNode('child-2'), + ]; + + const absolutePositionMap = createPositionMap([ + ['section-1', { x: 120, y: 80, width: 560, height: 420 }], + ['child-1', { x: 200, y: 150, width: 120, height: 60 }], + ['child-2', { x: 700, y: 500, width: 120, height: 60 }], + ]); + + const normalized = normalizeParentedElkPositions(nodes, absolutePositionMap); + + expect(normalized.get('section-1')).toEqual({ x: 120, y: 80, width: 560, height: 420 }); + expect(normalized.get('child-1')).toEqual({ x: 80, y: 70, width: 120, height: 60 }); + expect(normalized.get('child-2')).toEqual({ x: 700, y: 500, width: 120, height: 60 }); + }); +}); + +describe('applyElkLayoutToNodes', () => { + it('updates section size while preserving parent-relative child coordinates', () => { + const section = { + id: 'section-1', + type: 'section', + position: { x: 0, y: 0 }, + data: { label: 'Section' }, + style: { width: 500, height: 400 }, + } as FlowNode; + const child = { + ...createNode('child-1', 'section-1'), + position: { x: 0, y: 0 }, + style: { width: 120, height: 60 }, + } as FlowNode; + + const laidOutNodes = applyElkLayoutToNodes( + [section, child], + createPositionMap([ + ['section-1', { x: 120, y: 80, width: 560, height: 420 }], + ['child-1', { x: 200, y: 150, width: 120, height: 60 }], + ]) + ); + + expect(laidOutNodes.find((node) => node.id === 'section-1')?.position).toEqual({ + x: 120, + y: 80, + }); + expect(laidOutNodes.find((node) => node.id === 'section-1')?.style).toMatchObject({ + width: 560, + height: 420, + }); + expect(laidOutNodes.find((node) => node.id === 'child-1')?.position).toEqual({ x: 80, y: 70 }); }); }); @@ -228,9 +308,27 @@ describe('normalizeElkEdgeBoundaryFanout', () => { { id: 'e3', source: 'source', target: 'c', sourceHandle: 'right' }, ] as FlowEdge[]; const edgePointsMap = new Map([ - ['e1', [{ x: 200, y: 60 }, { x: 260, y: 60 }]], - ['e2', [{ x: 200, y: 60 }, { x: 260, y: 60 }]], - ['e3', [{ x: 200, y: 60 }, { x: 260, y: 60 }]], + [ + 'e1', + [ + { x: 200, y: 60 }, + { x: 260, y: 60 }, + ], + ], + [ + 'e2', + [ + { x: 200, y: 60 }, + { x: 260, y: 60 }, + ], + ], + [ + 'e3', + [ + { x: 200, y: 60 }, + { x: 260, y: 60 }, + ], + ], ]); const positionMap = createPositionMap([ ['source', { x: 0, y: 0, width: 200, height: 120 }], @@ -294,9 +392,27 @@ describe('normalizeElkEdgeBoundaryFanout', () => { { id: 'e3', source: 'source', target: 'c', sourceHandle: 'bottom' }, ] as FlowEdge[]; const edgePointsMap = new Map([ - ['e1', [{ x: 100, y: 120 }, { x: 100, y: 180 }]], - ['e2', [{ x: 100, y: 120 }, { x: 100, y: 180 }]], - ['e3', [{ x: 100, y: 120 }, { x: 100, y: 180 }]], + [ + 'e1', + [ + { x: 100, y: 120 }, + { x: 100, y: 180 }, + ], + ], + [ + 'e2', + [ + { x: 100, y: 120 }, + { x: 100, y: 180 }, + ], + ], + [ + 'e3', + [ + { x: 100, y: 120 }, + { x: 100, y: 180 }, + ], + ], ]); const positionMap = createPositionMap([ ['source', { x: 0, y: 0, width: 200, height: 120 }], @@ -329,14 +445,18 @@ describe('normalizeElkEdgeBoundaryFanout', () => { height: 60, data: { label: 'Source' }, } as FlowNode, - ...Array.from({ length: 5 }, (_, index) => ({ - id: `target-${index}`, - type: 'process', - position: { x: 280, y: index * 40 }, - width: 120, - height: 80, - data: { label: `Target ${index}` }, - } as FlowNode)), + ...Array.from( + { length: 5 }, + (_, index) => + ({ + id: `target-${index}`, + type: 'process', + position: { x: 280, y: index * 40 }, + width: 120, + height: 80, + data: { label: `Target ${index}` }, + }) as FlowNode + ), ]; const edges = Array.from({ length: 5 }, (_, index) => ({ id: `e${index}`, @@ -345,14 +465,26 @@ describe('normalizeElkEdgeBoundaryFanout', () => { sourceHandle: 'right', })) as FlowEdge[]; const edgePointsMap = new Map( - edges.map((edge) => [edge.id, [{ x: 180, y: 30 }, { x: 240, y: 30 }]]) + edges.map((edge) => [ + edge.id, + [ + { x: 180, y: 30 }, + { x: 240, y: 30 }, + ], + ]) ); - const positionMapEntries: Array<[string, { x: number; y: number; width?: number; height?: number }]> = [ + const positionMapEntries: Array< + [string, { x: number; y: number; width?: number; height?: number }] + > = [ ['source', { x: 0, y: 0, width: 180, height: 60 }], - ...Array.from({ length: 5 }, (_, index) => ([ - `target-${index}`, - { x: 280, y: index * 40, width: 120, height: 80 }, - ] as [string, { x: number; y: number; width?: number; height?: number }])), + ...Array.from( + { length: 5 }, + (_, index) => + [`target-${index}`, { x: 280, y: index * 40, width: 120, height: 80 }] as [ + string, + { x: number; y: number; width?: number; height?: number }, + ] + ), ]; const positionMap = createPositionMap(positionMapEntries); @@ -451,6 +583,39 @@ describe('resolveLayoutedEdgeHandles', () => { }); }); +describe('resolveAutomaticLayoutAlgorithm', () => { + it('prefers tree layout for high-branching acyclic graphs', () => { + const nodes = ['root', 'a', 'b', 'c', 'd', 'e'].map((id) => createNode(id)); + const edges = ['a', 'b', 'c', 'd', 'e'].map((id, index) => createEdge(`e${index}`, 'root', id)); + + expect(resolveAutomaticLayoutAlgorithm(nodes, edges, { diagramType: 'flowchart' })).toBe( + 'mrtree' + ); + }); + + it('switches cyclic graphs away from layered layout automatically', () => { + const nodes = ['a', 'b', 'c'].map((id) => createNode(id)); + const edges = [ + createEdge('e1', 'a', 'b'), + createEdge('e2', 'b', 'c'), + createEdge('e3', 'c', 'a'), + ]; + + expect(resolveAutomaticLayoutAlgorithm(nodes, edges, { diagramType: 'flowchart' })).toBe( + 'force' + ); + }); + + it('keeps architecture imports on layered layout', () => { + const nodes = [createNode('edge'), createNode('api')]; + const edges = [createEdge('e1', 'edge', 'api')]; + + expect(resolveAutomaticLayoutAlgorithm(nodes, edges, { diagramType: 'architecture' })).toBe( + 'layered' + ); + }); +}); + describe('shouldUseLightweightLayoutPostProcessing', () => { it('keeps smaller standard diagrams on the full post-processing path', () => { expect(shouldUseLightweightLayoutPostProcessing(20, 24, 'flowchart')).toBe(false); diff --git a/src/services/elkLayout.ts b/src/services/elkLayout.ts index eed6b115..4c2af1df 100644 --- a/src/services/elkLayout.ts +++ b/src/services/elkLayout.ts @@ -1,17 +1,24 @@ import type { ElkExtendedEdge, ElkNode } from 'elkjs/lib/elk.bundled.js'; import { NODE_HEIGHT, NODE_WIDTH } from '@/constants'; import { getIconAssetNodeMinSize, resolveNodeSize } from '@/components/nodeHelpers'; +import { + SECTION_CONTENT_PADDING_TOP, + SECTION_PADDING_BOTTOM, + SECTION_PADDING_X, +} from '@/hooks/node-operations/sectionBounds'; import { createLogger } from '@/lib/logger'; +import { getNodeParentId } from '@/lib/nodeParent'; import type { FlowEdge, FlowNode } from '@/lib/types'; -import { assignSmartHandlesWithOptions } from './smartEdgeRouting'; +import { assignSmartHandlesWithOptions, handleSideFromVector } from './smartEdgeRouting'; import { normalizeLayoutInputsForDeterminism } from './elk-layout/determinism'; -import { normalizeElkEdgeBoundaryFanout } from './elk-layout/boundaryFanout'; +import { normalizeElkEdgeBoundaryFanout, type NodeBounds } from './elk-layout/boundaryFanout'; import { buildResolvedLayoutConfiguration, getDeterministicSeedOptions, resolveLayoutPresetOptions, } from './elk-layout/options'; import type { FlowNodeWithMeasuredDimensions, LayoutOptions } from './elk-layout/types'; +import { getNodeHandleIdForSide } from '@/lib/nodeHandles'; interface ElkLayoutEngine { layout: (graph: ElkNode) => Promise; @@ -25,14 +32,38 @@ let elkInstancePromise: Promise | null = null; const LARGE_DIAGRAM_NODE_THRESHOLD = 48; const LARGE_DIAGRAM_EDGE_THRESHOLD = 72; const logger = createLogger({ scope: 'elkLayout' }); -const SEMANTIC_LAYER_ORDER = ['edge', 'frontend', 'api', 'services', 'data', 'external'] as const; +const FALLBACK_LAYER_ORDER = ['edge', 'frontend', 'api', 'services', 'data', 'external'] as const; + +const FALLBACK_LAYER_KEYWORDS: ReadonlyArray<{ + layer: (typeof FALLBACK_LAYER_ORDER)[number]; + keywords: string[]; +}> = [ + { layer: 'edge', keywords: ['edge', 'gateway', 'cdn'] }, + { layer: 'frontend', keywords: ['frontend', 'browser', 'web', 'mobile'] }, + { layer: 'api', keywords: ['api'] }, + { layer: 'services', keywords: ['service', 'compute', 'worker', 'backend'] }, + { layer: 'data', keywords: ['data', 'database', 'cache', 'storage'] }, + { layer: 'external', keywords: ['external', 'third-party', 'third party'] }, +]; + +const ELK_SECTION_PADDING = `[top=${SECTION_CONTENT_PADDING_TOP},left=${SECTION_PADDING_X},bottom=${SECTION_PADDING_BOTTOM},right=${SECTION_PADDING_X}]`; +const ELK_COMPOUND_LAYOUT_OPTIONS = { + 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', + 'elk.algorithm': 'layered', +} as const; const layoutCache = new Map(); const LAYOUT_CACHE_MAX = 20; function getLayoutCacheKey(nodes: FlowNode[], edges: FlowEdge[], options: LayoutOptions): string { - const nodeStr = nodes.map((n) => n.id).sort().join(','); - const edgeStr = edges.map((e) => `${e.source}>${e.target}`).sort().join(','); + const nodeStr = nodes + .map((n) => n.id) + .sort() + .join(','); + const edgeStr = edges + .map((e) => `${e.source}>${e.target}`) + .sort() + .join(','); return `${nodeStr}|${edgeStr}|${options.direction ?? 'TB'}:${options.algorithm ?? 'layered'}:${options.spacing ?? 'normal'}:${options.diagramType ?? ''}`; } @@ -91,94 +122,72 @@ function buildElkNode(node: FlowNode, childrenByParent: Map) } } + const hasChildren = children.length > 0; return { id: node.id, - width: children.length === 0 ? width : undefined, - height: children.length === 0 ? height : undefined, + width: hasChildren ? undefined : width, + height: hasChildren ? undefined : height, children: children.map((child) => buildElkNode(child, childrenByParent)), layoutOptions: { - 'elk.padding': '[top=40,left=20,bottom=20,right=20]', + 'elk.padding': ELK_SECTION_PADDING, + ...(hasChildren ? ELK_COMPOUND_LAYOUT_OPTIONS : {}), }, }; } -function inferSemanticLayerRank(node: FlowNode): number | null { +const SECTION_TYPES = new Set(['section', 'group', 'browser', 'mobile']); + +function buildDynamicLayerOrder(nodes: FlowNode[]): readonly string[] { + const sections = nodes.filter((n) => SECTION_TYPES.has(String(n.type))); + if (sections.length === 0) return FALLBACK_LAYER_ORDER; + return sections.map((n) => String(n.data?.label ?? n.id).toLowerCase()); +} + +function inferSemanticLayerRank(node: FlowNode, dynamicOrder: readonly string[]): number | null { + if (typeof node.data?.archLayerRank === 'number' && Number.isFinite(node.data.archLayerRank)) { + return node.data.archLayerRank; + } + const label = String(node.data?.label ?? '').toLowerCase(); const subLabel = String(node.data?.subLabel ?? '').toLowerCase(); const type = String(node.type ?? '').toLowerCase(); const haystack = `${label} ${subLabel} ${type}`; - if (haystack.includes('edge') || haystack.includes('gateway') || haystack.includes('cdn')) { - return SEMANTIC_LAYER_ORDER.indexOf('edge'); - } - if ( - haystack.includes('frontend') || - haystack.includes('browser') || - haystack.includes('web') || - haystack.includes('mobile') - ) { - return SEMANTIC_LAYER_ORDER.indexOf('frontend'); - } - if (haystack.includes('api')) { - return SEMANTIC_LAYER_ORDER.indexOf('api'); - } - if ( - haystack.includes('service') || - haystack.includes('compute') || - haystack.includes('worker') || - haystack.includes('backend') - ) { - return SEMANTIC_LAYER_ORDER.indexOf('services'); - } - if ( - haystack.includes('data') || - haystack.includes('database') || - haystack.includes('cache') || - haystack.includes('storage') - ) { - return SEMANTIC_LAYER_ORDER.indexOf('data'); - } - if ( - haystack.includes('external') || - haystack.includes('third-party') || - haystack.includes('third party') - ) { - return SEMANTIC_LAYER_ORDER.indexOf('external'); - } + const dynamicRank = dynamicOrder.findIndex((layer) => haystack.includes(layer)); + if (dynamicRank !== -1) return dynamicRank; - return null; + const fallbackMatch = FALLBACK_LAYER_KEYWORDS.find(({ keywords }) => + keywords.some((kw) => haystack.includes(kw)) + ); + return fallbackMatch ? FALLBACK_LAYER_ORDER.indexOf(fallbackMatch.layer) : null; } function isArchitectureLikeNode(node: FlowNode): boolean { - if (node.type === 'architecture') { - return true; - } - - return inferSemanticLayerRank(node) !== null || ['browser', 'mobile', 'section', 'group'].includes(String(node.type)); + if (node.type === 'architecture') return true; + return ( + inferSemanticLayerRank(node, FALLBACK_LAYER_ORDER) !== null || + SECTION_TYPES.has(String(node.type)) + ); } function resolveEffectiveDiagramType(nodes: FlowNode[], diagramType?: string): string | undefined { - if (diagramType) { - return diagramType; - } - + if (diagramType) return diagramType; return nodes.some(isArchitectureLikeNode) ? 'architecture' : undefined; } -function sortTopLevelNodesForArchitecture(topLevelNodes: FlowNode[]): FlowNode[] { +function sortTopLevelNodesForArchitecture( + topLevelNodes: FlowNode[], + dynamicOrder: readonly string[] +): FlowNode[] { + const rankCache = new Map( + topLevelNodes.map((n) => [n.id, inferSemanticLayerRank(n, dynamicOrder)]) + ); return [...topLevelNodes].sort((left, right) => { - const leftRank = inferSemanticLayerRank(left); - const rightRank = inferSemanticLayerRank(right); - - if (leftRank === null && rightRank === null) { - return 0; - } - if (leftRank === null) { - return 1; - } - if (rightRank === null) { - return -1; - } + const leftRank = rankCache.get(left.id) ?? null; + const rightRank = rankCache.get(right.id) ?? null; + if (leftRank === null && rightRank === null) return 0; + if (leftRank === null) return 1; + if (rightRank === null) return -1; return leftRank - rightRank; }); } @@ -204,6 +213,163 @@ function buildPositionMap( return positionMap; } +export function normalizeParentedElkPositions( + nodes: FlowNode[], + absolutePositionMap: Map +): Map { + const normalizedPositionMap = new Map(absolutePositionMap); + + for (const node of nodes) { + const parentId = getNodeParentId(node); + if (!parentId) { + continue; + } + + const childPosition = absolutePositionMap.get(node.id); + const parentPosition = absolutePositionMap.get(parentId); + if (!childPosition || !parentPosition) { + continue; + } + + normalizedPositionMap.set(node.id, { + ...childPosition, + x: childPosition.x - parentPosition.x, + y: childPosition.y - parentPosition.y, + }); + } + + return normalizedPositionMap; +} + +export function applyElkLayoutToNodes( + nodes: FlowNode[], + absolutePositionMap: Map +): FlowNode[] { + const normalizedPositionMap = normalizeParentedElkPositions(nodes, absolutePositionMap); + + return nodes.map((node) => { + const normalizedPosition = normalizedPositionMap.get(node.id); + if (!normalizedPosition) { + return node; + } + + const style = { ...node.style }; + if (node.type === 'group' || node.type === 'section' || node.type === 'container') { + if (normalizedPosition.width) { + style.width = normalizedPosition.width; + } + if (normalizedPosition.height) { + style.height = normalizedPosition.height; + } + } + + return { + ...node, + position: { x: normalizedPosition.x, y: normalizedPosition.y }, + style, + }; + }); +} + +function getNodeBoundsFromPositionMap( + node: FlowNode, + positionMap: Map +): NodeBounds { + const pos = positionMap.get(node.id); + const x = pos?.x ?? node.position.x; + const y = pos?.y ?? node.position.y; + const label = node.data?.label ?? ''; + const width = + pos?.width ?? + (node as FlowNodeWithMeasuredDimensions).measured?.width ?? + Math.max(NODE_WIDTH, label.length * 8 + 40); + const height = + pos?.height ?? + (node as FlowNodeWithMeasuredDimensions).measured?.height ?? + Math.max(NODE_HEIGHT, Math.ceil(label.length / 40) * 20 + 60); + return { + left: x, + right: x + width, + top: y, + bottom: y + height, + centerX: x + width / 2, + centerY: y + height / 2, + }; +} + +function inferHandleSideFromPoint( + bounds: NodeBounds, + point: { x: number; y: number }, + adjacentPoint?: { x: number; y: number } +): 'left' | 'right' | 'top' | 'bottom' { + const dx = adjacentPoint ? adjacentPoint.x - point.x : point.x - bounds.centerX; + const dy = adjacentPoint ? adjacentPoint.y - point.y : point.y - bounds.centerY; + return handleSideFromVector(dx, dy); +} + +function staggerParallelEdgeLabels(edges: FlowEdge[]): FlowEdge[] { + if (!edges.some((e) => e.label)) return edges; + + const pairCounts = new Map(); + const pairIndex = new Map(); + + for (const edge of edges) { + const key = [edge.source, edge.target].sort().join('|'); + pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1); + } + + return edges.map((edge) => { + const key = [edge.source, edge.target].sort().join('|'); + const count = pairCounts.get(key) ?? 1; + if (count <= 1 || !edge.label) return edge; + + const idx = pairIndex.get(key) ?? 0; + pairIndex.set(key, idx + 1); + + // Spread labels across 0.3–0.7 range to avoid pile-up at the midpoint. + const spread = 0.4; + const labelPosition = 0.5 + spread * ((idx / (count - 1)) - 0.5); + + return { + ...edge, + data: { + ...edge.data, + labelPosition: edge.data?.labelPosition ?? labelPosition, + }, + }; + }); +} + +function applyElkHandles( + edges: FlowEdge[], + nodes: FlowNode[], + positionMap: Map, + edgePointsMap: Map +): FlowEdge[] { + const nodeMap = new Map(nodes.map((n) => [n.id, n])); + const routed = edges.map((edge) => { + if (edge.source === edge.target) return edge; + const points = edgePointsMap.get(edge.id); + if (!points || points.length < 2) return edge; + const sourceNode = nodeMap.get(edge.source); + const targetNode = nodeMap.get(edge.target); + if (!sourceNode || !targetNode) return edge; + const sourceBounds = getNodeBoundsFromPositionMap(sourceNode, positionMap); + const targetBounds = getNodeBoundsFromPositionMap(targetNode, positionMap); + const sourceSide = inferHandleSideFromPoint(sourceBounds, points[0], points[1]); + const targetSide = inferHandleSideFromPoint( + targetBounds, + points[points.length - 1], + points[points.length - 2] + ); + const sourceHandle = getNodeHandleIdForSide(sourceNode, sourceSide); + const targetHandle = getNodeHandleIdForSide(targetNode, targetSide); + if (edge.sourceHandle === sourceHandle && edge.targetHandle === targetHandle) return edge; + return { ...edge, sourceHandle, targetHandle }; + }); + return staggerParallelEdgeLabels(routed); +} + export type { LayoutAlgorithm, LayoutDirection, LayoutOptions } from './elk-layout/types'; export { buildResolvedLayoutConfiguration, @@ -241,6 +407,92 @@ function isSparseDiagram(nodeCount: number, edgeCount: number): boolean { return avgDegree <= 2.5; } +function detectCycles(nodes: FlowNode[], edges: FlowEdge[]): boolean { + const adjacency = new Map(); + const visiting = new Set(); + const visited = new Set(); + + nodes.forEach((node) => adjacency.set(node.id, [])); + edges.forEach((edge) => { + if (!adjacency.has(edge.source)) { + adjacency.set(edge.source, []); + } + adjacency.get(edge.source)?.push(edge.target); + }); + + function visit(nodeId: string): boolean { + if (visiting.has(nodeId)) { + return true; + } + if (visited.has(nodeId)) { + return false; + } + + visiting.add(nodeId); + for (const nextId of adjacency.get(nodeId) ?? []) { + if (visit(nextId)) { + return true; + } + } + visiting.delete(nodeId); + visited.add(nodeId); + return false; + } + + for (const nodeId of adjacency.keys()) { + if (visit(nodeId)) { + return true; + } + } + + return false; +} + +function getMaxBranchingFactor(edges: FlowEdge[]): number { + const counts = new Map(); + let max = 0; + for (const edge of edges) { + const count = (counts.get(edge.source) ?? 0) + 1; + counts.set(edge.source, count); + if (count > max) max = count; + } + return max; +} + +export function resolveAutomaticLayoutAlgorithm( + nodes: FlowNode[], + edges: FlowEdge[], + options: LayoutOptions = {} +): LayoutOptions['algorithm'] { + if (options.algorithm) { + return options.algorithm; + } + + if (options.diagramType === 'architecture' || options.diagramType === 'infrastructure') { + return 'layered'; + } + + const nodeCount = nodes.length; + const edgeCount = edges.length; + if (nodeCount <= 1 || edgeCount === 0) { + return 'layered'; + } + + const density = edgeCount / Math.max(nodeCount * (nodeCount - 1), 1); + const hasCycles = detectCycles(nodes, edges); + const maxBranchingFactor = getMaxBranchingFactor(edges); + + if (!hasCycles && maxBranchingFactor > 4 && edgeCount >= nodeCount - 1) { + return 'mrtree'; + } + + if (density > 0.15 || hasCycles) { + return nodeCount >= 24 ? 'stress' : 'force'; + } + + return 'layered'; +} + export function shouldUseLightweightLayoutPostProcessing( nodeCount: number, edgeCount: number, @@ -263,10 +515,6 @@ export async function getElkLayout( edges: FlowEdge[], options: LayoutOptions = {} ): Promise<{ nodes: FlowNode[]; edges: FlowEdge[] }> { - const cacheKey = getLayoutCacheKey(nodes, edges, options); - const cached = layoutCache.get(cacheKey); - if (cached) return cached; - function collectEdgePoints( elkNode: ElkNode | (ElkNode & { edges?: ElkExtendedEdge[]; children?: ElkNode[] }), edgePointsMap: Map @@ -290,8 +538,20 @@ export async function getElkLayout( } const effectiveDiagramType = resolveEffectiveDiagramType(nodes, options.diagramType); + const algorithm = resolveAutomaticLayoutAlgorithm(nodes, edges, { + ...options, + diagramType: effectiveDiagramType, + }); + const cacheKey = getLayoutCacheKey(nodes, edges, { + ...options, + algorithm, + diagramType: effectiveDiagramType, + }); + const cached = layoutCache.get(cacheKey); + if (cached) return cached; const { layoutOptions } = buildResolvedLayoutConfiguration({ ...options, + algorithm, diagramType: effectiveDiagramType, }); const { topLevelNodes, childrenByParent, sortedEdges } = normalizeLayoutInputsForDeterminism( @@ -300,7 +560,7 @@ export async function getElkLayout( ); const orderedTopLevelNodes = effectiveDiagramType === 'architecture' || effectiveDiagramType === 'infrastructure' - ? sortTopLevelNodesForArchitecture(topLevelNodes) + ? sortTopLevelNodesForArchitecture(topLevelNodes, buildDynamicLayerOrder(nodes)) : topLevelNodes; const elkGraph: ElkNode = { @@ -324,23 +584,7 @@ export async function getElkLayout( const edgePointsMap = new Map(); collectEdgePoints(layoutResult, edgePointsMap); - const laidOutNodes = nodes.map((node) => { - const position = positionMap.get(node.id); - if (!position) return node; - - const style = { ...node.style }; - if (node.type === 'group' || node.type === 'section' || node.type === 'container') { - if (position.width) style.width = position.width; - if (position.height) style.height = position.height; - } - - return { - ...node, - position: { x: position.x, y: position.y }, - style, - }; - }); - const reroutedEdges = resolveLayoutedEdgeHandles(laidOutNodes, sortedEdges); + const laidOutNodes = applyElkLayoutToNodes(nodes, positionMap); const sparse = isSparseDiagram(nodes.length, sortedEdges.length); const useLightweightPostProcessing = shouldUseLightweightLayoutPostProcessing( nodes.length, @@ -348,6 +592,13 @@ export async function getElkLayout( effectiveDiagramType ); + // For sparse/small diagrams: use smart position-based handle assignment + bezier routing. + // For dense diagrams: infer handles directly from ELK's computed waypoints — more accurate. + const reroutedEdges = + sparse || useLightweightPostProcessing + ? resolveLayoutedEdgeHandles(laidOutNodes, sortedEdges) + : applyElkHandles(sortedEdges, laidOutNodes, positionMap, edgePointsMap); + const normalizedElkPointsMap = sparse || useLightweightPostProcessing ? new Map() diff --git a/src/services/mermaid/parseMermaidByType.test.ts b/src/services/mermaid/parseMermaidByType.test.ts index 23b34b38..1f94906d 100644 --- a/src/services/mermaid/parseMermaidByType.test.ts +++ b/src/services/mermaid/parseMermaidByType.test.ts @@ -149,7 +149,7 @@ describe('parseMermaidByType', () => { expect(result.nodes.every((node) => node.type === 'journey')).toBe(true); }); - it('returns journey diagnostics for malformed section and invalid step syntax', () => { + it('returns journey diagnostics for malformed section and malformed score-like steps', () => { const result = parseMermaidByType(` journey section @@ -166,7 +166,7 @@ describe('parseMermaidByType', () => { ) ).toBe(true); expect( - result.diagnostics?.some((message) => message.includes('Invalid journey step syntax at line')) + result.diagnostics?.some((message) => message.includes('Invalid journey score at line')) ).toBe(true); }); @@ -256,6 +256,75 @@ describe('parseMermaidByType', () => { expect(result.edges).toHaveLength(0); }); + it('parses semicolon-terminated node declarations correctly', () => { + const result = parseMermaidByType(` + graph TB + A[Start] ==> B{Is it?}; + B -->|Yes| C[OK]; + C --> D[Rethink]; + D -.-> B; + B ---->|No| E[End]; + `); + + expect(result.error).toBeUndefined(); + expect(result.nodes).toHaveLength(5); + expect(result.edges).toHaveLength(5); + + const b = result.nodes.find((n) => n.id === 'B'); + expect(b?.data.label).toBe('Is it?'); + expect(b?.data.shape).toBe('diamond'); + + const c = result.nodes.find((n) => n.id === 'C'); + expect(c?.data.label).toBe('OK'); + + expect(result.edges.some((e) => e.source === 'B' && e.target === 'E')).toBe(true); + expect(result.edges.some((e) => e.source === 'D' && e.target === 'B')).toBe(true); + }); + + it('normalizes extended arrows (----> and ====>) to standard forms', () => { + const result = parseMermaidByType(` + flowchart TD + A ---->|No| B + C ====> D + `); + + expect(result.error).toBeUndefined(); + expect(result.edges).toHaveLength(2); + expect(result.edges[0].source).toBe('A'); + expect(result.edges[0].target).toBe('B'); + expect(result.edges[1].source).toBe('C'); + expect(result.edges[1].target).toBe('D'); + }); + + it('parses chained edges on a single line', () => { + const result = parseMermaidByType(` + flowchart LR + A[Client] --> B[API] --> C[(DB)] + `); + + expect(result.error).toBeUndefined(); + expect(result.nodes).toHaveLength(3); + expect(result.edges).toHaveLength(2); + expect(result.edges.map((edge) => `${edge.source}->${edge.target}`)).toEqual([ + 'A->B', + 'B->C', + ]); + }); + + it('keeps arrow-like text inside labels from corrupting edge parsing', () => { + const result = parseMermaidByType(` + flowchart TD + A["A --> B?"] -->|"status --> ok"| B["Done"] + `); + + expect(result.error).toBeUndefined(); + expect(result.nodes.find((node) => node.id === 'A')?.data.label).toBe('A --> B?'); + expect(result.edges).toHaveLength(1); + expect(result.edges[0].label).toBe('status --> ok'); + expect(result.edges[0].source).toBe('A'); + expect(result.edges[0].target).toBe('B'); + }); + it('returns missing-header error when no family header exists', () => { const result = parseMermaidByType('A --> B'); diff --git a/src/services/smartEdgeRouting.ts b/src/services/smartEdgeRouting.ts index 0d03eba3..4231f591 100644 --- a/src/services/smartEdgeRouting.ts +++ b/src/services/smartEdgeRouting.ts @@ -3,24 +3,12 @@ import { NODE_WIDTH, NODE_HEIGHT } from '../constants'; import type { ViewSettings } from '@/store/types'; import { getNodeParentId } from '@/lib/nodeParent'; import { getNodeHandleIdForSide, type HandleSide } from '@/lib/nodeHandles'; +import { resolveNodeSize } from '@/components/nodeHelpers'; -/** - * Assign intelligent `sourceHandle` and `targetHandle` to each edge - * based on the relative positions of connected nodes. - * - * This produces natural edge routing: - * - If target is below source → bottom→top - * - If target is right of source → right→left - * - Bidirectional edges use different handle pairs - * - Multiple edges between same pair distribute across handles - */ -// Helper to get absolute position -function getAbsolutePosition(node: FlowNode, nodeMap: Map): { x: number, y: number } { - // We intentionally ignore node.positionAbsolute here because during drag operations - // in React Flow, the positionAbsolute might be stale or not yet updated in the store - // while node.position (relative) is updated. - // To ensure smooth "magnetic" routing, we always calculate absolute position - // from the hierarchy. +// Walks the parent hierarchy to get the canvas-absolute position of a node. +// Uses node.position (relative) rather than positionAbsolute, which can be +// stale during drag operations before React Flow updates the store. +function getAbsolutePosition(node: FlowNode, nodeMap: Map): { x: number; y: number } { let x = node.position.x; let y = node.position.y; @@ -39,33 +27,23 @@ function getAbsolutePosition(node: FlowNode, nodeMap: Map): { return { x, y }; } -// Helper to get node dimensions robustly -function getNodeDimensions(node: FlowNode): { width: number, height: number } { - const measured = (node as FlowNode & { - measured?: { - width?: number; - height?: number; - }; - }).measured; - if (measured && measured.width && measured.height) { +function getNodeDimensions(node: FlowNode): { width: number; height: number } { + const measured = (node as FlowNode & { measured?: { width?: number; height?: number } }).measured; + if (measured?.width && measured?.height) { return { width: measured.width, height: measured.height }; } - const styleWidth = node.style?.width; - const styleHeight = node.style?.height; - - const w = typeof styleWidth === 'string' && styleWidth.endsWith('px') - ? parseFloat(styleWidth) - : (typeof styleWidth === 'number' ? styleWidth : null); - - const h = typeof styleHeight === 'string' && styleHeight.endsWith('px') - ? parseFloat(styleHeight) - : (typeof styleHeight === 'number' ? styleHeight : null); + const resolved = resolveNodeSize(node); + if (resolved.width && resolved.height) { + return resolved; + } - return { - width: w ?? node.width ?? NODE_WIDTH, - height: h ?? node.height ?? NODE_HEIGHT - }; + // Match ELK's label-based estimation so handle assignment uses the same + // assumed size that ELK used when computing node positions. + const label = node.data?.label ?? ''; + const estimatedWidth = Math.max(NODE_WIDTH, label.length * 8 + 40); + const estimatedHeight = Math.max(NODE_HEIGHT, Math.ceil(label.length / 40) * 20 + 60); + return { width: estimatedWidth, height: estimatedHeight }; } type RoutingContext = { @@ -120,27 +98,21 @@ function preserveEdgeLabelPlacement(originalEdge: FlowEdge, nextEdge: FlowEdge): }; } +export function handleSideFromVector(dx: number, dy: number): HandleSide { + if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? 'right' : 'left'; + return dy >= 0 ? 'bottom' : 'top'; +} + function resolveAutoHandleSides( dx: number, dy: number, profile: SmartRoutingOptions['profile'] ): { sourceHandleSide: HandleSide; targetHandleSide: HandleSide } { - const verticalDominance = profile === 'infrastructure' - ? Math.abs(dy) > Math.abs(dx) * 1.25 - : Math.abs(dy) >= Math.abs(dx); - - if (verticalDominance) { - if (dy >= 0) { - return { sourceHandleSide: 'bottom', targetHandleSide: 'top' }; - } - return { sourceHandleSide: 'top', targetHandleSide: 'bottom' }; - } - - if (dx >= 0) { - return { sourceHandleSide: 'right', targetHandleSide: 'left' }; - } - - return { sourceHandleSide: 'left', targetHandleSide: 'right' }; + // Infrastructure uses a 1.25x bias toward horizontal routing. + const effectiveDy = profile === 'infrastructure' ? dy * 1.25 : dy; + const sourceSide = handleSideFromVector(dx, effectiveDy); + const targetSide = handleSideFromVector(-dx, -effectiveDy); + return { sourceHandleSide: sourceSide, targetHandleSide: targetSide }; } function buildRoutingContext(nodes: FlowNode[], _edges: FlowEdge[]): RoutingContext { From a96a97c3e29a554863b8a19a7c17786e37eb01b6 Mon Sep 17 00:00:00 2001 From: Varun Date: Wed, 8 Apr 2026 10:51:20 +0530 Subject: [PATCH 4/9] feat: icon state normalization, node properties, edge routing polish - Normalize provider icon metadata (archIconPackId, archIconShapeId, assetProvider) across import, editing, bulk ops, templates, and connect menu suggestions - Shared IconPicker for all node types including icon-backed asset nodes - Update NodeProperties, BulkNodeProperties, ArchitectureNodeSection to use unified icon editing path - Fix edge path utilities and custom edge routing - Update ConnectMenu suggestions to use normalized provider icon data - nodeParent helpers, types, domainLibrary, templateFactories polish - mermaidBuilder and mermaidParser test coverage additions Co-Authored-By: Claude Sonnet 4.6 --- GROUPS_IMPLEMENTATION_LOG.md | 46 ++++ MERMAID_IMPORT_IMPLEMENTATION_LOG.md | 119 +++++++++++ src/components/ConnectMenu.test.tsx | 89 ++++++-- src/components/ConnectMenu.tsx | 14 +- .../command-bar/AssetsView.test.tsx | 79 +++---- src/components/custom-edge/pathUtils.test.ts | 80 ++++++- src/components/custom-edge/pathUtils.ts | 69 +++--- .../properties/BulkNodeProperties.tsx | 49 +++-- src/components/properties/IconPicker.tsx | 15 +- .../properties/NodeProperties.test.tsx | 22 ++ src/components/properties/NodeProperties.tsx | 197 +++++------------- .../properties/bulkNodePropertiesModel.ts | 36 ++-- .../ArchitectureNodeProperties.test.tsx | 15 +- .../families/ArchitectureNodeSection.tsx | 17 +- src/diagram-types/journey/fuzzCorpus.test.ts | 2 +- src/hooks/node-operations/utils.test.ts | 14 +- src/hooks/useFlowEditorCallbacks.ts | 6 +- src/lib/flowmindDSLParserV2.ts | 4 +- src/lib/nodeParent.ts | 23 +- src/lib/types.ts | 2 + src/services/domainLibrary.test.ts | 4 +- src/services/domainLibrary.ts | 40 ++-- src/services/export/mermaidBuilder.ts | 3 +- src/services/mermaidParser.test.ts | 7 + .../templateLibrary/templateFactories.ts | 11 +- tsconfig.tsbuildinfo | 2 +- 26 files changed, 628 insertions(+), 337 deletions(-) create mode 100644 GROUPS_IMPLEMENTATION_LOG.md create mode 100644 MERMAID_IMPORT_IMPLEMENTATION_LOG.md diff --git a/GROUPS_IMPLEMENTATION_LOG.md b/GROUPS_IMPLEMENTATION_LOG.md new file mode 100644 index 00000000..9f249c81 --- /dev/null +++ b/GROUPS_IMPLEMENTATION_LOG.md @@ -0,0 +1,46 @@ +# Groups Implementation Log + +Date: 2026-04-07 + +## Implemented + +- Made parent assignment opt-in for `extent: 'parent'` in [src/lib/nodeParent.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/nodeParent.ts), so grouped nodes are no longer physically trapped by default. +- Updated Mermaid and OpenFlow DSL import paths to preserve `parentId` without forcing parent drag constraints in [src/lib/mermaidParser.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParser.ts) and [src/lib/flowmindDSLParserV2.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/flowmindDSLParserV2.ts). +- Aligned Mermaid-created section sizes with the real section minimums in [src/lib/mermaidParser.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParser.ts). +- Unified ELK root/container padding with the section bounds model and enabled compound hierarchy handling in [src/services/elk-layout/options.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/elk-layout/options.ts) and [src/services/elkLayout.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/elkLayout.ts). +- Added explicit ELK absolute-to-relative position normalization for parented nodes in [src/services/elkLayout.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/elkLayout.ts). +- Centralized post-layout section auto-fit in [src/services/composeDiagramForDisplay.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/composeDiagramForDisplay.ts) so imports and other composed layouts get the same safety net. +- Routed Mermaid paste imports through the shared display composition path in [src/components/flow-canvas/useFlowCanvasPaste.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/flow-canvas/useFlowCanvasPaste.ts). +- Added focused coverage for parser sizing/parenting and ELK parent-relative layout conversion in [src/services/mermaidParser.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaidParser.test.ts), [src/hooks/node-operations/utils.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/hooks/node-operations/utils.test.ts), and [src/services/elkLayout.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/elkLayout.test.ts). + +## Plan Refinements + +- Did not add a separate drag-stop escape path in `useNodeDragOperations.ts`. + Reason: the audit was stale on this point. The real trap was unconditional `extent: 'parent'`, while `applySectionParenting()` already knows how to unparent on drop outside a section once movement is no longer DOM-constrained. + +- Did not model a larger floating-header offset in ELK. + Reason: the current source of truth in `sectionBounds.ts` uses `SECTION_CONTENT_PADDING_TOP = SECTION_HEADER_HEIGHT = 16`, not the older `-36` header model described in the audit. Reusing the actual shared constants is safer than reintroducing stale geometry assumptions. + +- Applied section auto-fit at the shared display composition layer instead of only Mermaid paste. + Reason: this makes grouped layout reliability consistent across Mermaid import, code-panel apply flows, and any other caller that uses composed display layout. + +- Extended the import parenting change to the OpenFlow DSL parser as well. + Reason: the groups reliability issue is not Mermaid-specific. Keeping grouped imports on the same unconstrained parent model avoids inconsistent behavior between import paths. + +## Why These Decisions Improved Reliability + +- Removing unconditional parent constraints fixes the escape problem at the root cause instead of layering special-case drag logic on top. +- Shared padding/min-size constants eliminate geometry drift between hit testing, section fitting, and ELK. +- Converting ELK absolute child coordinates back to parent-relative React Flow coordinates fixes the main grouped-layout correctness bug. +- Centralized post-layout fitting gives a deterministic last-mile correction even when ELK sizing is slightly conservative. +- Reusing the same composition path for imports reduces divergence and makes future layout fixes apply everywhere. + +## Simplification Pass + +- Ran a code simplification/cleanup pass over the touched files after implementation. +- Applied Prettier formatting to the modified files to keep the new helpers and tests consistent with repo style. + +## Validation + +- `pnpm vitest run src/services/mermaidParser.test.ts src/hooks/node-operations/utils.test.ts src/services/elkLayout.test.ts` +- `pnpm exec tsc --noEmit` diff --git a/MERMAID_IMPORT_IMPLEMENTATION_LOG.md b/MERMAID_IMPORT_IMPLEMENTATION_LOG.md new file mode 100644 index 00000000..40fd469b --- /dev/null +++ b/MERMAID_IMPORT_IMPLEMENTATION_LOG.md @@ -0,0 +1,119 @@ +# Mermaid Import Implementation Log +**Date:** 2026-04-07 + +## Implemented + +### Import pipeline hardening +- Made `enrichNodesWithIcons()` import-aware and diagram-aware in [src/lib/nodeEnricher.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/nodeEnricher.ts). +- Added conservative technology/query guards in [src/lib/semanticClassifier.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/semanticClassifier.ts). +- Moved Mermaid paste imports onto strict enrichment behavior before ELK layout in [src/components/flow-canvas/useFlowCanvasPaste.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/flow-canvas/useFlowCanvasPaste.ts). +- Changed import spacing to `loose -> normal -> compact` with compact floor raised above the old 40px behavior in [src/services/elk-layout/options.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/elk-layout/options.ts). + +### Layout reliability +- Added automatic layout selection based on graph structure in [src/services/elkLayout.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/elkLayout.ts). +- Added cycle detection and branching-factor heuristics so star/tree graphs can use `mrtree`, while dense/cyclic graphs switch to `force` or `stress`. +- Preserved architecture ordering from imported boundary/group order via `archLayerRank` metadata in [src/diagram-types/architecture/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/architecture/plugin.ts) and [src/lib/types.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/types.ts). +- Improved ELK handle inference to use route direction vectors instead of nearest-side guesses in [src/services/elkLayout.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/elkLayout.ts). + +### Parser hardening +- Strengthened flowchart edge parsing in [src/lib/mermaidParserHelpers.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParserHelpers.ts). +- Added support for chained edges on one line. +- Stopped arrow detection from breaking on arrow-like text inside quoted labels or `|edge labels|`. +- Normalized additional bidirectional extended arrow variants. + +### Diagram plugin improvements +- Added state-diagram note rendering and fork/join control parsing in [src/diagram-types/stateDiagram/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/stateDiagram/plugin.ts). +- Added visible sequence fragment nodes and improved note placement metadata in [src/diagram-types/sequence/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/sequence/plugin.ts). +- Tightened journey score validation and mapped scores to visual color states in [src/diagram-types/journey/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/journey/plugin.ts). +- Added generic-class normalization and relation cardinality parsing in [src/diagram-types/classDiagram/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/classDiagram/plugin.ts). +- Upgraded ER field parsing from raw strings to structured field objects in [src/diagram-types/erDiagram/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/erDiagram/plugin.ts). + +### Edge polish +- Applied sibling-aware ELK label staggering in [src/components/custom-edge/pathUtils.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/custom-edge/pathUtils.ts). +- Improved self-loop sizing so loops scale from actual node dimensions in [src/components/custom-edge/pathUtils.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/custom-edge/pathUtils.ts). +- Added import-time icon enrichment failure isolation in [src/components/flow-canvas/useFlowCanvasPaste.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/flow-canvas/useFlowCanvasPaste.ts). + +### Test coverage added/updated +- Expanded focused coverage in: + - [src/lib/semanticClassifier.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/semanticClassifier.test.ts) + - [src/lib/nodeEnricher.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/nodeEnricher.test.ts) + - [src/services/mermaid/parseMermaidByType.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/parseMermaidByType.test.ts) + - [src/diagram-types/stateDiagram/plugin.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/stateDiagram/plugin.test.ts) + - [src/diagram-types/sequence/plugin.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/sequence/plugin.test.ts) + - [src/diagram-types/journey/plugin.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/journey/plugin.test.ts) + - [src/diagram-types/classDiagram/plugin.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/classDiagram/plugin.test.ts) + - [src/diagram-types/erDiagram/plugin.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/erDiagram/plugin.test.ts) + - [src/services/elkLayout.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/elkLayout.test.ts) + +## Plan Changes + +### Refined away stale audit assumptions +- The audit proposed a subgraph `end` guard in the legacy parser. That guard already exists in [src/lib/mermaidParser.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParser.ts), so no new code was added there. +- The audit described `[*]` state terminals as rendering plainly. The current core parser already gives state start nodes terminal styling, so the work was redirected to truly missing state features: notes and fork/join controls. + +### Replaced raw-source architecture ordering with node metadata +- The audit suggested teaching `elkLayout.ts` to read semantic layer order directly from Mermaid source. +- That would have coupled layout to source text it does not own. Instead, the architecture plugin now emits stable `archLayerRank` metadata, and layout consumes that metadata. +- This is more reliable because the parse stage remains the single source of truth and layout stays source-agnostic. + +### Replaced sequence fragment “section nodes” with annotation-style fragment markers +- The audit suggested styled section nodes for sequence fragments. +- Existing ELK behavior and the current sequence lane model make container sections high-risk because they distort participant layout and grouping. +- Fragment markers were implemented as visible annotation nodes instead. This preserves visibility without destabilizing sequence layout. + +### Refined the journey gap +- The audit said actors were ignored. +- In the current implementation, actors were already preserved as journey sublabels. +- The higher-value fix was strict score enforcement plus score-based visual differentiation, which directly improves correctness and readability. + +### Expanded generic parsing beyond the audit wording +- The audit called out `` generics. +- Mermaid syntax commonly uses `~T~`, so the class parser now normalizes `~T~` to `` and accepts both forms. +- This improves real-world Mermaid compatibility instead of only satisfying one notation. + +## Why These Decisions Improved Reliability + +- Import-time iconing is now conservative by default, which removes the most visible false-positive behavior without breaking existing manual/icon-explicit nodes. +- Layout selection is now based on structure, not just size, which prevents tree-like imports and dense cyclic imports from being forced through the same algorithm. +- Parser edge scanning now respects Mermaid syntax boundaries, which is the difference between “works on demos” and “survives real pasted diagrams”. +- Class and ER plugins now emit structured data closer to the rest of the editor’s internal node model, which reduces downstream hacks and makes future rendering improvements safer. + +## Code Simplification Pass + +### Step 1 +- Scope: [src/diagram-types/sequence/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/sequence/plugin.ts) +- Behavior preserved: + - Fragment node colors remain identical. + - Note lane placement remains identical. + - No parser semantics changed. +- Simplification: + - Replaced nested fragment-color ternaries with `getSequenceFragmentColor()`. + - Extracted `getParticipantLaneIndex()` to make note placement logic explicit. +- Validation: + - `pnpm vitest run src/diagram-types/sequence/plugin.test.ts` -> passed +- Rollback note: + - Revert only the helper extraction in `src/diagram-types/sequence/plugin.ts` if this refactor ever causes a regression. + +### Step 2 +- Scope: touched Mermaid implementation files +- Behavior preserved: + - No syntax support was removed. + - No layout heuristics were weakened. + - No icon-enrichment policy was broadened. +- Simplification: + - [src/components/flow-canvas/useFlowCanvasPaste.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/flow-canvas/useFlowCanvasPaste.ts): converted import enrichment isolation into a stable callback helper. + - [src/lib/nodeEnricher.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/nodeEnricher.ts): extracted icon-enrichment policy calculation into a focused helper. + - [src/diagram-types/classDiagram/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/classDiagram/plugin.ts): removed repeated class-record bootstrap branches via `ensureClassRecord()`. + - Reviewed the remaining touched Mermaid files for simplification opportunities and left already-clear code unchanged to avoid churn-only edits. +- Validation: + - `pnpm vitest run src/components/custom-edge/pathUtils.test.ts src/lib/nodeEnricher.test.ts src/services/mermaid/parseMermaidByType.test.ts src/diagram-types/classDiagram/plugin.test.ts src/diagram-types/stateDiagram/plugin.test.ts src/diagram-types/sequence/plugin.test.ts src/diagram-types/journey/plugin.test.ts src/diagram-types/erDiagram/plugin.test.ts src/services/elkLayout.test.ts` -> passed + - `pnpm exec tsc --noEmit` -> passed +- Rollback note: + - Revert only the helper extractions above if a maintenance-oriented refactor ever needs to be undone separately from behavior fixes. + +## Validation Summary + +- `pnpm vitest run src/lib/semanticClassifier.test.ts src/lib/nodeEnricher.test.ts src/services/mermaid/parseMermaidByType.test.ts src/diagram-types/stateDiagram/plugin.test.ts src/diagram-types/sequence/plugin.test.ts src/diagram-types/journey/plugin.test.ts src/diagram-types/classDiagram/plugin.test.ts src/diagram-types/erDiagram/plugin.test.ts src/services/elkLayout.test.ts` -> passed +- `pnpm vitest run src/diagram-types/sequence/plugin.test.ts` -> passed +- `pnpm vitest run src/components/custom-edge/pathUtils.test.ts src/lib/nodeEnricher.test.ts src/services/mermaid/parseMermaidByType.test.ts src/diagram-types/classDiagram/plugin.test.ts src/diagram-types/stateDiagram/plugin.test.ts src/diagram-types/sequence/plugin.test.ts src/diagram-types/journey/plugin.test.ts src/diagram-types/erDiagram/plugin.test.ts src/services/elkLayout.test.ts` -> passed +- `pnpm exec tsc --noEmit` -> passed diff --git a/src/components/ConnectMenu.test.tsx b/src/components/ConnectMenu.test.tsx index 834ef9df..d4f35f7b 100644 --- a/src/components/ConnectMenu.test.tsx +++ b/src/components/ConnectMenu.test.tsx @@ -2,25 +2,38 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { ConnectMenu } from './ConnectMenu'; -vi.mock('@/store', () => ({ - useFlowStore: (selector: (state: { nodes: Array<{ id: string; data: Record }> }) => unknown) => selector({ - nodes: [ - { - id: 'asset-1', - data: { - label: 'Analytics Athena', - assetPresentation: 'icon', - assetProvider: 'aws', - assetCategory: 'Analytics', - archIconShapeId: 'analytics-athena', - }, +const mockStoreState: { nodes: Array<{ id: string; data: Record }> } = { + nodes: [ + { + id: 'asset-1', + data: { + label: 'Analytics Athena', + assetPresentation: 'icon', + assetProvider: 'aws', + assetCategory: 'Analytics', + archIconPackId: 'aws-official-starter-v1', + archIconShapeId: 'analytics-athena', }, - ], - }), + }, + ], +}; + +vi.mock('@/store', () => ({ + useFlowStore: (selector: (state: { nodes: Array<{ id: string; data: Record }> }) => unknown) => selector(mockStoreState), })); -vi.mock('@/services/shapeLibrary/providerCatalog', () => ({ - loadProviderCatalogSuggestions: vi.fn(async () => [ +vi.mock('@/services/shapeLibrary/providerCatalog', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadProviderShapePreview: vi.fn(async () => ({ + previewUrl: '/mock/glue.svg', + })), + }; +}); + +vi.mock('@/services/assetCatalog', () => ({ + loadDomainAssetSuggestions: vi.fn(async () => [ { id: 'aws-official-starter-v1:analytics-glue', category: 'aws', @@ -35,9 +48,6 @@ vi.mock('@/services/shapeLibrary/providerCatalog', () => ({ archIconShapeId: 'analytics-glue', }, ]), - loadProviderShapePreview: vi.fn(async () => ({ - previewUrl: '/mock/glue.svg', - })), })); vi.mock('react-i18next', () => ({ @@ -82,6 +92,20 @@ describe('ConnectMenu', () => { }); it('shows provider suggestions for asset nodes instead of generic shapes', async () => { + mockStoreState.nodes = [ + { + id: 'asset-1', + data: { + label: 'Analytics Athena', + assetPresentation: 'icon', + assetProvider: 'aws', + assetCategory: 'Analytics', + archIconPackId: 'aws-official-starter-v1', + archIconShapeId: 'analytics-athena', + }, + }, + ]; + render( { expect(screen.queryByText('connectMenu.process')).toBeNull(); }); + it('derives asset provider from pack metadata for older icon nodes', async () => { + mockStoreState.nodes = [ + { + id: 'asset-legacy', + data: { + label: 'Lambda', + assetPresentation: 'icon', + archIconPackId: 'aws-official-starter-v1', + archIconShapeId: 'compute-lambda', + }, + }, + ]; + + render( + + ); + + expect(await screen.findByRole('menuitem', { name: /Analytics Glue/i })).toBeTruthy(); + }); + it('surfaces contextual class creation first for class connectors', () => { const onSelect = vi.fn(); render( diff --git a/src/components/ConnectMenu.tsx b/src/components/ConnectMenu.tsx index 4629a318..28d8016b 100644 --- a/src/components/ConnectMenu.tsx +++ b/src/components/ConnectMenu.tsx @@ -9,6 +9,7 @@ import { getAssetCategoryDisplayName } from '@/services/assetPresentation'; import { loadProviderShapePreview } from '@/services/shapeLibrary/providerCatalog'; import type { ConnectedEdgePreset } from '@/hooks/edge-operations/utils'; import { useMenuKeyboardNavigation } from '@/hooks/useMenuKeyboardNavigation'; +import { normalizeNodeIconData } from '@/lib/nodeIconState'; import { type ConnectMenuOption, GenericConnectOptionsSection, @@ -96,13 +97,14 @@ export const ConnectMenu = ({ position, sourceId, sourceType, onSelect, onSelect const menuRef = React.useRef(null); const { onKeyDown } = useMenuKeyboardNavigation({ menuRef, onClose }); const sourceNode = useFlowStore((state) => state.nodes.find((node) => node.id === sourceId)); + const normalizedIconData = normalizeNodeIconData(sourceNode?.data); const isMindmapSource = isMindmapConnectorSource(sourceType); - const isAssetSource = sourceNode?.data?.assetPresentation === 'icon' - && typeof sourceNode.data?.assetProvider === 'string'; - const assetProvider = (sourceNode?.data?.assetProvider || null) as DomainLibraryCategory | null; - const assetCategory = typeof sourceNode?.data?.assetCategory === 'string' ? sourceNode.data.assetCategory : undefined; - const currentShapeId = typeof sourceNode?.data?.archIconShapeId === 'string' ? sourceNode.data.archIconShapeId : undefined; - const currentIconName = typeof sourceNode?.data?.icon === 'string' ? sourceNode.data.icon : undefined; + const isAssetSource = normalizedIconData?.assetPresentation === 'icon' + && typeof normalizedIconData.assetProvider === 'string'; + const assetProvider = (normalizedIconData?.assetProvider || null) as DomainLibraryCategory | null; + const assetCategory = typeof normalizedIconData?.assetCategory === 'string' ? normalizedIconData.assetCategory : undefined; + const currentShapeId = typeof normalizedIconData?.archIconShapeId === 'string' ? normalizedIconData.archIconShapeId : undefined; + const currentIconName = typeof normalizedIconData?.icon === 'string' ? normalizedIconData.icon : undefined; const providerItemsKey = isAssetSource && assetProvider ? `${assetProvider}:${assetCategory ?? 'all'}:${currentShapeId ?? currentIconName ?? 'all'}` : null; diff --git a/src/components/command-bar/AssetsView.test.tsx b/src/components/command-bar/AssetsView.test.tsx index 00808047..9cbd7458 100644 --- a/src/components/command-bar/AssetsView.test.tsx +++ b/src/components/command-bar/AssetsView.test.tsx @@ -2,44 +2,49 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { AssetsView } from './AssetsView'; -vi.mock('@/services/shapeLibrary/providerCatalog', () => ({ - getProviderCatalogCount: vi.fn((provider: string) => (provider === 'aws' ? 2 : 0)), - loadProviderCatalog: vi.fn(async () => [ - { - id: 'aws-official-starter-v1:analytics-athena', - category: 'aws', +vi.mock('@/services/shapeLibrary/providerCatalog', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + getProviderCatalogCount: vi.fn((provider: string) => (provider === 'aws' ? 2 : 0)), + loadProviderCatalog: vi.fn(async () => [ + { + id: 'aws-official-starter-v1:analytics-athena', + category: 'aws', + label: 'Analytics Athena', + description: 'AWS Analytics', + icon: 'Box', + color: 'amber', + nodeType: 'custom', + assetPresentation: 'icon', + providerShapeCategory: 'Analytics', + archIconPackId: 'aws-official-starter-v1', + archIconShapeId: 'analytics-athena', + }, + { + id: 'aws-official-starter-v1:compute-lambda', + category: 'aws', + label: 'Compute Lambda', + description: 'AWS Compute', + icon: 'Box', + color: 'amber', + nodeType: 'custom', + assetPresentation: 'icon', + providerShapeCategory: 'Compute', + archIconPackId: 'aws-official-starter-v1', + archIconShapeId: 'compute-lambda', + }, + ]), + loadProviderShapePreview: vi.fn(async () => ({ + packId: 'aws-official-starter-v1', + shapeId: 'analytics-athena', label: 'Analytics Athena', - description: 'AWS Analytics', - icon: 'Box', - color: 'amber', - nodeType: 'custom', - assetPresentation: 'icon', - providerShapeCategory: 'Analytics', - archIconPackId: 'aws-official-starter-v1', - archIconShapeId: 'analytics-athena', - }, - { - id: 'aws-official-starter-v1:compute-lambda', - category: 'aws', - label: 'Compute Lambda', - description: 'AWS Compute', - icon: 'Box', - color: 'amber', - nodeType: 'custom', - assetPresentation: 'icon', - providerShapeCategory: 'Compute', - archIconPackId: 'aws-official-starter-v1', - archIconShapeId: 'compute-lambda', - }, - ]), - loadProviderShapePreview: vi.fn(async () => ({ - packId: 'aws-official-starter-v1', - shapeId: 'analytics-athena', - label: 'Analytics Athena', - category: 'Analytics', - previewUrl: '/mock/athena.svg', - })), -})); + category: 'Analytics', + previewUrl: '/mock/athena.svg', + })), + }; +}); vi.mock('react-i18next', () => ({ useTranslation: () => ({ diff --git a/src/components/custom-edge/pathUtils.test.ts b/src/components/custom-edge/pathUtils.test.ts index 98877ab9..ead6030a 100644 --- a/src/components/custom-edge/pathUtils.test.ts +++ b/src/components/custom-edge/pathUtils.test.ts @@ -181,6 +181,83 @@ describe('buildEdgePath', () => { expect(Number.isFinite(result.labelY)).toBe(true); }); + it('uses node dimensions when building self-loop paths', () => { + const result = buildEdgePath( + { + id: 'edge-self', + source: 'a', + target: 'a', + sourceX: 100, + sourceY: 100, + targetX: 100, + targetY: 100, + sourcePosition: Position.Right, + targetPosition: Position.Right, + }, + [], + [{ ...NODE_A, width: 320, height: 120 }], + 'smoothstep' + ); + + expect(result.edgePath).toContain('C'); + expect(result.labelX).toBeGreaterThan(100 + 100); + }); + + it('nudges ELK label positions for dense sibling bundles', () => { + const allEdges = [ + { id: 'edge-1', source: 'a', target: 'b', sourceHandle: 'right', targetHandle: 'left' }, + { id: 'edge-2', source: 'a', target: 'c', sourceHandle: 'right', targetHandle: 'left' }, + { id: 'edge-3', source: 'a', target: 'd', sourceHandle: 'right', targetHandle: 'left' }, + ]; + + const first = buildEdgePath( + { + id: 'edge-1', + source: 'a', + target: 'b', + sourceX: 100, + sourceY: 100, + targetX: 260, + targetY: 40, + sourcePosition: Position.Right, + targetPosition: Position.Left, + sourceHandleId: 'right', + targetHandleId: 'left', + }, + allEdges, + THREE_TARGET_NODES, + 'smoothstep', + { + routingMode: 'elk', + elkPoints: [{ x: 180, y: 100 }, { x: 220, y: 40 }], + } + ); + const middle = buildEdgePath( + { + id: 'edge-2', + source: 'a', + target: 'c', + sourceX: 100, + sourceY: 100, + targetX: 260, + targetY: 100, + sourcePosition: Position.Right, + targetPosition: Position.Left, + sourceHandleId: 'right', + targetHandleId: 'left', + }, + allEdges, + THREE_TARGET_NODES, + 'smoothstep', + { + routingMode: 'elk', + elkPoints: [{ x: 180, y: 100 }, { x: 220, y: 100 }], + } + ); + + expect(first.labelY).not.toBe(middle.labelY); + }); + it('builds straight auto-routed paths when the straight renderer is used', () => { const result = buildEdgePath( { @@ -746,6 +823,7 @@ describe('buildEdgePath', () => { expect(getMovePoint(first.edgePath)).toBe('100,100'); expect(first.labelX).toBe(160); - expect(first.labelY).toBe(70); + expect(first.labelY).toBeLessThan(70); + expect(first.labelY).toBeGreaterThan(60); }); }); diff --git a/src/components/custom-edge/pathUtils.ts b/src/components/custom-edge/pathUtils.ts index 17f420e3..82ed6123 100644 --- a/src/components/custom-edge/pathUtils.ts +++ b/src/components/custom-edge/pathUtils.ts @@ -42,16 +42,48 @@ export function buildEdgePath( return measureDevPerformance('buildEdgePath', () => { const interactionLowDetailModeActive = isEdgeInteractionLowDetailModeActive(); const graphRoutingFastPathActive = interactionLowDetailModeActive || allEdges.length >= EDGE_ROUTING_FAST_PATH_THRESHOLD; + const pairOffset = graphRoutingFastPathActive + ? 0 + : getParallelEdgeOffset(params.id, params.source, params.target, allEdges); + const sourceSiblingCount = graphRoutingFastPathActive + ? 0 + : getEndpointSiblingCount(allEdges, allNodes, { + nodeId: params.source, + handleId: params.sourceHandleId, + direction: 'source', + }); + const sourceFanoutOffset = graphRoutingFastPathActive + ? 0 + : getEndpointFanoutOffset(params.id, allEdges, allNodes, { + nodeId: params.source, + handleId: params.sourceHandleId, + direction: 'source', + }); + const targetFanoutOffset = graphRoutingFastPathActive + ? 0 + : getEndpointFanoutOffset(params.id, allEdges, allNodes, { + nodeId: params.target, + handleId: params.targetHandleId, + direction: 'target', + }); + const labelBundleOffset = pairOffset + (sourceFanoutOffset + targetFanoutOffset) / 2; if (params.source === params.target) { + const sourceNode = getNodeById(allNodes, params.source); const loop = getSelfLoopPath( params.sourceX, params.sourceY, - 180, - 60, + sourceNode?.width ?? 180, + sourceNode?.height ?? 60, getLoopDirection(params.sourcePosition) ); - return { edgePath: loop.path, labelX: loop.labelX, labelY: loop.labelY }; + return withBundledLabelOffset( + loop.path, + loop.labelX, + loop.labelY, + params, + labelBundleOffset + ); } const shouldUseElkRoute = @@ -81,39 +113,11 @@ export function buildEdgePath( const pathStr = buildRoundedPolylinePath(allPoints, 20); const { x: labelX, y: labelY } = getElkLabelPosition(adjustedSource.x, adjustedSource.y, points); - return { - edgePath: pathStr, - labelX, - labelY, - }; + return withBundledLabelOffset(pathStr, labelX, labelY, params, labelBundleOffset); } - const pairOffset = graphRoutingFastPathActive - ? 0 - : getParallelEdgeOffset(params.id, params.source, params.target, allEdges); const isMindmapBranch = Boolean(options.mindmapBranchKind) && variant === 'bezier' && !options.forceOrthogonal; const isMindmapRootBranch = options.mindmapBranchKind === 'root' && isMindmapBranch; - const sourceSiblingCount = graphRoutingFastPathActive - ? 0 - : getEndpointSiblingCount(allEdges, allNodes, { - nodeId: params.source, - handleId: params.sourceHandleId, - direction: 'source', - }); - const sourceFanoutOffset = graphRoutingFastPathActive - ? 0 - : getEndpointFanoutOffset(params.id, allEdges, allNodes, { - nodeId: params.source, - handleId: params.sourceHandleId, - direction: 'source', - }); - const targetFanoutOffset = graphRoutingFastPathActive - ? 0 - : getEndpointFanoutOffset(params.id, allEdges, allNodes, { - nodeId: params.target, - handleId: params.targetHandleId, - direction: 'target', - }); const shouldUseSharedSourceTrunk = !isMindmapBranch && (variant === 'smoothstep' || variant === 'step' || options.forceOrthogonal) @@ -135,7 +139,6 @@ export function buildEdgePath( pairOffset + ((isMindmapBranch || shouldUseSharedSourceTrunk) ? 0 : sourceFanoutOffset) ); const targetOffset = getOffsetVector(params.targetPosition, pairOffset + targetFanoutOffset); - const labelBundleOffset = pairOffset + (sourceFanoutOffset + targetFanoutOffset) / 2; const sourceX = params.sourceX + sourceOffset.x; const sourceY = params.sourceY + sourceOffset.y; const targetX = params.targetX + targetOffset.x; diff --git a/src/components/properties/BulkNodeProperties.tsx b/src/components/properties/BulkNodeProperties.tsx index 1b9945ab..34cc961e 100644 --- a/src/components/properties/BulkNodeProperties.tsx +++ b/src/components/properties/BulkNodeProperties.tsx @@ -42,6 +42,11 @@ import { type BulkSectionId, type BulkNodePropertiesFormState, } from './bulkNodePropertiesModel'; +import { + createBuiltInIconData, + createProviderIconData, + createUploadedIconData, +} from '@/lib/nodeIconState'; interface BulkNodePropertiesProps { selectedNodes: Node[]; @@ -138,41 +143,49 @@ export function BulkNodeProperties({ } function handleBuiltInIconChange(nextIcon: string): void { + const updates = createBuiltInIconData(nextIcon); setForm((current) => ({ ...current, iconMode: 'built-in', - icon: nextIcon, - customIconUrl: undefined, - assetProvider: undefined, - assetCategory: undefined, - archIconPackId: undefined, - archIconShapeId: undefined, + icon: updates.icon ?? '', + customIconUrl: updates.customIconUrl, + assetProvider: updates.assetProvider as BulkNodePropertiesFormState['assetProvider'], + assetCategory: updates.assetCategory, + archIconPackId: updates.archIconPackId, + archIconShapeId: updates.archIconShapeId, })); } function handleProviderIconChange(selection: ProviderIconSelection): void { + const updates = createProviderIconData({ + packId: selection.packId, + shapeId: selection.shapeId, + provider: selection.provider, + category: selection.category, + }); setForm((current) => ({ ...current, iconMode: 'provider', - icon: '', - customIconUrl: undefined, - assetProvider: selection.provider, - assetCategory: selection.category, - archIconPackId: selection.packId, - archIconShapeId: selection.shapeId, + icon: updates.icon ?? '', + customIconUrl: updates.customIconUrl, + assetProvider: updates.assetProvider as BulkNodePropertiesFormState['assetProvider'], + assetCategory: updates.assetCategory, + archIconPackId: updates.archIconPackId, + archIconShapeId: updates.archIconShapeId, })); } function handleCustomIconChange(url?: string): void { + const updates = createUploadedIconData(url); setForm((current) => ({ ...current, iconMode: url ? 'upload' : '', - icon: '', - customIconUrl: url, - assetProvider: undefined, - assetCategory: undefined, - archIconPackId: undefined, - archIconShapeId: undefined, + icon: updates.icon ?? '', + customIconUrl: updates.customIconUrl, + assetProvider: updates.assetProvider as BulkNodePropertiesFormState['assetProvider'], + assetCategory: updates.assetCategory, + archIconPackId: updates.archIconPackId, + archIconShapeId: updates.archIconShapeId, })); } diff --git a/src/components/properties/IconPicker.tsx b/src/components/properties/IconPicker.tsx index ef8f8802..36c52e09 100644 --- a/src/components/properties/IconPicker.tsx +++ b/src/components/properties/IconPicker.tsx @@ -8,6 +8,7 @@ import { loadProviderShapePreview, } from '@/services/shapeLibrary/providerCatalog'; import { useAssetCatalog } from '@/hooks/useAssetCatalog'; +import { inferAssetProviderFromPackId } from '@/lib/nodeIconState'; import { ICON_NAMES, ICON_PICKER_PRIORITY_NAMES, NamedIcon } from '../IconMap'; import { Tooltip } from '../Tooltip'; import { Select } from '../ui/Select'; @@ -72,16 +73,6 @@ function getInitialSource( return 'built-in'; } -function inferProviderFromPackId(packId: string | undefined): DomainLibraryCategory | undefined { - if (!packId) { - return undefined; - } - - const normalizedPackId = packId.toLowerCase(); - const match = PROVIDER_OPTIONS.find((option) => normalizedPackId.includes(option.value)); - return match?.value as DomainLibraryCategory | undefined; -} - function getProviderLabel(provider: DomainLibraryCategory): string { return getAssetCategoryDisplayName(provider); } @@ -103,7 +94,7 @@ export const IconPicker: React.FC = ({ ); const [provider, setProvider] = useState( selectedProvider - ?? inferProviderFromPackId(selectedProviderPackId) + ?? inferAssetProviderFromPackId(selectedProviderPackId) ?? (PROVIDER_OPTIONS[0]?.value as DomainLibraryCategory) ?? 'aws' ); @@ -117,7 +108,7 @@ export const IconPicker: React.FC = ({ setProvider(selectedProvider); return; } - const inferredProvider = inferProviderFromPackId(selectedProviderPackId); + const inferredProvider = inferAssetProviderFromPackId(selectedProviderPackId); if (inferredProvider) { setProvider(inferredProvider); } diff --git a/src/components/properties/NodeProperties.test.tsx b/src/components/properties/NodeProperties.test.tsx index 5cd9121b..81d5acda 100644 --- a/src/components/properties/NodeProperties.test.tsx +++ b/src/components/properties/NodeProperties.test.tsx @@ -58,4 +58,26 @@ describe('NodeProperties', () => { expect(screen.getByText('Secondary Style')).toBeTruthy(); expect(screen.queryByRole('button', { name: 'Text Style' })).toBeNull(); }); + + it('uses the shared icon picker for icon-backed asset nodes', () => { + render( + + ); + + expect(screen.getByRole('button', { name: 'Icon' })).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('icon-picker')).toBeTruthy(); + }); }); diff --git a/src/components/properties/NodeProperties.tsx b/src/components/properties/NodeProperties.tsx index b567ce27..03ef3a74 100644 --- a/src/components/properties/NodeProperties.tsx +++ b/src/components/properties/NodeProperties.tsx @@ -9,20 +9,19 @@ import { IconPicker, type ProviderIconSelection } from './IconPicker'; import { ImageUpload } from './ImageUpload'; import { CollapsibleSection } from '../ui/CollapsibleSection'; import { useMarkdownEditor } from '@/hooks/useMarkdownEditor'; -import { useAssetCatalog } from '@/hooks/useAssetCatalog'; import { NodeActionButtons } from './NodeActionButtons'; import { NodeContentSection } from './NodeContentSection'; import { NodeImageSettingsSection } from './NodeImageSettingsSection'; import { NodeWireframeVariantSection } from './NodeWireframeVariantSection'; import { InspectorSectionDivider } from './InspectorPrimitives'; -import { Tooltip } from '../Tooltip'; -import { Select } from '../ui/Select'; import type { DomainLibraryCategory } from '@/services/domainLibrary'; -import { getAssetCategoryDisplayName, getAssetCategoryNoun } from '@/services/assetPresentation'; -import { loadProviderShapePreview } from '@/services/shapeLibrary/providerCatalog'; -import { NamedIcon } from '../IconMap'; -import { createPropertyInputKeyDownHandler } from './propertyInputBehavior'; -import { IconSearchField, IconTileScrollGrid } from './IconTilePickerPrimitives'; +import { getAssetCategoryDisplayName } from '@/services/assetPresentation'; +import { + createBuiltInIconData, + createProviderIconData, + createUploadedIconData, + normalizeNodeIconData, +} from '@/lib/nodeIconState'; import { getNodeParentId } from '@/lib/nodeParent'; import { buildSectionActions } from './sectionActionBuilder'; @@ -52,8 +51,13 @@ export const NodeProperties: React.FC = ({ const isSection = selectedNode.type === 'section'; const isGroup = selectedNode.type === 'group'; const isWireframeApp = selectedNode.type === 'browser' || selectedNode.type === 'mobile'; - const isIconAssetNode = selectedNode.data?.assetPresentation === 'icon'; - const assetProvider = (selectedNode.data?.assetProvider || '') as DomainLibraryCategory; + const normalizedIconData = normalizeNodeIconData(selectedNode.data); + const isIconAssetNode = normalizedIconData?.assetPresentation === 'icon'; + const assetProvider = normalizedIconData?.assetProvider as DomainLibraryCategory | undefined; + const assetCategory = + typeof normalizedIconData?.assetCategory === 'string' + ? normalizedIconData.assetCategory + : undefined; const supportsAdvancedColorTheme = ['process', 'start', 'end', 'decision', 'custom'].includes( selectedNode.type || '' ); @@ -73,34 +77,10 @@ export const NodeProperties: React.FC = ({ onChange, }); - const { - items: assetItems, - filteredItems: filteredAssetItems, - previewUrls: assetPreviewUrls, - query: assetQuery, - setQuery: setAssetQuery, - category: assetFilterCategory, - setCategory: setAssetFilterCategory, - } = useAssetCatalog({ - provider: assetProvider, - }); - const assetCategories = React.useMemo( - () => - Array.from( - new Set( - assetItems - .map((item) => item.providerShapeCategory) - .filter((value): value is string => Boolean(value)) - ) - ).sort((left, right) => left.localeCompare(right)), - [assetItems] - ); - const handlePropertyInputKeyDown = createPropertyInputKeyDownHandler({ blurOnEnter: true }); - function getDefaultSection(): string { if (isImage) return 'image'; if (isWireframeApp) return 'variant'; - if (isIconAssetNode) return 'asset'; + if (isIconAssetNode) return 'icon'; if (isSection) return 'content'; if (isText || isAnnotation) return 'content'; return 'content'; @@ -155,36 +135,23 @@ export const NodeProperties: React.FC = ({ } function handleBuiltInIconChange(icon: string): void { - onChange(selectedNode.id, { - icon, - customIconUrl: undefined, - archIconPackId: undefined, - archIconShapeId: undefined, - assetProvider: undefined, - assetCategory: undefined, - }); + onChange(selectedNode.id, createBuiltInIconData(icon)); } function handleProviderIconChange(selection: ProviderIconSelection): void { - onChange(selectedNode.id, { - icon: undefined, - customIconUrl: undefined, - archIconPackId: selection.packId, - archIconShapeId: selection.shapeId, - assetProvider: selection.provider, - assetCategory: selection.category, - }); + onChange( + selectedNode.id, + createProviderIconData({ + packId: selection.packId, + shapeId: selection.shapeId, + provider: selection.provider, + category: selection.category, + }) + ); } function handleCustomIconChange(url?: string): void { - onChange(selectedNode.id, { - icon: undefined, - customIconUrl: url, - archIconPackId: undefined, - archIconShapeId: undefined, - assetProvider: undefined, - assetCategory: undefined, - }); + onChange(selectedNode.id, createUploadedIconData(url)); } return ( @@ -231,84 +198,6 @@ export const NodeProperties: React.FC = ({ /> )} - {isIconAssetNode && ( - } - isOpen={activeSection === 'asset' || activeSection === 'shape'} - onToggle={() => toggleSection('asset')} - > -
- setAssetQuery(event.target.value)} - onKeyDown={handlePropertyInputKeyDown} - placeholder={`Search ${getAssetCategoryDisplayName(assetProvider || 'icons').toLowerCase()} ${getAssetCategoryNoun(assetProvider || 'icons')}`} - /> - {assetCategories.length > 1 ? ( -
🧑‍💻 Code → Diagram
SQL · Terraform · K8s
OpenAPI · Source code
✨ Mermaid → Icons
Paste Mermaid · 1,100+ icons
auto-assigned · beautiful
🤖 AI Generation
9 providers · BYOK
Direct-to-canvas output
`{}` Diagram as Code
Bidirectional live sync
Git-friendly DSL
🧩 Asset Libraries
Developer · AWS · Azure
GCP · CNCF · Icons
- + @@ -112,8 +112,8 @@ OpenFlowKit is the **only MIT-licensed tool** that combines a real workspace hom | Visual canvas editor | ✅ | ✅ | ✅ | ❌ | ✅ | | Bidirectional diagram-as-code | ✅ | ❌ | ❌ | ✅ | ❌ | | AI generation (9 providers) `Beta` | ✅ | ❌ | ❌ | ❌ | Limited | -| Mermaid import (7 types) | ✅ | ❌ | ⚠️ | ✅ | ❌ | -| Auto-icon assignment (1,100+) | ✅ | ❌ | ❌ | ❌ | ❌ | +| Mermaid import (8 types) | ✅ | ❌ | ⚠️ | ✅ | ❌ | +| Auto-icon assignment (1,600+) | ✅ | ❌ | ❌ | ❌ | ❌ | | AWS / Azure / GCP / CNCF icons | ✅ | ❌ | ✅ | Partial | ✅ | | Real-time collaboration (P2P) `Beta` | ✅ | ✅ | ❌ | ❌ | ✅ (cloud) | | Cinematic animated export | ✅ | ❌ | ❌ | ❌ | ❌ | @@ -125,7 +125,7 @@ OpenFlowKit is the **only MIT-licensed tool** that combines a real workspace hom ## Paste Mermaid → Beautiful Diagrams -Paste any Mermaid flowchart, state diagram, class diagram, ER diagram, sequence diagram, mindmap, or journey. OpenFlowKit renders it on a visual canvas — and automatically assigns the correct branded icon to every technology node. +Paste any Mermaid flowchart, architecture, state diagram, class diagram, ER diagram, sequence diagram, mindmap, or journey — all 8 diagram families. OpenFlowKit renders it on a visual canvas and automatically assigns the correct branded icon to every technology node. ``` flowchart TD @@ -136,7 +136,7 @@ flowchart TD Paste this → you get the Express wordmark, PostgreSQL elephant, Redis logo, and RabbitMQ icon — all auto-detected, all beautifully laid out. No other tool does this. -**1,100+ icons** from developer, AWS, Azure, CNCF, and GCP catalogs are matched automatically based on node labels. No manual drag-and-drop. No configuration. +**1,600+ icons** from developer, AWS, Azure, CNCF, and GCP catalogs are matched automatically based on node labels. No manual drag-and-drop. No configuration. ### How it works @@ -177,17 +177,17 @@ Flowpilot sits directly in the editor. Describe a system, paste source code, upl **9 providers. Bring your own key. Switch any time.** -| Provider | Default model | Why use it | -| ------------------- | -------------------------------- | ----------------------------------------------- | -| Google Gemini | `gemini-2.5-flash-lite` | Free tier available, fast, browser-safe | -| OpenAI | `gpt-5-mini` | Best reasoning for complex architectures | -| Anthropic Claude | `claude-sonnet-4-6` | Excellent code and system understanding | -| Groq | `llama-4-scout-17b-16e-instruct` | Fastest inference available | -| Mistral | `mistral-medium-latest` | Strong European privacy-first alternative | -| NVIDIA NIM | `llama-4-scout-17b-16e-instruct` | Enterprise GPU inference | -| Cerebras | `gpt-oss-120b` | Fastest on WSE-3 silicon | -| OpenRouter | `google/gemini-2.5-flash` | Access 100+ models through one key | -| **Custom endpoint** | Any model | Ollama, LM Studio, or any OpenAI-compatible API | +| Provider | Default model | Why use it | +| ------------------- | ------------------------------------------ | ----------------------------------------------- | +| Google Gemini | `gemini-2.5-flash-lite` | Free tier available, fast, browser-safe | +| OpenAI | `gpt-5-mini` | Best reasoning for complex architectures | +| Anthropic Claude | `claude-sonnet-4-6` | Excellent code and system understanding | +| Groq | `meta-llama/llama-4-scout-17b-16e-instruct`| Fastest open-source inference available | +| Mistral | `mistral-large-latest` | Strong European privacy-first alternative | +| NVIDIA NIM | `meta/llama-4-maverick-17b-128e-instruct` | Enterprise GPU inference | +| Cerebras | `gpt-oss-120b` | Ultra-fast on WSE-3 silicon | +| OpenRouter | `google/gemini-2.5-pro` | Access 300+ models through one key | +| **Custom endpoint** | Any model | Ollama, LM Studio, or any OpenAI-compatible API | No proxy. No middleman. Direct browser-to-provider requests. @@ -251,7 +251,7 @@ Designed for architecture reviews, onboarding docs, and demos where a static ima > **No other open-source diagramming tool does this.** -Export as **WebM**, control animation speed, and share a link or embed it anywhere. +Export as **WebM or MP4** (browser-native, no codec install needed), control animation speed, and share or embed anywhere. --- @@ -259,7 +259,7 @@ Export as **WebM**, control animation speed, and share a link or embed it anywhe Build your diagram once. Take it anywhere. -- **🎬 Cinematic MP4 / GIF** — animated walkthrough, browser-only, no upload required +- **🎬 Cinematic WebM / MP4** — animated build walkthrough, browser-only, no upload required - **PNG / SVG** — transparent background, pixel-perfect at any resolution - **PDF** — print-ready, vector-crisp - **Mermaid** — paste directly into GitHub READMEs, Notion, Confluence, Linear @@ -294,7 +294,7 @@ Plus: smart alignment guides, snap-to-grid, multi-select, pages, layers, section Current roadmap focus: -- **GIF export for cinematic animations** — WebM is shipping now; GIF export coming next so diagrams can be embedded anywhere without conversion +- **GIF export for cinematic animations** — WebM/MP4 ship today; GIF export for zero-conversion embeds is next - **GitHub repo → diagram** — currently in beta internally; will ship when output quality is consistent across real-world codebases - better layers and page workflows for larger technical diagrams - stronger code and structured-import diagram quality diff --git a/src/config/aiProviders.ts b/src/config/aiProviders.ts index 86093b56..3a6b6eb5 100644 --- a/src/config/aiProviders.ts +++ b/src/config/aiProviders.ts @@ -14,10 +14,10 @@ export const DEFAULT_MODELS: Record = { openai: 'gpt-5-mini', claude: 'claude-sonnet-4-6', groq: 'meta-llama/llama-4-scout-17b-16e-instruct', - nvidia: 'meta/llama-4-scout-17b-16e-instruct', + nvidia: 'meta/llama-4-maverick-17b-128e-instruct', cerebras: 'gpt-oss-120b', - mistral: 'mistral-medium-latest', - openrouter: 'google/gemini-2.5-flash', + mistral: 'mistral-large-latest', + openrouter: 'google/gemini-2.5-pro', custom: 'gpt-4o', }; From 140cedba79a01383007bf70e363e0cc2ac8002be Mon Sep 17 00:00:00 2001 From: Varun Date: Wed, 8 Apr 2026 22:35:22 +0530 Subject: [PATCH 7/9] docs: move internal audit docs to docs/internal/ Co-Authored-By: Claude Sonnet 4.6 --- GROUPS_AUDIT.md | 267 ------ GROUPS_IMPLEMENTATION_LOG.md | 46 - MERMAID_IMPORT_AUDIT.md | 1209 -------------------------- MERMAID_IMPORT_IMPLEMENTATION_LOG.md | 119 --- 4 files changed, 1641 deletions(-) delete mode 100644 GROUPS_AUDIT.md delete mode 100644 GROUPS_IMPLEMENTATION_LOG.md delete mode 100644 MERMAID_IMPORT_AUDIT.md delete mode 100644 MERMAID_IMPORT_IMPLEMENTATION_LOG.md diff --git a/GROUPS_AUDIT.md b/GROUPS_AUDIT.md deleted file mode 100644 index af8373ac..00000000 --- a/GROUPS_AUDIT.md +++ /dev/null @@ -1,267 +0,0 @@ -# Groups & Sections — Audit & Fix Plan -**Date:** 2026-04-07 - ---- - -## The Three Problems - -1. **Nodes can't escape groups** — once inside a group, you're trapped -2. **Nodes overlap inside groups** — layout doesn't space children properly -3. **Groups don't resize correctly** — wrong padding, too small, floating header ignored - -All three have clear root causes in the code. None require architectural changes. - ---- - -## Root Causes - -### Problem 1: Can't Escape Groups - -**File:** `src/lib/nodeParent.ts:15-21` - -`setNodeParent()` unconditionally sets `extent: 'parent'` on every parented node: - -```typescript -export function setNodeParent(node: T, parentId: string): T { - return { - ...node, - parentId, - extent: 'parent' as const, // always set — physically locks node in React Flow - } as T; -} -``` - -React Flow enforces `extent: 'parent'` at the DOM level during drag. There's no escape hatch. `clearNodeParent()` exists but is only called in specific delete/ungroup scenarios — not when a user drags to the boundary. - -The drag-stop handler (`useNodeDragOperations.ts:98-180`) calls `applySectionParenting()` which re-parents nodes when they land inside a section, but never un-parents them when they land outside. So moving outward just snaps back. - -**Also:** Mermaid-parsed subgraph children get `extent: 'parent'` immediately at parse time (`mermaidParser.ts:273`) — before the user ever interacts. - ---- - -### Problem 2: Nodes Overlap Inside Groups - -**Three compounding causes:** - -**2A. ELK doesn't lay out children of parent nodes** - -`src/services/elkLayout.ts:104-112` — `buildElkNode()` sets `width` and `height` to `undefined` for nodes that have children: - -```typescript -return { - id: node.id, - width: children.length === 0 ? width : undefined, // undefined for groups - height: children.length === 0 ? height : undefined, // undefined for groups - children: children.map(...), - layoutOptions: { 'elk.padding': '[top=40,left=20,bottom=20,right=20]' }, -}; -``` - -ELK receives a group node with `width/height = undefined` and children that also have their own positions. ELK auto-sizes the group to wrap the children, but it **doesn't re-layout the children** — it just wraps around wherever they are. If children have positions from a previous pass or default to 0,0, they all stack at the top-left. - -**2B. ELK outputs absolute coordinates, React Flow expects relative** - -`src/services/elkLayout.ts:164-196` — `buildPositionMap()` collects positions as **absolute canvas coordinates** from ELK. But React Flow requires child nodes to have **positions relative to their parent's top-left corner**. - -The apply step (`elkLayout.ts:482`) uses these coordinates as-is: -```typescript -position: { x: position.x, y: position.y }, // absolute, not relative to parent -``` - -So a child at absolute position (500, 300) inside a parent at (400, 200) gets placed at (500, 300) relative to the parent — which puts it at absolute position (900, 500) on the canvas. Completely wrong. - -**2C. No compound layout options in ELK** - -`src/services/elk-layout/options.ts:91-144` — ELK config has no hierarchical layout options: -- No `elk.hierarchyHandling` -- No per-group layout direction -- No `elk.separateConnectedComponents` - -ELK is configured as a flat graph layouter, not a compound graph layouter. - ---- - -### Problem 3: Groups Don't Resize Correctly - -**3A. Padding mismatch** - -ELK padding (set in `elkLayout.ts:110`): `top=40, left=20, bottom=20, right=20` -Section rendering padding (`sectionBounds.ts:5-11`): `SECTION_PADDING_X = 32`, `SECTION_PADDING_BOTTOM = 32` -Global ELK padding (`options.ts:190`): `top=50, left=50, bottom=50, right=50` - -Three different values in three different places. ELK sizes the group with 20px padding, the UI renders with 32px padding, and the hit-testing uses 32px. Every calculation is off. - -**3B. Floating header not in ELK's model** - -`src/components/SectionNode.tsx:46-97` — the section title floats **above** the border with `top: -36`. ELK doesn't know about this. Children get positioned without the 36px header offset, so they render under the title. - -**3C. Mermaid-parsed sections start undersized** - -`src/lib/mermaidParser.ts:267`: sections created with `style: { width: 400, height: 300 }`. -`src/hooks/node-operations/sectionBounds.ts`: `SECTION_MIN_WIDTH = 500`, `SECTION_MIN_HEIGHT = 400`. - -Parsed sections start smaller than the minimum. The first render always looks cramped. - -**3D. `fitSectionToChildren()` is reactive, not automatic** - -`sectionOperations.ts:25-85` — `fitSectionToChildren()` only runs when explicitly called (after drag-drop). It doesn't run after ELK layout. So after a Mermaid import, sections aren't fitted — children sit inside an undersized parent. - ---- - -## Fix Plan - -### Fix 1 — Allow Nodes to Escape Groups (drag to boundary = exit) - -**File:** `src/hooks/node-operations/useNodeDragOperations.ts` + `sectionOperations.ts` - -In `onNodeDragStop()`, after the drag ends: -1. Check if the dragged node's **drop position is outside its current parent's bounds** -2. If yes → call `clearNodeParent(node)` to remove `parentId` and `extent` -3. If no → current `applySectionParenting()` logic stays the same - -The check: use `getSectionContentBounds(parentSection, allNodes)` and test if the node's center is outside it. If outside → un-parent. - -Also fix mermaid-parsed nodes: don't set `extent: 'parent'` at parse time. Set it only when the node is actually placed inside a section during layout (`mermaidParser.ts:273`). Let ELK position them, then apply parenting after layout positions are resolved. - -**What this enables:** drag a node to the edge of a group and release → it pops out. The group auto-resizes to exclude it. - ---- - -### Fix 2 — Fix Child Positioning (absolute → relative conversion) - -**File:** `src/services/elkLayout.ts` — `buildPositionMap()` and the apply step - -ELK outputs absolute coordinates. React Flow needs relative. The fix is to subtract the parent's absolute position from each child's position: - -```typescript -// After collecting positionMap from ELK: -for (const node of nodes) { - if (node.parentId) { - const parentPos = positionMap.get(node.parentId); - const childPos = positionMap.get(node.id); - if (parentPos && childPos) { - positionMap.set(node.id, { - ...childPos, - x: childPos.x - parentPos.x, - y: childPos.y - parentPos.y, - }); - } - } -} -``` - -This needs to handle nested parents recursively (grandchild positions need both parent and grandparent subtracted). Use a depth-first traversal: process parents before children, accumulate offset. - ---- - -### Fix 3 — Enable ELK Compound Layout for Groups - -**File:** `src/services/elkLayout.ts` — `buildElkNode()` - -Add ELK hierarchical layout options to group nodes: - -```typescript -// For nodes with children: -layoutOptions: { - 'elk.padding': `[top=${SECTION_HEADER_HEIGHT + SECTION_PADDING_TOP},left=${SECTION_PADDING_X},bottom=${SECTION_PADDING_BOTTOM},right=${SECTION_PADDING_X}]`, - 'elk.algorithm': 'layered', // lay out children with layered algorithm - 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', // treat group as compound node - 'elk.direction': resolvedDirection, // inherit diagram direction -}, -``` - -With `elk.hierarchyHandling: INCLUDE_CHILDREN`, ELK will actually space children inside the group instead of wrapping around their existing positions. - ---- - -### Fix 4 — Unify Padding Constants - -**Files:** `src/services/elkLayout.ts`, `src/services/elk-layout/options.ts`, `src/hooks/node-operations/sectionBounds.ts` - -Import `SECTION_PADDING_X`, `SECTION_PADDING_BOTTOM`, `SECTION_HEADER_HEIGHT` from `sectionBounds.ts` into the ELK layout files. Use these constants everywhere instead of hardcoded numbers. - -```typescript -// In elkLayout.ts, replace: -'elk.padding': '[top=40,left=20,bottom=20,right=20]' - -// With: -import { SECTION_PADDING_X, SECTION_PADDING_BOTTOM, SECTION_HEADER_HEIGHT } from '@/hooks/node-operations/sectionBounds'; -// top = header height (36) + content top padding (16) -`'elk.padding': '[top=${SECTION_HEADER_HEIGHT + 16},left=${SECTION_PADDING_X},bottom=${SECTION_PADDING_BOTTOM},right=${SECTION_PADDING_X}]'` -``` - -Single source of truth. Change it once, applies everywhere. - ---- - -### Fix 5 — Fix Initial Section Size from Mermaid - -**File:** `src/lib/mermaidParser.ts:267` - -Change initial section dimensions to match the actual minimums: - -```typescript -// Before: -style: { width: 400, height: 300 } - -// After: -style: { width: SECTION_MIN_WIDTH, height: SECTION_MIN_HEIGHT } -``` - -Import `SECTION_MIN_WIDTH`, `SECTION_MIN_HEIGHT` from `sectionBounds.ts`. - ---- - -### Fix 6 — Auto-fit Sections After ELK Layout - -**File:** wherever `getElkLayout()` result is applied (likely `useFlowCanvasPaste.ts` or equivalent) - -After ELK positions are applied to nodes, call `autoFitSectionsToChildren()` automatically. This resizes each section to wrap its children with correct padding — regardless of whether ELK got the size exactly right. - -```typescript -const { nodes: laidOutNodes, edges: laidOutEdges } = await getElkLayout(enrichedNodes, edges, options); -const fittedNodes = autoFitSectionsToChildren(laidOutNodes); // add this -setNodes(fittedNodes); -setEdges(laidOutEdges); -``` - -This is a safety net — even if ELK's group sizing is slightly off, the final result is always correctly fitted. - ---- - -## Fix Priority & Order - -Do in this order — each fix makes the next one safer: - -| # | Fix | File(s) | Risk | Impact | -|---|-----|---------|------|--------| -| 1 | Unify padding constants (Fix 4) | `elkLayout.ts`, `options.ts` | Low | Foundation for all sizing fixes | -| 2 | Fix initial mermaid section size (Fix 5) | `mermaidParser.ts` | Low | Sections no longer start undersized | -| 3 | Auto-fit after layout (Fix 6) | Import orchestration | Low | Sections always wrap children correctly | -| 4 | Absolute → relative coordinate conversion (Fix 2) | `elkLayout.ts` | Medium | Fixes overlap completely | -| 5 | ELK compound layout options (Fix 3) | `elkLayout.ts` | Medium | ELK properly spaces children | -| 6 | Allow escaping groups (Fix 1) | `useNodeDragOperations.ts`, `sectionOperations.ts`, `mermaidParser.ts` | Medium | Drag-out-of-group works | - ---- - -## Expected Result After All Fixes - -- Paste a mermaid diagram with subgraphs → children are spaced correctly inside groups, no overlap -- Drag a node to the edge of a group → it escapes, group shrinks to fit remaining children -- Groups always show the correct size — header visible, no children hidden under title -- Padding is consistent: ELK, hit-testing, and rendering all use the same numbers -- Section node titles never overlap with children - ---- - -## Files Touch Map - -| File | What Changes | -|---|---| -| `src/lib/nodeParent.ts` | Don't set `extent: 'parent'` at parse time; set only post-layout | -| `src/lib/mermaidParser.ts` | Use `SECTION_MIN_WIDTH/HEIGHT` for initial sizes; remove `extent` from parse | -| `src/services/elkLayout.ts` | Absolute→relative coordinate fix; compound layout options; import shared padding | -| `src/services/elk-layout/options.ts` | Use shared padding constants | -| `src/hooks/node-operations/useNodeDragOperations.ts` | Detect drag-outside-parent, call `clearNodeParent` | -| `src/hooks/node-operations/sectionOperations.ts` | Call `autoFitSectionsToChildren` after layout | -| `src/hooks/node-operations/sectionBounds.ts` | Export constants for reuse (may already export) | diff --git a/GROUPS_IMPLEMENTATION_LOG.md b/GROUPS_IMPLEMENTATION_LOG.md deleted file mode 100644 index 9f249c81..00000000 --- a/GROUPS_IMPLEMENTATION_LOG.md +++ /dev/null @@ -1,46 +0,0 @@ -# Groups Implementation Log - -Date: 2026-04-07 - -## Implemented - -- Made parent assignment opt-in for `extent: 'parent'` in [src/lib/nodeParent.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/nodeParent.ts), so grouped nodes are no longer physically trapped by default. -- Updated Mermaid and OpenFlow DSL import paths to preserve `parentId` without forcing parent drag constraints in [src/lib/mermaidParser.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParser.ts) and [src/lib/flowmindDSLParserV2.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/flowmindDSLParserV2.ts). -- Aligned Mermaid-created section sizes with the real section minimums in [src/lib/mermaidParser.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParser.ts). -- Unified ELK root/container padding with the section bounds model and enabled compound hierarchy handling in [src/services/elk-layout/options.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/elk-layout/options.ts) and [src/services/elkLayout.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/elkLayout.ts). -- Added explicit ELK absolute-to-relative position normalization for parented nodes in [src/services/elkLayout.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/elkLayout.ts). -- Centralized post-layout section auto-fit in [src/services/composeDiagramForDisplay.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/composeDiagramForDisplay.ts) so imports and other composed layouts get the same safety net. -- Routed Mermaid paste imports through the shared display composition path in [src/components/flow-canvas/useFlowCanvasPaste.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/flow-canvas/useFlowCanvasPaste.ts). -- Added focused coverage for parser sizing/parenting and ELK parent-relative layout conversion in [src/services/mermaidParser.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaidParser.test.ts), [src/hooks/node-operations/utils.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/hooks/node-operations/utils.test.ts), and [src/services/elkLayout.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/elkLayout.test.ts). - -## Plan Refinements - -- Did not add a separate drag-stop escape path in `useNodeDragOperations.ts`. - Reason: the audit was stale on this point. The real trap was unconditional `extent: 'parent'`, while `applySectionParenting()` already knows how to unparent on drop outside a section once movement is no longer DOM-constrained. - -- Did not model a larger floating-header offset in ELK. - Reason: the current source of truth in `sectionBounds.ts` uses `SECTION_CONTENT_PADDING_TOP = SECTION_HEADER_HEIGHT = 16`, not the older `-36` header model described in the audit. Reusing the actual shared constants is safer than reintroducing stale geometry assumptions. - -- Applied section auto-fit at the shared display composition layer instead of only Mermaid paste. - Reason: this makes grouped layout reliability consistent across Mermaid import, code-panel apply flows, and any other caller that uses composed display layout. - -- Extended the import parenting change to the OpenFlow DSL parser as well. - Reason: the groups reliability issue is not Mermaid-specific. Keeping grouped imports on the same unconstrained parent model avoids inconsistent behavior between import paths. - -## Why These Decisions Improved Reliability - -- Removing unconditional parent constraints fixes the escape problem at the root cause instead of layering special-case drag logic on top. -- Shared padding/min-size constants eliminate geometry drift between hit testing, section fitting, and ELK. -- Converting ELK absolute child coordinates back to parent-relative React Flow coordinates fixes the main grouped-layout correctness bug. -- Centralized post-layout fitting gives a deterministic last-mile correction even when ELK sizing is slightly conservative. -- Reusing the same composition path for imports reduces divergence and makes future layout fixes apply everywhere. - -## Simplification Pass - -- Ran a code simplification/cleanup pass over the touched files after implementation. -- Applied Prettier formatting to the modified files to keep the new helpers and tests consistent with repo style. - -## Validation - -- `pnpm vitest run src/services/mermaidParser.test.ts src/hooks/node-operations/utils.test.ts src/services/elkLayout.test.ts` -- `pnpm exec tsc --noEmit` diff --git a/MERMAID_IMPORT_AUDIT.md b/MERMAID_IMPORT_AUDIT.md deleted file mode 100644 index 10f4033b..00000000 --- a/MERMAID_IMPORT_AUDIT.md +++ /dev/null @@ -1,1209 +0,0 @@ -# Mermaid Import Audit, Research, and Execution Plan -**Date:** 2026-04-08 -**Product Goal:** Import Mermaid diagrams reliably, make them fully editable, make them look excellent, and use icons only when they are genuinely correct. -**Standard:** We should aim to be as reliable as Mermaid for supported syntax, and better than Mermaid in editability, diagnostics, and visual polish. - ---- - -## Executive Summary - -OpenFlowKit is not currently using Mermaid's official parser/runtime for import. We use a custom Mermaid detection and plugin parsing pipeline that converts Mermaid text into our own editable graph model. - -That is not automatically wrong. In fact, for a product whose output must be editable canvas nodes, edges, sections, handles, icons, and property panels, a custom parser is a valid and often necessary architecture. - -The real problem is not "custom parser vs official parser" in isolation. The real problem is that we currently have: - -- a custom editable import pipeline -- no official Mermaid parser installed as a validator or compatibility oracle -- selective support for eight Mermaid families -- uneven syntax coverage between those families -- good diagnostics in many places, but no systematic compatibility benchmarking against official Mermaid -- post-parse enrichment and layout logic that can improve the output, but can also reduce trust when it overreaches - -My strongest recommendation is: - -1. Keep the custom editable parser pipeline. -2. Add official Mermaid as a syntax oracle and compatibility gate. -3. Treat import as a two-layer system: - - Layer A: official Mermaid compatibility validation - - Layer B: OpenFlowKit editable AST conversion -4. Define a strict "supported editable subset" per diagram family. -5. Add a fallback mode for valid Mermaid we cannot yet map cleanly into editable nodes. - -That gives us the best chance of becoming: - -- as reliable as Mermaid for accepted syntax -- better than Mermaid at editability -- better than many competitors at diagnostics and visual outcomes -- less annoying than current auto-enrichment behavior - ---- - -## Short Answer to the Core Question - -### Do we currently have the official Mermaid parser in the app? - -No. - -Evidence: - -- [package.json](/Users/varun/Desktop/Dev_projects/flowmind-ai/package.json) does not include a `mermaid` dependency. -- Mermaid import enters through [parseMermaidByType.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/parseMermaidByType.ts). -- That dispatcher routes to our plugin registry in [builtInPlugins.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/builtInPlugins.ts). -- Flowchart and part of state parsing ultimately rely on our local parser in [mermaidParser.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParser.ts). - -### Should we replace our parser with the official Mermaid parser? - -No, not as a full replacement. - -The official Mermaid stack is optimized to parse and render Mermaid diagrams. Our product needs to parse, normalize, enrich, lay out, and convert Mermaid into an editable internal graph model. That requires product-specific structure that Mermaid does not directly provide as a ready-made editable canvas AST. - -### Should we still use the official Mermaid parser? - -Yes. - -We should use official Mermaid for: - -- syntax validation -- compatibility benchmarking -- diagram type confirmation -- regression corpus testing -- fallback behavior for valid Mermaid we cannot yet edit faithfully - -That hybrid model is the strongest path. - ---- - -## Current Architecture in This Repo - -### Current Import Flow - -For paste/import, the current path is effectively: - -1. Detect diagram type via [detectDiagramType.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/detectDiagramType.ts) -2. Dispatch by family via [parseMermaidByType.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/parseMermaidByType.ts) -3. Parse through a custom plugin: - - `flowchart` - - `stateDiagram` - - `classDiagram` - - `erDiagram` - - `mindmap` - - `journey` - - `architecture` - - `sequence` -4. Enrich nodes via [nodeEnricher.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/nodeEnricher.ts) -5. Normalize icon state via `nodeIconState` -6. Compose layout and smart handles in [useFlowCanvasPaste.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/flow-canvas/useFlowCanvasPaste.ts) - -### Important Architectural Strength - -This architecture is already aligned with an editable-diagram product: - -- parsing is family-aware -- diagnostics are often surfaced without hard-failing -- result is converted to product-native nodes and edges -- import already feeds directly into editable canvas state - -This is the right overall shape for "editable Mermaid import." - -### Important Architectural Gap - -We currently do not have an external source of truth validating whether: - -- the Mermaid input is valid according to official Mermaid -- our type detection is correct -- our parser is silently under-parsing valid Mermaid -- our diagnostics match real Mermaid behavior -- a failing import is due to invalid Mermaid or our unsupported editable subset - -That makes trust weaker than it should be. - ---- - -## What the Codebase Is Good At Today - -### 1. Editable-native import - -This is the biggest advantage of the current approach. - -We do not just render Mermaid. We convert it into: - -- editable nodes -- editable edges -- editable sections/containers -- product-specific node types -- downstream layout and icon enrichment - -Many Mermaid-capable tools stop at "render from text." We already go further. - -### 2. Plugin-per-family architecture - -The family plugin model is a strong foundation: - -- [stateDiagram/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/stateDiagram/plugin.ts) -- [sequence/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/sequence/plugin.ts) -- [classDiagram/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/classDiagram/plugin.ts) -- [erDiagram/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/erDiagram/plugin.ts) -- [mindmap/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/mindmap/plugin.ts) -- [journey/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/journey/plugin.ts) -- [architecture/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/architecture/plugin.ts) - -That makes targeted fixes possible without a full rewrite. - -### 3. Diagnostics instead of just throwing - -Multiple plugins emit warnings and continue parsing where possible. That is good product behavior because users care about import usefulness, not parser purity. - -### 4. Supported family scope is explicit - -[parseMermaidByType.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/parseMermaidByType.ts) clearly defines supported editable families. That is better than pretending full Mermaid support and failing unpredictably. - -### 5. Conservative icon work is already moving in the right direction - -The recent tightening in: - -- [nodeEnricher.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/nodeEnricher.ts) -- [iconMatcher.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/iconMatcher.ts) - -is the right direction. "No icon" is better than a wrong icon. - ---- - -## Where the Codebase Is Not Good Enough Yet - -## 1. No official Mermaid compatibility oracle - -This is the largest strategic gap. - -Without official Mermaid in the loop: - -- we cannot distinguish "invalid Mermaid" from "valid Mermaid we do not support" -- we cannot benchmark fidelity against Mermaid itself -- we are vulnerable to spec drift -- we lack a robust parser acceptance corpus tied to the upstream project - -## 2. Our flowchart parser is spec-shaped only in part - -[mermaidParser.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParser.ts) and [mermaidParserHelpers.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParserHelpers.ts) are pragmatic and useful, but they are still handwritten rule-based parsing. - -That means: - -- edge forms can be missed -- special syntax quirks can drift from Mermaid behavior -- new Mermaid syntax will not arrive automatically -- ambiguous lines are interpreted by our rules, not the official grammar - -## 3. Support is broad by family but not deep by syntax - -Current support covers the right high-level families, but several plugins still support only a practical subset of their official syntax. - -Examples visible in code and tests: - -- state diagrams still rely partly on generic parsing plus plugin augmentation -- journey parsing warns and skips malformed lines rather than fully modeling richer journey semantics -- class and ER diagrams parse enough for editability, but not full language richness -- sequence diagrams parse structured messages, but the rendering/edit model still has room to better represent fragments and advanced constructs - -## 4. Import trust is still fragile when enrichment is too opinionated - -Even after improvements, enrichment remains a product risk area. - -Users trust imports when: - -- the structure is correct -- the text is preserved -- icons are accurate when present -- absence of icon feels intentional, not broken - -Wrong icons are more damaging than missing icons. - -## 5. We do not yet separate product guarantees clearly enough - -We need to explicitly define: - -- valid Mermaid -- supported Mermaid -- editable Mermaid -- render-only Mermaid -- unsupported Mermaid - -Right now those boundaries exist in code but are not formalized as product contracts. - ---- - -## External Research and Competitive Landscape - -## 1. Official Mermaid - -Official Mermaid's main strength is rendering Mermaid text into diagrams. The Mermaid documentation positions Mermaid.js as the JavaScript library that renders diagrams from text, and the Mermaid Live Editor as a text-first editor for writing and previewing Mermaid diagrams in real time. - -What this implies: - -- Mermaid itself is excellent as a syntax and rendering engine -- Mermaid is not, by default, an editable whiteboard/canvas product in the way OpenFlowKit is trying to be -- Mermaid's own live editor is code-first, not a full node-dragging GUI editor for canvas-native editing - -Source: -- Mermaid docs: https://mermaid.js.org/intro/getting-started.html - -## 2. Mermaid Chart - -Mermaid Chart explicitly describes the open-source Mermaid stack as having a limited editing experience because the live editor renders Mermaid code but has no GUI for adding nodes or dragging them without writing code. It positions its own product around visual editing, collaboration, enhanced design, and AI on top of Mermaid syntax. - -What this implies: - -- the market sees "editable Mermaid" as a meaningful upgrade over Mermaid itself -- visual editing on top of Mermaid is a real product category -- OpenFlowKit is directionally pursuing a real and valuable problem - -Source: -- Mermaid Chart blog: https://mermaid.ai/docs/blog/posts/mermaid-chart-the-evolution-of-mermaid - -## 3. Lucidchart - -Lucid's help content describes "Diagram as code with Mermaid in Lucidchart," which indicates Lucid treats Mermaid as an import/generation path into Lucidchart diagrams. - -What this implies: - -- enterprise diagram tools are using Mermaid as an ingestion format -- the product expectation is not just rendering but diagram creation within a richer editor -- OpenFlowKit is competing in a real workflow, not inventing a niche problem - -Source: -- Lucid help center article listing: https://help.lucid.co/hc/en-us/profiles/395718981451-Shanna-S - -## Research Takeaway - -There are roughly three classes of Mermaid products: - -1. Render-first - - Mermaid OSS - - best at syntax-to-rendering - -2. Visual-editing-on-top-of-Mermaid - - Mermaid Chart - - likely treats Mermaid as source plus enhanced editing surface - -3. Diagram-suite importers - - Lucidchart and similar tools - - treat Mermaid as one input into a broader diagramming system - -OpenFlowKit should compete as: - -- editable importer -- better diagnostics than render-only tools -- better visual polish than plain Mermaid -- more trustworthy than "magic conversion" tools - ---- - -## Strategic Product Positioning - -We should not try to beat Mermaid by becoming a better raw Mermaid renderer. - -We should try to beat Mermaid by being: - -- as syntax-compatible as possible for supported families -- more editable after import -- more visually polished after import -- more explicit and trustworthy about what was imported faithfully vs approximated - -This is a crucial difference. - -### Wrong target - -"Support every Mermaid feature exactly like Mermaid and also make everything editable immediately." - -That is expensive, brittle, and likely unrealistic. - -### Right target - -"For a clearly-defined supported subset, import with very high reliability and editability. For valid Mermaid outside that subset, fail gracefully or offer render-only/fallback behavior." - -That is shippable, honest, and strong. - ---- - -## Core Decision: What Should We Do About the Official Parser? - -## Recommendation - -Add official Mermaid as a dependency and use it in four roles. - -### Role 1: Syntax oracle - -Before editable conversion, validate the source with official Mermaid. - -Possible outcomes: - -- valid Mermaid, supported editable family -- valid Mermaid, unsupported editable family -- invalid Mermaid - -This gives much better diagnostics and product trust. - -### Role 2: Compatibility benchmark - -Use official Mermaid examples and acceptance cases as a test corpus. - -We should continuously answer: - -- does Mermaid accept this? -- do we accept it? -- if we accept it, do we preserve semantics faithfully? - -### Role 3: Fallback renderer or snapshot oracle - -For valid Mermaid that we cannot edit faithfully yet, give one of these paths: - -- render-only preview -- import with warning and partial editability -- offer "convert what is supported" vs "render as locked group" - -### Role 4: Type confirmation and future-proofing - -Use official Mermaid detection/parse behavior where possible to reduce drift from our own detection heuristics. - -## Recommendation Against Full Replacement - -Do not replace our editable parser pipeline with official Mermaid runtime as the main model source. - -Reason: - -- official Mermaid is not our internal node graph -- it does not directly solve our editable canvas modeling problem -- we still need semantic mapping into OpenFlowKit nodes, sections, icon metadata, property panels, and layout - -The right architecture is hybrid, not replacement. - ---- - -## Proposed Target Architecture - -```text -Mermaid Input - -> Pre-normalization - -> Official Mermaid validation - -> Diagram type detection confirmation - -> Editable support gate - -> if supported: family plugin conversion to OpenFlowKit AST - -> if partially supported: import with structured diagnostics - -> if valid but unsupported: render-only or locked fallback - -> Semantic enrichment - -> Layout - -> Editable canvas - -> Round-trip metadata and diagnostics -``` - -## New Contracts We Need - -Every Mermaid import should end in one of these explicit states: - -- `editable_full` -- `editable_partial` -- `render_only_valid` -- `invalid_source` -- `unsupported_family` -- `unsupported_construct` - -This contract should be visible in diagnostics and analytics. - ---- - -## Family-by-Family Audit - -## Flowchart - -### Strengths - -- Most mature path -- backed by the generic parser -- already editable -- compatible with current icon enrichment strategy - -### Risks - -- rule-based edge parsing is still vulnerable to syntax edge cases -- special token handling can drift from Mermaid -- some valid Mermaid flowchart features may be accepted differently than official Mermaid - -### Recommendation - -- use official Mermaid as acceptance oracle for flowchart corpus -- keep our editable flowchart parser -- expand parity tests aggressively - -## State Diagram - -### Strengths - -- plugin exists -- diagnostics are relatively strong -- notes and composite handling are partially modeled - -### Risks - -- relies in part on the generic parser, which is not truly state-diagram-native -- state semantics are richer than flowchart semantics -- initial/final states and advanced constructs need explicit fidelity rules - -### Recommendation - -- continue plugin-specific modeling -- reduce reliance on generic flowchart-like parsing -- benchmark against official stateDiagram examples - -## Class Diagram - -### Strengths - -- plugin exists -- block parsing and diagnostics exist - -### Risks - -- class syntax richness is high -- relationship semantics can outpace current editable model -- generics, visibility, annotations, stereotypes, and richer relationship metadata need careful mapping - -### Recommendation - -- formally define supported editable subset -- preserve unsupported tokens in metadata even when not visually modeled - -## ER Diagram - -### Strengths - -- entities and relations are modeled -- diagnostics exist - -### Risks - -- fields are not yet treated as rich schema objects to the extent needed for best-in-class editing -- key constraints and relation/cardinality fidelity matter a lot here - -### Recommendation - -- move from string-ish field handling to structured field AST -- make ER import one of the flagship "better than Mermaid" families - -## Mindmap - -### Strengths - -- relatively contained syntax -- good candidate for high reliability - -### Risks - -- indentation sensitivity can be brittle -- wrapper syntax and formatting need parity with Mermaid expectations - -### Recommendation - -- this should become one of the highest-confidence editable imports - -## Journey - -### Strengths - -- supported -- diagnostics exist - -### Risks - -- current editable semantics may undershoot user expectations around actors and scoring -- visual differentiation is important for usefulness - -### Recommendation - -- elevate journey beyond "parsed lines" into a richer native journey model - -## Sequence - -### Strengths - -- plugin exists -- good foundational parsing path - -### Risks - -- advanced sequence constructs are where many products become unreliable -- visual semantics matter a lot - -### Recommendation - -- define exact supported fragment subset -- add richer visual fidelity for fragments, notes, activations, and participant semantics - -## Architecture - -### Strengths - -- likely our best strategic family -- close fit with product value -- strict mode already exists - -### Risks - -- architecture users are the most sensitive to wrong icons and wrong semantics -- this family can look amazing or feel fake depending on icon quality - -### Recommendation - -- make architecture the gold-standard import family -- require highest trust threshold for icons -- consider provider-aware validation and stronger architecture linting - ---- - -## Icons: How We Should Think About Them - -Icons should never be treated as decoration during Mermaid import. - -Icons are semantic claims. - -If we add an AWS Lambda icon, we are claiming: - -- this node is Lambda -- not just "compute" -- not just "serverless" -- not just "some backend thing" - -That means our icon policy should be: - -- exact product match -> use icon -- strong alias match -> use icon -- trusted vendor/product compound match -> use icon -- generic concept -> do not use icon -- ambiguous concept -> do not use icon -- uncertain variant/wordmark match -> do not use icon - -Best practice for Mermaid import: - -- no icon is better than wrong icon -- structural defaults are okay for start/end/decision -- product icons must be earned, not guessed - -This matches the recent direction in: - -- [nodeEnricher.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/nodeEnricher.ts) -- [iconMatcher.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/iconMatcher.ts) - -### Additional Recommendation - -Persist skip reasons in import metadata: - -- `generic_term` -- `ambiguous_match` -- `runner_up_too_close` -- `variant_only` -- `no_trusted_candidate` - -Then surface those gently in the inspector rather than forcing icons silently. - ---- - -## Reliability Standard We Should Adopt - -If we want to be "as good as or better than Mermaid," we need to stop defining success as: - -"Did we create some nodes and edges?" - -We should define success across five dimensions: - -## 1. Syntax compatibility - -For supported families, official Mermaid accepts it and we accept it too. - -## 2. Semantic fidelity - -We preserve the meaning of: - -- node identity -- labels -- edge direction -- relationship type -- hierarchy -- family-specific semantics - -## 3. Editability - -After import, the user can actually work with the diagram as native OpenFlowKit content. - -## 4. Visual quality - -Layout, spacing, handles, typography, colors, and icons should improve the result rather than making it noisier. - -## 5. Honesty - -When we approximate or ignore something, we say so. - -This last one is how we beat many competitors in trust. - ---- - -## Recommended Roadmap - -## Phase 0: Product Contract and Instrumentation - -### Goal - -Define what "supported editable Mermaid" means. - -### Work - -- Add import result status enum -- Add structured diagnostics object, not just strings -- Add analytics for: - - valid official Mermaid / invalid official Mermaid - - editable full / editable partial / render-only - - family-level success rate - - icon assignment rate - - icon skip reason rate - -### Why first - -Without this, we cannot measure progress honestly. - -## Phase 1: Add Official Mermaid as Syntax Oracle - -### Goal - -Introduce official Mermaid without disrupting editable import. - -### Work - -- add `mermaid` dependency -- build a validation adapter service -- validate source before plugin conversion -- classify failures into: - - invalid Mermaid - - valid but unsupported family - - valid but unsupported construct - -### Output - -Users finally get trustworthy diagnostics. - -## Phase 2: Build a Compatibility Harness - -### Goal - -Measure ourselves against Mermaid continuously. - -### Work - -- create an upstream-style Mermaid fixture corpus -- import official Mermaid examples for supported families -- for each fixture, assert: - - Mermaid accepts it - - we accept it - - we preserve key semantics -- add snapshot tests for OpenFlowKit AST and visible output - -### Output - -A real compatibility scorecard instead of vibes. - -## Phase 3: Define Editable Subsets Per Family - -### Goal - -Stop pretending support is binary. - -### Work - -- publish per-family support matrix -- define: - - fully editable constructs - - partially editable constructs - - render-only constructs - - unsupported constructs - -### Output - -Clear engineering and product boundaries. - -## Phase 4: Strengthen Family Parsers - -### Goal - -Deepen syntax support where it matters most. - -### Priority order - -1. flowchart -2. architecture -3. sequence -4. stateDiagram -5. erDiagram -6. classDiagram -7. mindmap -8. journey - -### Why this order - -- flowchart is the broadest usage surface -- architecture is the highest strategic differentiator for OpenFlowKit -- sequence/state/ER/class are high-value technical diagrams - -## Phase 5: Fallback Modes - -### Goal - -Never force a bad editable conversion. - -### Work - -For valid Mermaid that we cannot edit faithfully: - -- offer render-only locked import -- or import with warnings and explicit unsupported markers -- preserve original Mermaid source in metadata - -### Output - -Trust improves because we stop pretending. - -## Phase 6: Visual Quality and Icon Excellence - -### Goal - -Make imports look polished without making them dishonest. - -### Work - -- keep strict sparse icon policy -- family-aware icon rules -- architecture-specific provider icon rules -- sequence/state/class/journey default to minimal iconing -- layout tuned per family and graph density -- typography and spacing presets by diagram family - -### Output - -Imported diagrams look upgraded, not over-decorated. - ---- - -## Concrete Engineering Recommendations - -## Recommendation A - -Add a new service: - -`src/services/mermaid/officialMermaidValidation.ts` - -Responsibilities: - -- run official Mermaid validation -- normalize validation result into our diagnostics format -- expose: - - `isValid` - - `detectedType` - - `errors` - - `unsupportedByOpenFlowKit` - -## Recommendation B - -Add an import compatibility report generator - -Suggested path: - -`scripts/mermaid-compat-report.mjs` - -Responsibilities: - -- run fixture corpus through official Mermaid and OpenFlowKit -- produce per-family compatibility stats -- output CI artifact - -## Recommendation C - -Add structured diagnostics model - -Current string diagnostics are useful, but we should move toward: - -```ts -type MermaidImportDiagnostic = { - code: string; - severity: 'info' | 'warning' | 'error'; - family?: string; - line?: number; - message: string; - officialMermaidAccepted?: boolean; - editableImpact?: 'none' | 'partial' | 'blocked'; -}; -``` - -## Recommendation D - -Store original Mermaid source with imported diagram metadata - -This enables: - -- re-validation -- re-import upgrades later -- compare/editability troubleshooting -- future round-trip tooling - -## Recommendation E - -Separate parsing from semantic decoration more cleanly - -The parser's job: - -- detect -- tokenize -- model structure -- preserve semantics - -The decoration layer's job: - -- layout -- icon enrichment -- color defaults -- visual polish - -These should remain cleanly separated. - ---- - -## Strong Suggestions and Non-Negotiables - -## 1. Do not make icon enrichment more aggressive again - -That would hurt trust. - -## 2. Do not market "full Mermaid support" - -Market: - -- "editable Mermaid import for supported diagram families" -- "strong diagnostics for unsupported or partially supported constructs" - -## 3. Add official Mermaid before broadening syntax claims - -Otherwise we will keep guessing at compatibility. - -## 4. Make architecture import best-in-class - -This is where OpenFlowKit can feel truly better than Mermaid: - -- provider-aware icons -- clearer structure -- better layout -- editable cloud/infra semantics - -## 5. Make fallback behavior a feature, not an embarrassment - -"Valid Mermaid, but not yet editable as native nodes. Imported as locked render with source preserved." - -That is much better than mangling a diagram. - ---- - -## Success Metrics - -We should start tracking these: - -- official-valid Mermaid acceptance rate by family -- editable-full import rate by family -- editable-partial import rate by family -- render-only fallback rate by family -- import diagnostic rate by category -- user correction rate after import -- manual icon override rate -- icon false-positive complaints -- round-trip retention rate for supported families - -Target standards: - -- flowchart editable-full on supported corpus: 95%+ -- architecture editable-full on supported corpus: 95%+ -- state/sequence/class/ER supported-corpus semantic fidelity: 90%+ -- wrong-icon rate on import: near zero - ---- - -## Final Recommendation - -OpenFlowKit should not try to become "Mermaid but with a different parser." - -OpenFlowKit should become: - -- Mermaid-compatible where it claims compatibility -- more editable than Mermaid -- more visually polished than Mermaid -- more honest than magical importers - -The winning strategy is: - -- keep our editable parser architecture -- add official Mermaid as a validation and compatibility layer -- formalize supported editable subsets -- introduce graceful fallback for valid-but-not-editable Mermaid -- continue strict sparse iconing -- measure fidelity against official Mermaid continuously - -That is the strongest path to being reliable, credible, and genuinely better for the user's actual workflow. - ---- - -## Source Links - -- Mermaid documentation: https://mermaid.js.org/intro/getting-started.html -- Mermaid Chart product/positioning blog: https://mermaid.ai/docs/blog/posts/mermaid-chart-the-evolution-of-mermaid -- Lucid help center profile listing showing Mermaid article: https://help.lucid.co/hc/en-us/profiles/395718981451-Shanna-S - -## Internal Code References - -- [package.json](/Users/varun/Desktop/Dev_projects/flowmind-ai/package.json) -- [parseMermaidByType.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/parseMermaidByType.ts) -- [detectDiagramType.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/detectDiagramType.ts) -- [mermaidParser.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParser.ts) -- [mermaidParserHelpers.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParserHelpers.ts) -- [useFlowCanvasPaste.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/flow-canvas/useFlowCanvasPaste.ts) -- [nodeEnricher.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/nodeEnricher.ts) -- [iconMatcher.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/iconMatcher.ts) -- [builtInPlugins.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/builtInPlugins.ts) - ---- - -## Implementation Log - -### Completed in this pass - -- Added explicit Mermaid import contract types in [importContracts.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/importContracts.ts): - - `MermaidImportStatus` - - `MermaidImportDiagnostic` - - normalization and classification helpers -- Upgraded [parseMermaidByType.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/parseMermaidByType.ts) to: - - preserve `originalSource` - - emit `structuredDiagnostics` - - classify imports as `editable_full`, `editable_partial`, `invalid_source`, `unsupported_family`, or `unsupported_construct` -- Tightened [detectDiagramType.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/detectDiagramType.ts) so unsupported-family detection only triggers on real header-shaped lines, which avoids false positives like treating `A --> B` as family `"A"`. -- Added [officialMermaidValidation.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/officialMermaidValidation.ts) as the seam for the official Mermaid validator so Phase 1 can land without reworking the dispatcher again. -- Installed the official `mermaid` package and upgraded [officialMermaidValidation.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/officialMermaidValidation.ts) into a real Phase 1 service with: - - synchronous official type detection for the app’s current import path - - asynchronous official parse validation for correctness work and harnessing -- Added [scripts/mermaid-compat-report.mjs](/Users/varun/Desktop/Dev_projects/flowmind-ai/scripts/mermaid-compat-report.mjs) as the first compatibility scorecard scaffold. -- Verified a real upstream integration constraint in the current Node harness: - - Mermaid 11 official parsing surfaces `DOMPurify` environment failures for several valid families in this environment - - genuine syntax failures still produce real official parse errors - - this confirms we need browser-aware async preflight validation rather than pretending the current synchronous import path can fully delegate to Mermaid today -- Added async official-Mermaid preflight to the command-bar apply path in [applyCodeChanges.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/command-bar/applyCodeChanges.ts), so upstream parse failures can now block apply before editable conversion in the UI path that already supports async work. -- Strengthened flowchart fidelity in: - - [mermaidParser.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParser.ts) - - [mermaidParserHelpers.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParserHelpers.ts) - - [mermaidBuilder.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/export/mermaidBuilder.ts) - with: - - explicit subgraph id + label parsing, e.g. `subgraph api[API Layer]` - - round-trip preservation of explicit subgraph ids during export - - retention of inline `:::class` metadata on edge-declared nodes so `classDef` styling applies correctly -- Expanded the compatibility harness from a stub into a corpus-driven scorecard with: - - fixture data in [mermaid-compat-fixtures.json](/Users/varun/Desktop/Dev_projects/flowmind-ai/scripts/mermaid-compat-fixtures.json) - - family-level summaries - - expected-outcome matching - - explicit `environment_limited` classification for upstream DOMPurify/runtime constraints -- Tightened architecture compatibility expectations: - - added an official-subset architecture edge fixture with no label - - reclassified labeled architecture edges as an OpenFlowKit extension outside the current official Mermaid subset -- Made [officialMermaidValidation.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/officialMermaidValidation.ts) browser-aware for full validation so non-browser runtimes degrade intentionally to type detection instead of surfacing misleading DOM runtime failures as diagram syntax failures. -- Extended [importFidelity.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/importFidelity.ts) and [applyCodeChanges.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/command-bar/applyCodeChanges.ts) so Mermaid warnings now flow into import reports instead of being silently treated as clean successes. -- Tightened class diagram export fidelity in [classDiagramMermaid.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/export/mermaid/classDiagramMermaid.ts) so relationship cardinalities already captured by the parser, such as `User "1" o-- "*" Account`, now round-trip instead of being silently dropped on export. -- Tightened ER diagram export fidelity by separating editor-facing field formatting from Mermaid-facing field formatting: - - [entityFields.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/entityFields.ts) now includes a Mermaid-specific serializer that emits valid `type name` ER field syntax and preserves `REFERENCES` metadata. - - [erDiagramMermaid.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/export/mermaid/erDiagramMermaid.ts) now uses that serializer so exported ER diagrams no longer degrade field order or silently drop foreign-key references. -- Tightened journey export fidelity so imported titles now round-trip instead of being overwritten by a hardcoded fallback: - - [journey/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/journey/plugin.ts) now preserves the Mermaid `title` value on journey nodes. - - [journeyMermaid.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/export/mermaid/journeyMermaid.ts) now exports the preserved title instead of always writing `title Journey`. -- Tightened mindmap wrapper fidelity so supported Mermaid wrapper syntax now round-trips instead of being flattened into plain labels: - - [mindmap/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/mindmap/plugin.ts) now preserves wrapper metadata such as `((...))`, `[[...]]`, and `{{...}}` on parsed nodes. - - [mindmapMermaid.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/export/mermaid/mindmapMermaid.ts) now re-emits those wrappers during export. -- Tightened studio Mermaid preview UX in [useStudioCodePanelController.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/studio-code-panel/useStudioCodePanelController.ts) so partially editable Mermaid drafts no longer present the same preview copy as clean imports. The preview now explicitly says `Ready with warnings` and calls out partial editability before apply. -- Centralized Mermaid import-state presentation in [importStatePresentation.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/importStatePresentation.ts) and reused it across: - - studio preview copy - - command-bar Mermaid import summaries - - paste-path warning toasts - This removes state-specific wording drift between entrypoints. -- Extended Mermaid diagnostics snapshots in [types.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/store/types.ts) so `importState` now travels with the diagnostic payload. That gives downstream UX a stable way to distinguish `editable_partial`, `unsupported_construct`, and `invalid_source` instead of inferring from generic warnings/errors. -- Tightened blocked-state guidance so unsupported Mermaid families and constructs now surface actionable fallback messaging instead of only raw parser errors: - - [importStatePresentation.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/importStatePresentation.ts) now exposes shared guidance text per import state. - - [applyCodeChanges.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/command-bar/applyCodeChanges.ts), [useFlowCanvasPaste.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/flow-canvas/useFlowCanvasPaste.ts), and [useStudioCodePanelController.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/studio-code-panel/useStudioCodePanelController.ts) now reuse that guidance so unsupported Mermaid reads like an intentional fallback path. -- Centralized Mermaid diagnostics snapshot creation in [diagnosticsSnapshot.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/diagnosticsSnapshot.ts) and enriched the snapshot contract with: - - `statusLabel` - - `statusDetail` - This makes the stored diagnostics payload immediately usable by future UI surfaces instead of forcing each surface to recompute Mermaid state meaning from raw errors and warnings. -- Added Mermaid source preservation to the diagnostics snapshot contract: - - [diagnosticsSnapshot.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/diagnosticsSnapshot.ts) now carries `originalSource` - - [MermaidDiagnosticsBanner.tsx](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/MermaidDiagnosticsBanner.tsx) now tells the user that the original Mermaid source is preserved and points them back to Mermaid code for safe recovery - This is the first concrete step toward a true source-preserving fallback mode instead of warning-only UX. -- Tightened the recovery/reporting layer for Mermaid: - - [importFidelity.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/importFidelity.ts) now summarizes Mermaid imports with human-readable state labels instead of raw enum codes. - - [ImportRecoveryDialog.tsx](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/ImportRecoveryDialog.tsx) now shows Mermaid-specific status and recovery guidance when the failed import came from Mermaid. -- Added a real shell-level Mermaid recovery action instead of banner-only messaging: - - [MermaidDiagnosticsBanner.tsx](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/MermaidDiagnosticsBanner.tsx) now supports an optional action button. - - [useFlowEditorController.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/flow-editor/useFlowEditorController.ts) now exposes the existing `openStudioCode` controller action to shell consumers. - - [FlowEditor.tsx](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/FlowEditor.tsx) now wires preserved-source Mermaid diagnostics to an `Open Mermaid code` action that takes the user straight into Mermaid code mode for recovery. -- Extended the same recovery path into failed Mermaid import flows: - - [ImportRecoveryDialog.tsx](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/ImportRecoveryDialog.tsx) now supports an optional recovery action alongside retry/dismiss. - - [FlowEditor.tsx](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/FlowEditor.tsx) now passes `Open Mermaid code` into the import recovery dialog when the failed import is Mermaid and preserved Mermaid source is available in diagnostics. -- Moved preserved Mermaid source into the import report contract itself: - - [importFidelity.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/importFidelity.ts) now carries `originalSource` on Mermaid reports. - - [applyCodeChanges.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/command-bar/applyCodeChanges.ts) now persists the original Mermaid source into manual Mermaid import reports across blocking, failure, warning, and success-with-fallback paths. - - [ImportRecoveryDialog.tsx](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/ImportRecoveryDialog.tsx) now renders the preserved-source recovery note from the report itself. - - [FlowEditor.tsx](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/FlowEditor.tsx) now prefers report-owned Mermaid source for recovery actions instead of relying only on ambient global diagnostics. -- Expanded the compatibility corpus materially: - - [mermaid-compat-fixtures.json](/Users/varun/Desktop/Dev_projects/flowmind-ai/scripts/mermaid-compat-fixtures.json) now covers 19 fixtures across flowchart, stateDiagram, sequence, classDiagram, erDiagram, mindmap, journey, architecture, and unsupported gitGraph. - - [compatReportHarness.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/compatReportHarness.test.ts) now asserts broader family coverage instead of only a minimal smoke baseline. - - The expanded corpus surfaced one real upstream compatibility nuance: `REFERENCES CUSTOMER.id` in Mermaid ER fields is currently rejected by official Mermaid in this harness, so that fixture is now classified as officially invalid instead of silently assuming official compatibility. -- Expanded the compatibility corpus again to cover more invalid and partial-shape cases: - - [mermaid-compat-fixtures.json](/Users/varun/Desktop/Dev_projects/flowmind-ai/scripts/mermaid-compat-fixtures.json) now covers 24 fixtures, adding invalid flowchart, sequence, stateDiagram, classDiagram, and journey cases. - - The harness now distinguishes between true official-invalid cases and `environment_limited` cases where the current Node runtime cannot honestly validate an officially invalid expectation because Mermaid falls over on DOMPurify first. -- Fixed a real sequence activation fidelity bug across parser, renderer, and exporter: - - [types.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/types.ts) now models `seqActivations` as explicit `{ order, activate }` events instead of a lossy number list. - - [sequence/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/sequence/plugin.ts) now preserves explicit activation/deactivation events on participant nodes. - - [sequenceMermaid.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/export/mermaid/sequenceMermaid.ts) now emits activation commands in the correct timeline position instead of front-loading them before all messages. - - [SequenceParticipantNode.tsx](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/custom-nodes/SequenceParticipantNode.tsx) now renders activation bars from explicit activation ranges instead of assuming simple alternating start/end pairs. -- Fixed a real stateDiagram composite-label fidelity bug across parser and exporter: - - [stateDiagramMermaid.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/export/mermaid/stateDiagramMermaid.ts) now preserves composite state labels by emitting `state "Label" as Alias {` when a composite state's display label differs from its node id. - - [stateDiagram/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/stateDiagram/plugin.ts) now recognizes quoted composite aliases during parent reconstruction and supports quoted note targets in state note parsing. - - [mermaidParser.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParser.ts) now accepts broader state alias identifiers like `Working.Set` or `WorkingSet`. -- Tightened the generic flowchart parser so malformed-but-recoverable structure is no longer silently treated as a clean import: - - [mermaidParserModel.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParserModel.ts) now carries parser diagnostics alongside nodes and edges. - - [mermaidParser.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParser.ts) now emits diagnostics for invalid edge syntax, malformed subgraph declarations, unexpected block closers, unrecognized flowchart lines, and unclosed flowchart blocks. - - [parseMermaidByType.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/parseMermaidByType.test.ts) now asserts that these malformed flowchart cases downgrade to `editable_partial` instead of pretending the parse was fully clean. -- Tightened ER export compatibility against official Mermaid: - - [entityFields.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/entityFields.ts) now emits Mermaid-compatible ER field references as `REFERENCES TABLE` and uses `UK` instead of `UNIQUE` for uniqueness markers in Mermaid export. - - [erDiagram/plugin.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/erDiagram/plugin.test.ts) now covers the official-compatible table-only `REFERENCES` form. - - [mermaid-compat-fixtures.json](/Users/varun/Desktop/Dev_projects/flowmind-ai/scripts/mermaid-compat-fixtures.json) now includes an officially valid ER field fixture using `FK REFERENCES CUSTOMER` plus `UK`, and the compatibility report shows that as a genuine official-valid case. -- Fixed a real sequence fragment fidelity gap across parser and exporter: - - [sequence/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/sequence/plugin.ts) now preserves per-branch fragment metadata for `alt/else` and `par/and` instead of flattening every branch back to a generic fragment start. - - [sequenceMermaid.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/export/mermaid/sequenceMermaid.ts) now emits `else ...` and `and ...` branch markers in the correct timeline position instead of incorrectly reopening a second `alt` or `par` block. - - [mermaidBuilder.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/export/mermaidBuilder.ts) now recognizes fragment annotation nodes as part of sequence exports so mixed participant/note/fragment sequence canvases are exported through the sequence serializer instead of falling through to another family. -- Added a dedicated editable-partial regression corpus for malformed-but-recoverable Mermaid imports: - - [editablePartialCorpus.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/editablePartialCorpus.test.ts) now verifies malformed-but-editable cases across flowchart, stateDiagram, classDiagram, erDiagram, mindmap, and journey. - - The corpus asserts that these cases stay importable, downgrade to `editable_partial`, and produce structured syntax diagnostics instead of silently looking like clean imports. -- Fixed a real stateDiagram export fidelity gap around explicit direction: - - [stateDiagramMermaid.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/export/mermaid/stateDiagramMermaid.ts) now preserves an explicitly provided `direction LR/TB` during export instead of always re-inferring direction from node layout. - - [mermaidBuilder.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/export/mermaidBuilder.ts) now passes the caller-provided direction through to the stateDiagram exporter just like the flowchart exporter already did. -- Tightened sequence fragment fidelity again by covering parallel branches as first-class Mermaid branches: - - [remainingFamiliesRoundTrip.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/remainingFamiliesRoundTrip.test.ts) now verifies that `par ... and ... end` round-trips as `par/and` instead of degrading to repeated `par` openings. - - [mermaid-compat-fixtures.json](/Users/varun/Desktop/Dev_projects/flowmind-ai/scripts/mermaid-compat-fixtures.json) now includes `state-direction-lr` and `sequence-par-and`, pushing the compatibility corpus to 29 fixtures. -- Fixed a real classDiagram export fidelity gap for generic identifiers: - - [classDiagramMermaid.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/export/mermaid/classDiagramMermaid.ts) now converts internal generic identifiers like `Repository` back to Mermaid syntax like `Repository~T~` during export instead of leaking the normalized internal form. - - [remainingFamiliesRoundTrip.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/remainingFamiliesRoundTrip.test.ts) now verifies generic class identifiers and generic relation endpoints round-trip honestly through parse/export/parse. -- Expanded malformed-but-recoverable coverage for sequence imports: - - [editablePartialCorpus.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/editablePartialCorpus.test.ts) now includes a sequence case with one valid message followed by a malformed message, verifying that sequence imports can stay editable as `editable_partial` with syntax diagnostics. - - [mermaid-compat-fixtures.json](/Users/varun/Desktop/Dev_projects/flowmind-ai/scripts/mermaid-compat-fixtures.json) now includes `sequence-partial-after-valid-message`, pushing the compatibility corpus to 30 fixtures. -- Fixed a real architecture round-trip fidelity gap around titles: - - [architecture/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/architecture/plugin.ts) now preserves `title ...` metadata on imported architecture nodes instead of discarding it during parse. - - [architectureMermaid.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/export/mermaid/architectureMermaid.ts) now emits the preserved architecture title back into Mermaid export. - - [architectureRoundTrip.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/architectureRoundTrip.test.ts) now verifies title preservation through parse/export/parse. -- Expanded editable-partial coverage to architecture recovery cases and tightened the corpus contract: - - [editablePartialCorpus.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/editablePartialCorpus.test.ts) now includes an architecture implicit-node recovery case and asserts expected structured diagnostic codes per corpus case instead of incorrectly assuming every partial import is `MERMAID_SYNTAX`. - - That keeps recovery-driven partial imports like architecture implicit-node creation measured honestly as `MERMAID_RECOVERY` instead of weakening the corpus with a false generic assertion. -- Expanded the compatibility harness to cover architecture titles explicitly: - - [mermaid-compat-fixtures.json](/Users/varun/Desktop/Dev_projects/flowmind-ai/scripts/mermaid-compat-fixtures.json) now includes `architecture-title-basic`, pushing the compatibility corpus to 31 fixtures. - - [compatReportHarness.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/compatReportHarness.test.ts) was raised accordingly so title preservation is tracked in the corpus, not just unit tests. - - The harness also surfaced another real upstream constraint: official Mermaid still reports the architecture-title fixture as `environment_limited` in this Node runtime because the same DOMPurify/browser assumption affects that path too. -- Fixed a real mindmap round-trip fidelity gap around Mermaid alias prefixes: - - [mindmap/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/mindmap/plugin.ts) now preserves alias prefixes for wrapped Mermaid mindmap nodes like `root((Root))` and `feature[[Topic]]` instead of dropping them when the structured node tree is built. - - [mindmapMermaid.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/export/mermaid/mindmapMermaid.ts) now emits preserved aliases back into Mermaid export for wrapped mindmap nodes. - - [mindmap/plugin.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/mindmap/plugin.test.ts) and [remainingFamiliesRoundTrip.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/remainingFamiliesRoundTrip.test.ts) now verify alias preservation through parse/export/parse. -- Updated tests in: - - [parseMermaidByType.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/parseMermaidByType.test.ts) - - [importFidelity.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/importFidelity.test.ts) - - [remainingFamiliesRoundTrip.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/remainingFamiliesRoundTrip.test.ts) -- Expanded flowchart parser fidelity for modern architecture-style Mermaid syntax: - - [mermaidParserHelpers.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParserHelpers.ts) now preserves annotation-only `@{ ... }` label and shape metadata without requiring legacy bracket syntax. - - [mermaidParserHelpers.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParserHelpers.ts) now accepts dotted Mermaid ids like `api.gateway`, `db.primary`, and `cluster.api` across standalone nodes, inline edge endpoints, modern annotations, and subgraph ids. - - [flowchartRoundTrip.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/flowchartRoundTrip.test.ts) now verifies dotted-id and modern-annotation flowcharts survive parse/export/parse without losing labels or endpoints. -- Expanded flowchart class styling fidelity: - - [mermaidParserHelpers.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParserHelpers.ts) now parses Mermaid `class A,B hot` assignment lines instead of silently skipping them. - - [mermaidParser.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParser.ts) now applies those class assignments to registered nodes, including dotted ids, so later `classDef` styling actually reaches the imported canvas nodes. - - [mermaidParser.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaidParser.test.ts) and [parseMermaidByType.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/parseMermaidByType.test.ts) now cover class-assignment directives, dotted ids, and modern annotation-only nodes through both the raw parser and the higher-level Mermaid dispatcher. -- Expanded the compatibility corpus again: - - [mermaid-compat-fixtures.json](/Users/varun/Desktop/Dev_projects/flowmind-ai/scripts/mermaid-compat-fixtures.json) now includes flowchart fixtures for `class` assignment lines and modern annotation+dotted-id combinations, pushing the tracked corpus to 33 fixtures. - - [compatReportHarness.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/compatReportHarness.test.ts) was raised accordingly so these flowchart constructs are now part of the ongoing compatibility scorecard, not one-off unit tests. -- Tightened flowchart export fidelity so imported styling semantics now survive round-trip instead of being flattened away: - - [mermaidBuilder.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/export/mermaidBuilder.ts) now emits Mermaid `style ...` directives for node background/border/text colors and `linkStyle ...` directives for edge stroke color/width when those styles exist on the editable graph. - - [exportService.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/exportService.test.ts), [flowchartRoundTrip.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/flowchartRoundTrip.test.ts), and [mermaidExportQuality.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/export/mermaidExportQuality.test.ts) now verify that Mermaid-imported `classDef`/`class` and `linkStyle` semantics survive parse/export/parse as concrete Mermaid directives. -- Tightened sequence fragment fidelity so notes inside control blocks no longer fall out of their Mermaid fragment context on export: - - [sequence/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/sequence/plugin.ts) now preserves fragment metadata on note nodes created inside `alt`, `par`, and similar control blocks. - - [sequenceMermaid.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/export/mermaid/sequenceMermaid.ts) now drives fragment open/close transitions for note timeline entries as well as message edges, so note export stays inside the correct Mermaid block. - - [sequence/plugin.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/sequence/plugin.test.ts) and [remainingFamiliesRoundTrip.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/remainingFamiliesRoundTrip.test.ts) now verify note-in-fragment preservation through parse/export/parse. -- Tightened architecture round-trip fidelity for richer node kinds: - - [architectureMermaid.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/export/mermaid/architectureMermaid.ts) now exports preserved architecture kinds like `person`, `container`, and `database_container` instead of collapsing everything non-group/non-junction back to `service`. - - [architectureRoundTrip.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/architectureRoundTrip.test.ts) and [exportService.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/exportService.test.ts) now verify that richer architecture node kinds survive import/export/import honestly. -- Expanded the compatibility corpus again for these two families: - - [mermaid-compat-fixtures.json](/Users/varun/Desktop/Dev_projects/flowmind-ai/scripts/mermaid-compat-fixtures.json) now includes `sequence-note-inside-alt` and `architecture-rich-node-kinds`, pushing the tracked corpus to 35 fixtures. - - [compatReportHarness.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/compatReportHarness.test.ts) was raised accordingly so these architecture/sequence cases stay part of the ongoing compatibility scorecard. - - [entityFields.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/entityFields.test.ts) - - [journey/plugin.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/journey/plugin.test.ts) - - [mindmap/plugin.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/mindmap/plugin.test.ts) - - [useStudioCodePanelController.test.tsx](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/studio-code-panel/useStudioCodePanelController.test.tsx) - - [StudioCodePanel.test.tsx](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/StudioCodePanel.test.tsx) - - [importStatePresentation.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/importStatePresentation.test.ts) - - [applyCodeChanges.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/command-bar/applyCodeChanges.test.ts) - - [diagnosticsSnapshot.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/diagnosticsSnapshot.test.ts) - - [MermaidDiagnosticsBanner.test.tsx](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/MermaidDiagnosticsBanner.test.tsx) - - [ImportRecoveryDialog.test.tsx](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/ImportRecoveryDialog.test.tsx) - - [FlowEditor.test.tsx](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/FlowEditor.test.tsx) - - [editablePartialCorpus.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/editablePartialCorpus.test.ts) - - [stateDiagramRoundTrip.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/stateDiagramRoundTrip.test.ts) - - [compatReportHarness.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/compatReportHarness.test.ts) - -### Plan changes made during implementation - -- Refined Phase 0 to include a hard distinction between `invalid_source` and `unsupported_family`. - Why this improved reliability: - The previous behavior told users that unsupported Mermaid families were "missing chart type declarations," which was false and damaged trust. -- Pulled the structured diagnostics work ahead of the official-parser integration. - Why this improved reliability: - The validator layer needs a stable result contract first; otherwise Phase 1 would add more branching without a clear outcome model. -- Added the validator seam before adding the dependency. - Why this improved quality: - It decouples contract work from package integration and keeps the parser entrypoint stable when the official Mermaid package is wired in. -- Split official Mermaid integration into a synchronous detection layer and an asynchronous full-validation layer. - Why this improved reliability: - Mermaid’s official parser is async, while the current editable import path is synchronous and widely used. Forcing a fake sync wrapper here would have been brittle and high-risk. - -### Simplification / refactor pass - -- Removed dead fallback control flow from [parseMermaidByType.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/parseMermaidByType.ts) after the new finalize path made it redundant. -- Centralized Mermaid import-state classification so plugins do not need to duplicate contract logic. -- Simplified class relation export by giving [classDiagramMermaid.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/export/mermaid/classDiagramMermaid.ts) a single typed resolver for relation token, label, and cardinality metadata instead of reconstructing those pieces in multiple places. -- Avoided an ER editor regression by keeping the existing editor serializer in [entityFields.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/entityFields.ts) unchanged and adding a dedicated Mermaid serializer for export only. -- Avoided a broader document-model refactor for journey metadata by preserving the Mermaid title locally on journey nodes first, which improves round-trip fidelity now without adding a risky cross-format metadata layer in the same change-set. -- Avoided conflating Mermaid wrapper syntax with general node shape styling by preserving the original wrapper token separately in mindmap node data and using it only for Mermaid export. -- Avoided a larger studio preview state-model refactor by reusing the existing `ready` state and making the preview copy truthful for `editable_partial` drafts first. -- Avoided duplicating fallback copy yet again by introducing a shared import-state presenter instead of hardcoding separate strings in studio, import, and paste flows. -- Avoided inventing a new fallback mode in the same pass; instead, made the current blocked states self-explanatory first so the later render-only fallback can sit on a clearer product contract. -- Removed duplicated ad hoc Mermaid snapshot assembly from apply and paste flows so future diagnostics UI changes only need one contract update. -- Preserved original Mermaid source on blocked and partial paths so future fallback actions can operate on the exact user input instead of reconstructing it from diagnostics. -- Removed Mermaid-specific recovery wording drift between import reports and the recovery dialog by reusing the same import-state presenter there too. -- Reused the existing studio-opening controller action instead of adding a second Mermaid-recovery-specific navigation path in the view layer. -- Reused the same Mermaid recovery action contract in both the shell banner and the import recovery dialog so fallback behavior stays consistent across blocked Mermaid entrypoints. -- Removed the import recovery dialog's hidden dependency on global Mermaid diagnostics by storing preserved Mermaid source on the report itself, which makes failed Mermaid recovery more self-contained and reliable. -- Removed a hidden sequence export assumption that activation commands always alternate cleanly and can be reconstructed purely from count/order parity. That assumption was false and caused incorrect Mermaid round-trips. -- Removed a hidden stateDiagram export assumption that composite state ids and human-readable labels are interchangeable. That assumption was false and caused composite state labels to be dropped on round-trip. -- Removed a hidden flowchart import assumption that malformed structure without a hard parse failure should be treated as a clean editable import. That assumption was false and masked partial reliability problems. -- Removed a hidden ER export assumption that richer editor semantics like `UNIQUE` and `REFERENCES TABLE.field` automatically map to Mermaid’s officially accepted ER subset. That assumption was false; the exporter now uses the official-compatible token forms where available. -- Removed a hidden sequence export assumption that every fragment branch can be reconstructed as a fresh fragment start. That assumption was false and caused `else` and `and` branches to round-trip as the wrong Mermaid control syntax. -- Added a focused editable-partial corpus instead of burying malformed-but-recoverable cases inside unrelated happy-path tests, which keeps reliability regressions easier to diagnose family by family. -- Removed a hidden stateDiagram export assumption that layout shape is a safe substitute for an explicit Mermaid `direction` declaration. That assumption was false and caused honest `direction LR` inputs to drift on export. -- Removed a hidden sequence export assumption that only `alt/else` needed branch-kind preservation. Parallel `par/and` branches have the same fidelity requirement and are now covered the same way. -- Removed a hidden classDiagram export assumption that internal normalized generic identifiers are safe to emit directly as Mermaid source. That assumption was false and caused generic classes to export in a non-Mermaid form. -- Removed a hidden architecture import/export assumption that `title` is non-essential metadata. That assumption was false and caused architecture diagrams to lose document-level meaning on round-trip. -- Expanded the compatibility harness with title-bearing architecture input so document-level metadata fidelity is measured alongside graph-shape fidelity instead of remaining an untracked round-trip behavior. -- Removed a hidden mindmap parser assumption that alias-like wrapper prefixes were presentation-only. That assumption was false and caused valid Mermaid identifiers like `root((Root))` to disappear on export even though the node itself survived. - -### Next recommended implementation steps - -- Expand the fixture corpus behind [scripts/mermaid-compat-report.mjs](/Users/varun/Desktop/Dev_projects/flowmind-ai/scripts/mermaid-compat-report.mjs) into a real per-family compatibility harness. -- Introduce an async import preflight path in the command bar so official parse validation can run before editable conversion without forcing the entire canvas paste/import pipeline to become synchronous-in-name-only. -- Start parser-depth work with flowchart and architecture, using the new import-state and structured-diagnostics contract as the baseline. diff --git a/MERMAID_IMPORT_IMPLEMENTATION_LOG.md b/MERMAID_IMPORT_IMPLEMENTATION_LOG.md deleted file mode 100644 index 40fd469b..00000000 --- a/MERMAID_IMPORT_IMPLEMENTATION_LOG.md +++ /dev/null @@ -1,119 +0,0 @@ -# Mermaid Import Implementation Log -**Date:** 2026-04-07 - -## Implemented - -### Import pipeline hardening -- Made `enrichNodesWithIcons()` import-aware and diagram-aware in [src/lib/nodeEnricher.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/nodeEnricher.ts). -- Added conservative technology/query guards in [src/lib/semanticClassifier.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/semanticClassifier.ts). -- Moved Mermaid paste imports onto strict enrichment behavior before ELK layout in [src/components/flow-canvas/useFlowCanvasPaste.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/flow-canvas/useFlowCanvasPaste.ts). -- Changed import spacing to `loose -> normal -> compact` with compact floor raised above the old 40px behavior in [src/services/elk-layout/options.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/elk-layout/options.ts). - -### Layout reliability -- Added automatic layout selection based on graph structure in [src/services/elkLayout.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/elkLayout.ts). -- Added cycle detection and branching-factor heuristics so star/tree graphs can use `mrtree`, while dense/cyclic graphs switch to `force` or `stress`. -- Preserved architecture ordering from imported boundary/group order via `archLayerRank` metadata in [src/diagram-types/architecture/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/architecture/plugin.ts) and [src/lib/types.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/types.ts). -- Improved ELK handle inference to use route direction vectors instead of nearest-side guesses in [src/services/elkLayout.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/elkLayout.ts). - -### Parser hardening -- Strengthened flowchart edge parsing in [src/lib/mermaidParserHelpers.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParserHelpers.ts). -- Added support for chained edges on one line. -- Stopped arrow detection from breaking on arrow-like text inside quoted labels or `|edge labels|`. -- Normalized additional bidirectional extended arrow variants. - -### Diagram plugin improvements -- Added state-diagram note rendering and fork/join control parsing in [src/diagram-types/stateDiagram/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/stateDiagram/plugin.ts). -- Added visible sequence fragment nodes and improved note placement metadata in [src/diagram-types/sequence/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/sequence/plugin.ts). -- Tightened journey score validation and mapped scores to visual color states in [src/diagram-types/journey/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/journey/plugin.ts). -- Added generic-class normalization and relation cardinality parsing in [src/diagram-types/classDiagram/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/classDiagram/plugin.ts). -- Upgraded ER field parsing from raw strings to structured field objects in [src/diagram-types/erDiagram/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/erDiagram/plugin.ts). - -### Edge polish -- Applied sibling-aware ELK label staggering in [src/components/custom-edge/pathUtils.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/custom-edge/pathUtils.ts). -- Improved self-loop sizing so loops scale from actual node dimensions in [src/components/custom-edge/pathUtils.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/custom-edge/pathUtils.ts). -- Added import-time icon enrichment failure isolation in [src/components/flow-canvas/useFlowCanvasPaste.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/flow-canvas/useFlowCanvasPaste.ts). - -### Test coverage added/updated -- Expanded focused coverage in: - - [src/lib/semanticClassifier.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/semanticClassifier.test.ts) - - [src/lib/nodeEnricher.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/nodeEnricher.test.ts) - - [src/services/mermaid/parseMermaidByType.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/mermaid/parseMermaidByType.test.ts) - - [src/diagram-types/stateDiagram/plugin.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/stateDiagram/plugin.test.ts) - - [src/diagram-types/sequence/plugin.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/sequence/plugin.test.ts) - - [src/diagram-types/journey/plugin.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/journey/plugin.test.ts) - - [src/diagram-types/classDiagram/plugin.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/classDiagram/plugin.test.ts) - - [src/diagram-types/erDiagram/plugin.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/erDiagram/plugin.test.ts) - - [src/services/elkLayout.test.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/services/elkLayout.test.ts) - -## Plan Changes - -### Refined away stale audit assumptions -- The audit proposed a subgraph `end` guard in the legacy parser. That guard already exists in [src/lib/mermaidParser.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/mermaidParser.ts), so no new code was added there. -- The audit described `[*]` state terminals as rendering plainly. The current core parser already gives state start nodes terminal styling, so the work was redirected to truly missing state features: notes and fork/join controls. - -### Replaced raw-source architecture ordering with node metadata -- The audit suggested teaching `elkLayout.ts` to read semantic layer order directly from Mermaid source. -- That would have coupled layout to source text it does not own. Instead, the architecture plugin now emits stable `archLayerRank` metadata, and layout consumes that metadata. -- This is more reliable because the parse stage remains the single source of truth and layout stays source-agnostic. - -### Replaced sequence fragment “section nodes” with annotation-style fragment markers -- The audit suggested styled section nodes for sequence fragments. -- Existing ELK behavior and the current sequence lane model make container sections high-risk because they distort participant layout and grouping. -- Fragment markers were implemented as visible annotation nodes instead. This preserves visibility without destabilizing sequence layout. - -### Refined the journey gap -- The audit said actors were ignored. -- In the current implementation, actors were already preserved as journey sublabels. -- The higher-value fix was strict score enforcement plus score-based visual differentiation, which directly improves correctness and readability. - -### Expanded generic parsing beyond the audit wording -- The audit called out `` generics. -- Mermaid syntax commonly uses `~T~`, so the class parser now normalizes `~T~` to `` and accepts both forms. -- This improves real-world Mermaid compatibility instead of only satisfying one notation. - -## Why These Decisions Improved Reliability - -- Import-time iconing is now conservative by default, which removes the most visible false-positive behavior without breaking existing manual/icon-explicit nodes. -- Layout selection is now based on structure, not just size, which prevents tree-like imports and dense cyclic imports from being forced through the same algorithm. -- Parser edge scanning now respects Mermaid syntax boundaries, which is the difference between “works on demos” and “survives real pasted diagrams”. -- Class and ER plugins now emit structured data closer to the rest of the editor’s internal node model, which reduces downstream hacks and makes future rendering improvements safer. - -## Code Simplification Pass - -### Step 1 -- Scope: [src/diagram-types/sequence/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/sequence/plugin.ts) -- Behavior preserved: - - Fragment node colors remain identical. - - Note lane placement remains identical. - - No parser semantics changed. -- Simplification: - - Replaced nested fragment-color ternaries with `getSequenceFragmentColor()`. - - Extracted `getParticipantLaneIndex()` to make note placement logic explicit. -- Validation: - - `pnpm vitest run src/diagram-types/sequence/plugin.test.ts` -> passed -- Rollback note: - - Revert only the helper extraction in `src/diagram-types/sequence/plugin.ts` if this refactor ever causes a regression. - -### Step 2 -- Scope: touched Mermaid implementation files -- Behavior preserved: - - No syntax support was removed. - - No layout heuristics were weakened. - - No icon-enrichment policy was broadened. -- Simplification: - - [src/components/flow-canvas/useFlowCanvasPaste.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/components/flow-canvas/useFlowCanvasPaste.ts): converted import enrichment isolation into a stable callback helper. - - [src/lib/nodeEnricher.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/lib/nodeEnricher.ts): extracted icon-enrichment policy calculation into a focused helper. - - [src/diagram-types/classDiagram/plugin.ts](/Users/varun/Desktop/Dev_projects/flowmind-ai/src/diagram-types/classDiagram/plugin.ts): removed repeated class-record bootstrap branches via `ensureClassRecord()`. - - Reviewed the remaining touched Mermaid files for simplification opportunities and left already-clear code unchanged to avoid churn-only edits. -- Validation: - - `pnpm vitest run src/components/custom-edge/pathUtils.test.ts src/lib/nodeEnricher.test.ts src/services/mermaid/parseMermaidByType.test.ts src/diagram-types/classDiagram/plugin.test.ts src/diagram-types/stateDiagram/plugin.test.ts src/diagram-types/sequence/plugin.test.ts src/diagram-types/journey/plugin.test.ts src/diagram-types/erDiagram/plugin.test.ts src/services/elkLayout.test.ts` -> passed - - `pnpm exec tsc --noEmit` -> passed -- Rollback note: - - Revert only the helper extractions above if a maintenance-oriented refactor ever needs to be undone separately from behavior fixes. - -## Validation Summary - -- `pnpm vitest run src/lib/semanticClassifier.test.ts src/lib/nodeEnricher.test.ts src/services/mermaid/parseMermaidByType.test.ts src/diagram-types/stateDiagram/plugin.test.ts src/diagram-types/sequence/plugin.test.ts src/diagram-types/journey/plugin.test.ts src/diagram-types/classDiagram/plugin.test.ts src/diagram-types/erDiagram/plugin.test.ts src/services/elkLayout.test.ts` -> passed -- `pnpm vitest run src/diagram-types/sequence/plugin.test.ts` -> passed -- `pnpm vitest run src/components/custom-edge/pathUtils.test.ts src/lib/nodeEnricher.test.ts src/services/mermaid/parseMermaidByType.test.ts src/diagram-types/classDiagram/plugin.test.ts src/diagram-types/stateDiagram/plugin.test.ts src/diagram-types/sequence/plugin.test.ts src/diagram-types/journey/plugin.test.ts src/diagram-types/erDiagram/plugin.test.ts src/services/elkLayout.test.ts` -> passed -- `pnpm exec tsc --noEmit` -> passed From 28c61c606aafea38691f5e05f1b654636dbdb57e Mon Sep 17 00:00:00 2001 From: Varun Date: Thu, 9 Apr 2026 18:54:13 +0530 Subject: [PATCH 8/9] fix: compound nodes now inherit elk.direction from root layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ELK does not cascade elk.direction from parent to child compound nodes — each compound node must set it explicitly or ELK falls back to its own default (DOWN), causing subgraphs to always render vertically regardless of the root graph direction setting. buildElkNode now accepts rootElkDirection and sets elk.direction on every compound node's layoutOptions. Subgraphs with internal edges inherit the root direction (DOWN for TB = sources on rank 1, targets on rank 2, side by side). Subgraphs with no internal edges (pure parallel siblings) still override to RIGHT. This fixes the main visual quality gap vs Mermaid.js — imported flowchart subgraphs now render wide and horizontal instead of stacking into tall vertical columns. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 6 + package.json | 3 + scripts/mermaid-compat-fixtures.json | 93 ++++- scripts/mermaid-compat-fixtures.mjs | 342 ++++++++++++++++++ scripts/mermaid-compat-report.mjs | 9 +- src/components/FlowCanvas.tsx | 125 ++++++- .../custom-edge/SequenceMessageEdge.tsx | 2 +- .../custom-nodes/SequenceParticipantNode.tsx | 17 +- .../flow-canvas/useFlowCanvasPaste.ts | 64 +++- src/diagram-types/architecture/plugin.test.ts | 16 + src/diagram-types/classDiagram/plugin.test.ts | 18 + src/diagram-types/classDiagram/plugin.ts | 4 +- src/diagram-types/erDiagram/plugin.test.ts | 23 ++ src/diagram-types/erDiagram/plugin.ts | 27 +- src/diagram-types/journey/plugin.test.ts | 17 + src/diagram-types/journey/plugin.ts | 62 ++-- src/diagram-types/mindmap/plugin.test.ts | 15 + src/diagram-types/mindmap/plugin.ts | 35 +- src/diagram-types/sequence/plugin.test.ts | 25 ++ src/diagram-types/sequence/plugin.ts | 111 +++--- src/diagram-types/stateDiagram/plugin.test.ts | 32 ++ src/diagram-types/stateDiagram/plugin.ts | 62 +++- src/hooks/node-operations/sectionBounds.ts | 6 +- src/hooks/node-operations/utils.test.ts | 10 +- src/lib/entityFields.test.ts | 2 +- src/lib/entityFields.ts | 15 +- src/lib/mermaidParserHelpers.ts | 7 +- src/lib/types.ts | 4 +- src/services/architectureRoundTrip.test.ts | 28 ++ src/services/composeDiagramForDisplay.test.ts | 9 +- src/services/composeDiagramForDisplay.ts | 10 +- src/services/elk-layout/options.test.ts | 30 ++ src/services/elk-layout/options.ts | 85 +++-- src/services/elk-layout/textSizing.ts | 107 ++++++ src/services/elk-layout/types.ts | 3 + src/services/elkLayout.test.ts | 18 +- src/services/elkLayout.ts | 228 ++++++++++-- .../export/mermaid/architectureMermaid.ts | 76 ++-- .../export/mermaid/sequenceMermaid.ts | 6 +- src/services/exportService.test.ts | 28 ++ src/services/importLayoutMetadata.ts | 94 +++++ .../mermaid/compatFixtureCorpus.test.ts | 139 +++++++ .../mermaid/compatReportHarness.test.ts | 27 +- .../mermaid/mermaidLayoutCorpus.test.ts | 220 +++++++++++ .../mermaid/parseMermaidByType.test.ts | 39 ++ src/services/mermaid/parseMermaidByType.ts | 39 +- src/services/mermaidParser.test.ts | 33 ++ .../remainingFamiliesRoundTrip.test.ts | 145 ++++++++ src/services/sequence/layoutConstants.ts | 7 + src/services/sequenceLayout.test.ts | 94 +++++ src/services/sequenceLayout.ts | 201 ++++++++++ src/services/smartEdgeRouting.ts | 13 +- tsconfig.tsbuildinfo | 2 +- 53 files changed, 2554 insertions(+), 279 deletions(-) create mode 100644 scripts/mermaid-compat-fixtures.mjs create mode 100644 src/services/elk-layout/options.test.ts create mode 100644 src/services/elk-layout/textSizing.ts create mode 100644 src/services/importLayoutMetadata.ts create mode 100644 src/services/mermaid/compatFixtureCorpus.test.ts create mode 100644 src/services/mermaid/mermaidLayoutCorpus.test.ts create mode 100644 src/services/sequence/layoutConstants.ts create mode 100644 src/services/sequenceLayout.test.ts create mode 100644 src/services/sequenceLayout.ts diff --git a/README.md b/README.md index 25a1b038..1eafaddf 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,12 @@ Paste this → you get the Express wordmark, PostgreSQL elephant, Redis logo, an 4. **Enricher** assigns colors, icons, and provider SVGs to every node 5. **ELK layout** arranges everything cleanly +### Mermaid quality gates + +- `npm run test:mermaid` runs the broad Mermaid parser/plugin/round-trip gate +- `npm run test:mermaid:layout` runs the layout, import-state, and recovery corpus gate +- `npm run test:mermaid:gold` runs both together + ### AI generation (API key required) Describe your system in plain English. AI generates a diagram on the canvas with correct icons applied automatically. diff --git a/package.json b/package.json index 0a15b35b..6f67e7e8 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "build:ci": "npm run build && npm run bundle:check", "test:ci": "npm run test -- --run && npm run build:ci", "test:s0": "npx tsc -b --pretty false && npm run test -- --run src/store.test.ts src/hooks/useFlowHistory.test.ts src/components/flow-canvas/flowCanvasTypes.test.ts src/services/mermaid/parseMermaidByType.test.ts src/services/exportService.test.ts src/components/flow-canvas/largeGraphSafetyMode.test.ts", + "test:mermaid:gold": "npm run test:mermaid && npm run test:mermaid:layout", + "test:mermaid": "npx tsc -b --pretty false && npm run test -- --run src/services/mermaid/*.test.ts src/diagram-types/*/*.test.ts src/services/*RoundTrip.test.ts", + "test:mermaid:layout": "npx tsc -b --pretty false && npm run test -- --run src/services/mermaid/mermaidLayoutCorpus.test.ts src/services/mermaid/compatFixtureCorpus.test.ts src/services/mermaid/editablePartialCorpus.test.ts", "test:s10-state": "npx tsc -b --pretty false && npm run test -- --run src/diagram-types/stateDiagram/plugin.test.ts src/services/stateDiagramRoundTrip.test.ts src/services/exportService.test.ts src/services/mermaid/parseMermaidByType.test.ts src/services/mermaid/detectDiagramType.test.ts", "test:s10-canvas": "npx tsc -b --pretty false && npm run test -- --run src/components/flow-canvas/pasteHelpers.test.ts src/components/flow-canvas/flowCanvasTypes.test.ts src/components/flow-canvas/largeGraphSafetyMode.test.ts src/hooks/edgeConnectInteractions.test.ts src/components/DesignSystem.integration.test.tsx src/components/HomePage.integration.test.tsx src/store.test.ts", "test:s4-handle-safety": "npx tsc -b --pretty false && npm run test -- --run src/components/handleInteraction.test.ts src/components/handleInteractionUsage.test.ts src/components/CustomNode.handleInteraction.test.tsx src/components/custom-nodes/ArchitectureNode.handleInteraction.test.tsx src/components/custom-nodes/ClassEntityNode.handleInteraction.test.tsx src/components/lightweight-nodes.handleInteraction.test.tsx src/components/custom-nodes/VisualNodes.handleInteraction.test.tsx src/components/container-nodes.handleInteraction.test.tsx src/components/flow-canvas/flowCanvasTypes.test.ts src/components/DesignSystem.integration.test.tsx src/components/HomePage.integration.test.tsx", diff --git a/scripts/mermaid-compat-fixtures.json b/scripts/mermaid-compat-fixtures.json index 5f3e27f7..e33a1cf0 100644 --- a/scripts/mermaid-compat-fixtures.json +++ b/scripts/mermaid-compat-fixtures.json @@ -83,6 +83,13 @@ "expectedEditableGate": "supported_family", "source": "classDiagram\nclass Repository~T~\nclass User\nRepository~T~ \"1\" --> \"*\" User : stores" }, + { + "name": "class-generic-multi-param", + "family": "classDiagram", + "expectedOfficial": "valid", + "expectedEditableGate": "supported_family", + "source": "classDiagram\nclass Map~K, V~\nclass Entry\nMap~K, V~ --> Entry : stores" + }, { "name": "class-invalid-relation", "family": "classDiagram", @@ -111,6 +118,13 @@ "expectedEditableGate": "supported_family", "source": "erDiagram\nORDER {\n uuid customer_id FK REFERENCES CUSTOMER\n string external_id UK\n}" }, + { + "name": "er-field-dotted-reference", + "family": "erDiagram", + "expectedOfficial": "valid", + "expectedEditableGate": "supported_family", + "source": "erDiagram\nORDER {\n uuid customer_id FK REFERENCES billing.Customer.id\n}" + }, { "name": "mindmap-basic", "family": "mindmap", @@ -125,6 +139,13 @@ "expectedEditableGate": "supported_family", "source": "mindmap\n root((Root))\n feature[[Child A]]\n decision{{Child B}}" }, + { + "name": "mindmap-dotted-aliases", + "family": "mindmap", + "expectedOfficial": "valid", + "expectedEditableGate": "supported_family", + "source": "mindmap\n platform.root((Root))\n platform.api[[Child A]]\n platform.branch(Child B)" + }, { "name": "journey-basic", "family": "journey", @@ -146,6 +167,20 @@ "expectedEditableGate": "supported_family", "source": "journey\ntitle Checkout\nsection Happy\n Search: User" }, + { + "name": "journey-colon-rich-step", + "family": "journey", + "expectedOfficial": "valid", + "expectedEditableGate": "supported_family", + "source": "journey\ntitle Incident Response\nsection Alerts\n HTTP: 500 Error: 1: SRE: On-call\n Recover service: 4: API: Team" + }, + { + "name": "journey-support-escalation", + "family": "journey", + "expectedOfficial": "valid", + "expectedEditableGate": "supported_family", + "source": "journey\ntitle Support Escalation\nsection Intake\n Customer reports issue: 2: Support\n Triage severity: 3: Support\nsection Escalation\n Hand off to engineering: 4: Support, Engineering\n Confirm resolution: 5: Support" + }, { "name": "state-notes-and-control", "family": "stateDiagram", @@ -195,6 +230,20 @@ "expectedEditableGate": "supported_family", "source": "flowchart TD\napi.gateway@{ shape: rect, label: \"API Gateway\" } --> db.primary@{ shape: cyl, label: \"Primary DB\" }" }, + { + "name": "flowchart-style-dotted-id", + "family": "flowchart", + "expectedOfficial": "valid", + "expectedEditableGate": "supported_family", + "source": "flowchart TD\napi.gateway[Gateway]\nstyle api.gateway fill:#dff,stroke:#08c,color:#024" + }, + { + "name": "flowchart-nested-subgraphs", + "family": "flowchart", + "expectedOfficial": "valid", + "expectedEditableGate": "supported_family", + "source": "flowchart TD\nsubgraph platform[Platform]\n subgraph api[API]\n gateway[Gateway] --> service[Service]\n end\nend" + }, { "name": "flowchart-unexpected-end", "family": "flowchart", @@ -202,6 +251,13 @@ "expectedEditableGate": "supported_family", "source": "flowchart TD\nA --> B\nend" }, + { + "name": "flowchart-auth-decision", + "family": "flowchart", + "expectedOfficial": "valid", + "expectedEditableGate": "supported_family", + "source": "flowchart TD\nuser[User] --> gateway[API Gateway]\ngateway --> auth{Authenticated?}\nauth -->|Yes| app[Dashboard]\nauth -->|No| login[Login]" + }, { "name": "state-composite-alias", "family": "stateDiagram", @@ -216,6 +272,13 @@ "expectedEditableGate": "supported_family", "source": "stateDiagram-v2\ndirection LR\n[*] --> Idle\nIdle --> Running\nRunning --> [*]" }, + { + "name": "state-composite-standalone-declarations", + "family": "stateDiagram", + "expectedOfficial": "valid", + "expectedEditableGate": "supported_family", + "source": "stateDiagram-v2\nstate Working {\n state Busy\n state Idle\n Busy --> Idle\n}\nIdle --> [*]" + }, { "name": "sequence-par-and", "family": "sequence", @@ -230,18 +293,46 @@ "expectedEditableGate": "supported_family", "source": "sequenceDiagram\nparticipant A\nparticipant B\nalt success\n note over A, B: Shared context\n A->>B: Request\nelse failure\n B-->>A: Error\nend" }, + { + "name": "sequence-critical-option", + "family": "sequence", + "expectedOfficial": "valid", + "expectedEditableGate": "supported_family", + "source": "sequenceDiagram\nparticipant A\nparticipant B\ncritical primary path\n A->>B: Request\noption fallback path\n B-->>A: Error\nend" + }, { "name": "architecture-rich-node-kinds", "family": "architecture", - "expectedOfficial": "valid", + "expectedOfficial": "invalid", "expectedEditableGate": "supported_family", "source": "architecture-beta\nperson user[User]\ncontainer app(server)[App]\ndatabase_container data(database)[Data Store]\nuser:R --> L:app : HTTPS\napp:R --> L:data : TCP:5432" }, + { + "name": "architecture-nested-groups", + "family": "architecture", + "expectedOfficial": "valid", + "expectedEditableGate": "supported_family", + "source": "architecture-beta\ngroup global[Global]\ngroup prod(cloud)[Prod] in global\nservice api(server)[API] in prod" + }, + { + "name": "architecture-edge-tier", + "family": "architecture", + "expectedOfficial": "valid", + "expectedEditableGate": "supported_family", + "source": "architecture-beta\ngroup edge[Edge]\nservice web(server)[Web] in edge\nservice api(server)[API]\nservice db(database)[Database]\nweb:R --> L:api : HTTPS:443\napi:R --> L:db : TCP:5432" + }, { "name": "unsupported-gitgraph", "family": "gitGraph", "expectedOfficial": "valid", "expectedEditableGate": "unsupported_family", "source": "gitGraph\ncommit id: \"A\"" + }, + { + "name": "missing-header-flow-only", + "family": "flowchart", + "expectedOfficial": "invalid", + "expectedEditableGate": "invalid_source", + "source": "A --> B" } ] diff --git a/scripts/mermaid-compat-fixtures.mjs b/scripts/mermaid-compat-fixtures.mjs new file mode 100644 index 00000000..d26241e9 --- /dev/null +++ b/scripts/mermaid-compat-fixtures.mjs @@ -0,0 +1,342 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const fixturesPath = path.join(__dirname, 'mermaid-compat-fixtures.json'); + +const rawFixtures = JSON.parse(fs.readFileSync(fixturesPath, 'utf8')); + +const FIXTURE_METADATA = { + 'flowchart-basic': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 2, minEdges: 1, requiredLabels: ['Start', 'End'] }, + layoutAssertions: { + maxBoundingWidth: 260, + maxBoundingHeight: 240, + requireUniquePositions: true, + orderedLabelsTopToBottom: ['Start', 'End'], + }, + }, + 'flowchart-subgraph-explicit-id': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 3, minEdges: 1, minSections: 1, requiredLabels: ['API Layer'] }, + layoutAssertions: { maxBoundingWidth: 420, maxBoundingHeight: 320, minSections: 1 }, + }, + 'flowchart-invalid-edge': { + bucket: 'valid_but_not_editable', + expectedImportState: 'unsupported_construct', + structuralAssertions: { maxNodes: 0, maxEdges: 0 }, + }, + 'flowchart-invalid-subgraph-close': { + bucket: 'editable_partial', + expectedImportState: 'editable_partial', + structuralAssertions: { minNodes: 2, minEdges: 1, diagnosticsMin: 1, minSections: 1 }, + }, + 'state-basic': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 3, minEdges: 2 }, + layoutAssertions: { maxBoundingWidth: 360, maxBoundingHeight: 280, requireUniquePositions: true }, + }, + 'state-invalid-direction': { + bucket: 'editable_partial', + expectedImportState: 'editable_partial', + structuralAssertions: { minNodes: 2, minEdges: 1, diagnosticsMin: 1 }, + }, + 'sequence-basic': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 2, minEdges: 1, minParticipants: 2 }, + layoutAssertions: { + minParticipants: 2, + requireSequenceLaneAlignment: true, + maxBoundingWidth: 520, + orderedLabelsLeftToRight: ['Alice', 'Bob'], + sameRowLabels: ['Alice', 'Bob'], + }, + }, + 'sequence-activation-and-alt': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 3, minEdges: 2, minParticipants: 2, minAnnotations: 1 }, + layoutAssertions: { minParticipants: 2, requireSequenceLaneAlignment: true, maxBoundingWidth: 620 }, + }, + 'sequence-invalid-message': { + bucket: 'editable_partial', + expectedImportState: 'editable_partial', + structuralAssertions: { minNodes: 1, maxEdges: 0, diagnosticsMin: 1, minParticipants: 1 }, + }, + 'sequence-partial-after-valid-message': { + bucket: 'editable_partial', + expectedImportState: 'editable_partial', + structuralAssertions: { minNodes: 2, minEdges: 1, diagnosticsMin: 1, minParticipants: 2 }, + }, + 'class-basic': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 2, minEdges: 1 }, + }, + 'class-cardinality-generic': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 2, minEdges: 1, requiredNodeIds: ['Repository', 'User'] }, + }, + 'class-generic-multi-param': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 2, minEdges: 1, requiredNodeIds: ['Map', 'Entry'] }, + }, + 'class-invalid-relation': { + bucket: 'editable_partial', + expectedImportState: 'editable_partial', + structuralAssertions: { minNodes: 1, diagnosticsMin: 1 }, + }, + 'er-basic': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 2, minEdges: 1 }, + }, + 'er-field-metadata': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 1, maxEdges: 0, requiredLabels: ['ORDER'] }, + }, + 'er-field-references-table-only': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 1, maxEdges: 0, requiredLabels: ['ORDER'] }, + }, + 'er-field-dotted-reference': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 1, maxEdges: 0, requiredLabels: ['ORDER'] }, + }, + 'mindmap-basic': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 3, minEdges: 2 }, + }, + 'mindmap-wrapped-nodes': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 3, minEdges: 2, requiredLabels: ['Root', 'Child A', 'Child B'] }, + }, + 'mindmap-dotted-aliases': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { + minNodes: 3, + minEdges: 2, + requiredLabels: ['Root', 'Child A', 'Child B'], + }, + }, + 'journey-basic': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 2, minEdges: 1, requiredLabels: ['Search', 'Buy'] }, + layoutAssertions: { + maxBoundingHeight: 220, + orderedLabelsTopToBottom: ['Search', 'Buy'], + }, + }, + 'journey-multiple-sections': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 2, maxEdges: 0, requiredLabels: ['Alert fires', 'Mitigate'] }, + }, + 'journey-invalid-score': { + bucket: 'valid_but_not_editable', + expectedImportState: 'unsupported_construct', + structuralAssertions: { maxNodes: 0, maxEdges: 0, diagnosticsMin: 1 }, + }, + 'journey-colon-rich-step': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 2, minEdges: 1, requiredLabels: ['HTTP: 500 Error', 'Recover service'] }, + }, + 'journey-support-escalation': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { + minNodes: 4, + minEdges: 2, + requiredLabels: ['Customer reports issue', 'Triage severity', 'Hand off to engineering', 'Confirm resolution'], + }, + }, + 'state-notes-and-control': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 4, minEdges: 4, requiredLabels: ['Idle'] }, + }, + 'architecture-official-basic': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 2, minEdges: 1, requiredLabels: ['API', 'Database'] }, + layoutAssertions: { + maxBoundingWidth: 420, + maxBoundingHeight: 260, + requireUniquePositions: true, + orderedLabelsLeftToRight: ['API', 'Database'], + }, + }, + 'architecture-title-basic': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 2, minEdges: 1, requiredLabels: ['API', 'Database'] }, + }, + 'architecture-extension-labeled-edge': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 2, minEdges: 1 }, + }, + 'flowchart-inline-class': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 2, minEdges: 1, requiredLabels: ['API', 'DB'] }, + }, + 'flowchart-class-assignment-line': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 2, maxEdges: 0, requiredLabels: ['API', 'DB'] }, + }, + 'flowchart-modern-annotation-dotted-ids': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 2, minEdges: 1, requiredNodeIds: ['api.gateway', 'db.primary'] }, + layoutAssertions: { maxBoundingWidth: 320, maxBoundingHeight: 240, requireUniquePositions: true }, + }, + 'flowchart-style-dotted-id': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 1, maxEdges: 0, requiredNodeIds: ['api.gateway'], requiredLabels: ['Gateway'] }, + layoutAssertions: { maxBoundingWidth: 200, maxBoundingHeight: 120 }, + }, + 'flowchart-nested-subgraphs': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { + minNodes: 4, + minEdges: 1, + minSections: 2, + requiredLabels: ['Platform', 'API', 'Gateway', 'Service'], + requiredParentIds: { + api: 'platform', + gateway: 'api', + service: 'api', + }, + }, + layoutAssertions: { maxBoundingWidth: 460, maxBoundingHeight: 360, minSections: 2 }, + }, + 'flowchart-unexpected-end': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 2, minEdges: 1 }, + }, + 'flowchart-auth-decision': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { + minNodes: 5, + minEdges: 4, + requiredLabels: ['User', 'API Gateway', 'Authenticated?', 'Dashboard', 'Login'], + }, + }, + 'state-composite-alias': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 4, minEdges: 3, minSections: 1, requiredLabels: ['Working Set'] }, + }, + 'state-direction-lr': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 4, minEdges: 3 }, + layoutAssertions: { maxBoundingWidth: 520, maxBoundingHeight: 440, requireUniquePositions: true }, + }, + 'state-composite-standalone-declarations': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { + minNodes: 3, + minEdges: 2, + requiredLabels: ['Working', 'Busy', 'Idle'], + requiredParentIds: { + Busy: 'Working', + Idle: 'Working', + }, + }, + layoutAssertions: { maxBoundingWidth: 420, maxBoundingHeight: 320, requireUniquePositions: true }, + }, + 'sequence-par-and': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 3, minEdges: 2, minParticipants: 3 }, + layoutAssertions: { minParticipants: 3, requireSequenceLaneAlignment: true, maxBoundingWidth: 760 }, + }, + 'sequence-note-inside-alt': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 3, minEdges: 2, minParticipants: 2, minNotes: 1, minAnnotations: 1 }, + layoutAssertions: { minParticipants: 2, requireSequenceLaneAlignment: true, requireNotesBelowParticipants: true, maxBoundingWidth: 620 }, + }, + 'sequence-critical-option': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 4, minEdges: 2, minParticipants: 2, minAnnotations: 2 }, + layoutAssertions: { minParticipants: 2, requireSequenceLaneAlignment: true, maxBoundingWidth: 620 }, + }, + 'architecture-rich-node-kinds': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { minNodes: 3, minEdges: 2, requiredLabels: ['User', 'App', 'Data Store'] }, + }, + 'architecture-nested-groups': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { + minNodes: 3, + maxEdges: 0, + requiredLabels: ['Global', 'Prod', 'API'], + requiredParentIds: { + prod: 'global', + api: 'prod', + }, + }, + }, + 'architecture-edge-tier': { + bucket: 'editable_full', + expectedImportState: 'editable_full', + structuralAssertions: { + minNodes: 4, + minEdges: 2, + requiredLabels: ['Edge', 'Web', 'API', 'Database'], + }, + }, + 'unsupported-gitgraph': { + bucket: 'valid_but_not_editable', + expectedImportState: 'unsupported_family', + structuralAssertions: { maxNodes: 0, maxEdges: 0 }, + }, + 'missing-header-flow-only': { + bucket: 'invalid_source', + expectedImportState: 'invalid_source', + structuralAssertions: { maxNodes: 0, maxEdges: 0, diagnosticsMin: 1 }, + }, +}; + +function enrichFixture(fixture) { + const metadata = FIXTURE_METADATA[fixture.name]; + if (!metadata) { + throw new Error(`Missing metadata for Mermaid compat fixture "${fixture.name}".`); + } + + return { + ...fixture, + ...metadata, + }; +} + +export const MERMAID_COMPAT_FIXTURES = rawFixtures.map(enrichFixture); diff --git a/scripts/mermaid-compat-report.mjs b/scripts/mermaid-compat-report.mjs index 880064ed..800635a0 100644 --- a/scripts/mermaid-compat-report.mjs +++ b/scripts/mermaid-compat-report.mjs @@ -1,7 +1,5 @@ import mermaid from 'mermaid'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { MERMAID_COMPAT_FIXTURES as fixtures } from './mermaid-compat-fixtures.mjs'; mermaid.initialize({ startOnLoad: false, @@ -26,11 +24,6 @@ const SUPPORTED_EDITABLE_FAMILIES = new Set([ 'sequenceDiagram', ]); -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const fixturesPath = path.join(__dirname, 'mermaid-compat-fixtures.json'); -const fixtures = JSON.parse(fs.readFileSync(fixturesPath, 'utf8')); - function classifyOfficialResult(official) { if (official.isValid) return 'valid'; if (official.diagnostics.some((message) => message.includes('DOMPurify'))) { diff --git a/src/components/FlowCanvas.tsx b/src/components/FlowCanvas.tsx index 6d38893b..535828cf 100644 --- a/src/components/FlowCanvas.tsx +++ b/src/components/FlowCanvas.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useShallow } from 'zustand/react/shallow'; import { useReactFlow, toFlowNode } from '@/lib/reactflowCompat'; @@ -26,6 +26,12 @@ import { useSelectionActions } from '@/store/selectionHooks'; import { useTabActions, useActiveTabId } from '@/store/tabHooks'; import { useCanvasViewSettings } from '@/store/viewHooks'; import { useMermaidDiagnosticsActions } from '@/store/selectionHooks'; +import { + clearImportLayoutMetadata, + isImportPendingLayoutNode, + readImportLayoutMetadata, +} from '@/services/importLayoutMetadata'; +import { composeDiagramForDisplay } from '@/services/composeDiagramForDisplay'; interface FlowCanvasProps { recordHistory: () => void; @@ -76,6 +82,7 @@ export const FlowCanvas: React.FC = ({ const reactFlowWrapper = useRef(null); const lastInteractionScreenPositionRef = useRef<{ x: number; y: number } | null>(null); const connectMenuSetterRef = useRef<((value: ConnectMenuState | null) => void) | null>(null); + const importStabilizationSignatureRef = useRef(null); const { screenToFlowPosition, fitView } = useReactFlow(); const clearPaneSelection = useCallback((): void => { @@ -266,6 +273,122 @@ export const FlowCanvas: React.FC = ({ }; }, [interactionLowDetailModeActive]); + // Stable signal: changes only when import-pending node IDs or their measured + // state change — not on every node array reference update. + const importStabilizationKey = useMemo(() => { + const pending = nodes.filter(isImportPendingLayoutNode); + if (pending.length === 0) return null; + return pending + .map((n) => { + const m = (n as FlowNode & { measured?: { width?: number; height?: number } }).measured; + return `${n.id}:${m?.width ?? '?'}x${m?.height ?? '?'}`; + }) + .join('|'); + }, [nodes]); + + useEffect(() => { + if (!importStabilizationKey) { + importStabilizationSignatureRef.current = null; + return; + } + + const pendingImportNodes = nodes.filter(isImportPendingLayoutNode); + const metadata = readImportLayoutMetadata(pendingImportNodes); + if (!metadata || importStabilizationSignatureRef.current === metadata.signature) { + return; + } + + const measurableNodes = pendingImportNodes.filter((node) => node.type !== 'section'); + const allMeasured = + measurableNodes.length > 0 + && measurableNodes.every((node) => { + const m = (node as FlowNode & { measured?: { width?: number; height?: number } }).measured; + return typeof m?.width === 'number' && typeof m?.height === 'number'; + }); + + if (!allMeasured) { + return; + } + + importStabilizationSignatureRef.current = metadata.signature; + + const runConvergenceLoop = async (signature: string): Promise => { + const { clearLayoutCache } = await import('@/services/elkLayout'); + const { assignSmartHandles } = await import('@/services/smartEdgeRouting'); + + // Up to 3 layout passes until node positions converge within 1px. + // Mermaid.js does the same — first pass uses estimated sizes, subsequent + // passes use React Flow's measured dimensions for precision. + const MAX_PASSES = 3; + const CONVERGENCE_THRESHOLD_PX = 1; + + let prevPositions: Map | null = null; + + for (let pass = 0; pass < MAX_PASSES; pass++) { + const state = useFlowStore.getState(); + const currentMetadata = readImportLayoutMetadata(state.nodes); + if (!currentMetadata || currentMetadata.signature !== signature) return; + + clearLayoutCache(); + const { nodes: layoutedNodes, edges: layoutedEdges } = await composeDiagramForDisplay( + state.nodes, + state.edges, + { + direction: currentMetadata.direction, + spacing: currentMetadata.spacing, + contentDensity: currentMetadata.contentDensity, + diagramType: currentMetadata.diagramType, + source: 'import', + } + ); + + const nextPositions = new Map(layoutedNodes.map((n) => [n.id, n.position])); + + const converged = + prevPositions !== null && + layoutedNodes.every((n) => { + const prev = prevPositions!.get(n.id); + return ( + prev !== undefined && + Math.abs(n.position.x - prev.x) <= CONVERGENCE_THRESHOLD_PX && + Math.abs(n.position.y - prev.y) <= CONVERGENCE_THRESHOLD_PX + ); + }); + + prevPositions = nextPositions; + + const latestMetadata = readImportLayoutMetadata(useFlowStore.getState().nodes); + if (!latestMetadata || latestMetadata.signature !== signature) return; + + const smartEdges = assignSmartHandles(layoutedNodes, layoutedEdges); + const finalNodes = converged || pass === MAX_PASSES - 1 + ? clearImportLayoutMetadata(layoutedNodes) + : layoutedNodes; + + setNodes(finalNodes); + setEdges(smartEdges); + + if (converged) break; + + // Yield to React to render and measure the updated nodes before next pass. + await new Promise((resolve) => { window.setTimeout(resolve, 60); }); + } + + fitView({ duration: 500, padding: 0.2 }); + }; + + const timer = window.setTimeout(() => { + void runConvergenceLoop(metadata.signature).finally(() => { + if (importStabilizationSignatureRef.current === metadata.signature) { + importStabilizationSignatureRef.current = null; + } + }); + // Wait for React to render and measure nodes before first pass. + }, 60); + + return () => window.clearTimeout(timer); + }, [fitView, importStabilizationKey, nodes, setEdges, setNodes]); + const selectedNodeCount = nodes.filter((node) => node.selected).length; const selectedEdgeCount = edges.filter((edge) => edge.selected).length; const selectionAnnouncement = diff --git a/src/components/custom-edge/SequenceMessageEdge.tsx b/src/components/custom-edge/SequenceMessageEdge.tsx index 80acaeda..69c72a15 100644 --- a/src/components/custom-edge/SequenceMessageEdge.tsx +++ b/src/components/custom-edge/SequenceMessageEdge.tsx @@ -7,7 +7,7 @@ import { SEQ_ACTOR_EXTRA_H, SEQ_MSG_OFFSET, SEQ_MSG_SPACING, -} from '@/components/custom-nodes/SequenceParticipantNode'; +} from '@/services/sequence/layoutConstants'; // Resolved at edge render time from source node data (passed via edge data). const SELF_LOOP_W = 56; diff --git a/src/components/custom-nodes/SequenceParticipantNode.tsx b/src/components/custom-nodes/SequenceParticipantNode.tsx index 70f63642..c49243ac 100644 --- a/src/components/custom-nodes/SequenceParticipantNode.tsx +++ b/src/components/custom-nodes/SequenceParticipantNode.tsx @@ -7,15 +7,14 @@ import { InlineTextEditSurface } from '@/components/InlineTextEditSurface'; import { NodeChrome } from '@/components/NodeChrome'; import { getTransformDiagnosticsAttrs } from '@/components/transformDiagnostics'; import { resolveContainerVisualStyle } from '@/theme'; - -// These constants are used by SequenceMessageEdge to position arrows correctly. -export const SEQ_BOX_H = 48; -export const SEQ_ACTOR_EXTRA_H = 40; -export const SEQ_LIFELINE_H = 500; -export const SEQ_MSG_OFFSET = 20; // gap between box bottom and first message -export const SEQ_MSG_SPACING = 52; - -const SEQ_NODE_W = 140; +import { + SEQ_ACTOR_EXTRA_H, + SEQ_BOX_H, + SEQ_LIFELINE_H, + SEQ_NODE_W, + SEQ_MSG_OFFSET, + SEQ_MSG_SPACING, +} from '@/services/sequence/layoutConstants'; // Single invisible handle at top-center — used by SequenceMessageEdge const TOP_HANDLE_ONLY = [{ id: 'top', position: Position.Top, side: 'top' as const }]; diff --git a/src/components/flow-canvas/useFlowCanvasPaste.ts b/src/components/flow-canvas/useFlowCanvasPaste.ts index c2d8a806..c4b6d0da 100644 --- a/src/components/flow-canvas/useFlowCanvasPaste.ts +++ b/src/components/flow-canvas/useFlowCanvasPaste.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { useFlowStore } from '@/store'; -import type { ParseDiagnostic } from '@/lib/openFlowDSLParser'; + import type { FlowEdge, FlowNode } from '@/lib/types'; import type { MermaidDiagnosticsSnapshot } from '@/store/types'; import { @@ -28,12 +28,41 @@ import { enrichNodesWithIcons } from '@/lib/nodeEnricher'; import { normalizeNodeIconData } from '@/lib/nodeIconState'; import { assignSmartHandles } from '@/services/smartEdgeRouting'; import type { LayoutOptions } from '@/services/elk-layout/types'; +import { attachImportLayoutMetadata } from '@/services/importLayoutMetadata'; + +const IMPORT_LABEL_COMPACT_THRESHOLD = 10; +const IMPORT_LABEL_VERBOSE_THRESHOLD = 20; +const IMPORT_LARGE_DIAGRAM_THRESHOLD = 36; -function combineMermaidDiagnostics( - officialDiagnostics: ParseDiagnostic[], - parserDiagnostics: ParseDiagnostic[] -): ParseDiagnostic[] { - return [...officialDiagnostics, ...parserDiagnostics]; +function getAverageLabelLength(nodes: FlowNode[]): number { + if (nodes.length === 0) return 0; + const total = nodes.reduce((sum, node) => sum + String(node.data?.label ?? '').trim().length, 0); + return total / nodes.length; +} + +function resolveImportLayoutOptions( + nodes: FlowNode[], + diagramType?: string +): { spacing: NonNullable; contentDensity: NonNullable } { + const avg = getAverageLabelLength(nodes); + + let spacing: NonNullable; + if (diagramType === 'architecture') { + spacing = nodes.length >= 24 ? 'normal' : 'compact'; + } else if (avg <= IMPORT_LABEL_COMPACT_THRESHOLD) { + spacing = 'compact'; + } else if (avg <= IMPORT_LABEL_VERBOSE_THRESHOLD) { + spacing = nodes.length >= IMPORT_LARGE_DIAGRAM_THRESHOLD ? 'loose' : 'normal'; + } else { + spacing = 'loose'; + } + + const contentDensity: NonNullable = + avg <= IMPORT_LABEL_COMPACT_THRESHOLD ? 'compact' + : avg <= IMPORT_LABEL_VERBOSE_THRESHOLD ? 'balanced' + : 'verbose'; + + return { spacing, contentDensity }; } type SetFlowNodes = (payload: FlowNode[] | ((nodes: FlowNode[]) => FlowNode[])) => void; @@ -79,11 +108,6 @@ export function useFlowCanvasPaste({ getLastInteractionFlowPosition, getCanvasCenterFlowPosition, }: UseFlowCanvasPasteParams) { - const getImportSpacing = (nodeCount: number): LayoutOptions['spacing'] => { - if (nodeCount <= 10) return 'loose'; - if (nodeCount <= 25) return 'normal'; - return 'compact'; - }; const safelyEnrichImportedNodes = useCallback( (nodes: FlowNode[], diagramType: MermaidDiagnosticsSnapshot['diagramType']): FlowNode[] => { @@ -153,7 +177,7 @@ export function useFlowCanvasPaste({ const result = parseMermaidByType(pastedText, { architectureStrictMode }); const parserDiagnostics = normalizeParseDiagnostics(result.diagnostics); - const diagnostics = combineMermaidDiagnostics(officialDiagnostics, parserDiagnostics); + const diagnostics = [...officialDiagnostics, ...parserDiagnostics]; if (!result.error) { if (diagnostics.length > 0) { @@ -187,17 +211,29 @@ export function useFlowCanvasPaste({ const { clearLayoutCache } = await import('@/services/elkLayout'); clearLayoutCache(); const layoutDirection = resolveLayoutDirection(result); + const { spacing, contentDensity } = resolveImportLayoutOptions(enrichedNodes, result.diagramType); const { nodes: layoutedNodes, edges: layoutedEdges } = await composeDiagramForDisplay( enrichedNodes, result.edges, { direction: layoutDirection, - spacing: getImportSpacing(enrichedNodes.length), + spacing, + contentDensity, diagramType: result.diagramType, + source: 'import', } ); const smartEdges = assignSmartHandles(layoutedNodes, layoutedEdges); - setNodes(layoutedNodes); + const importSignature = `mermaid-import-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + setNodes( + attachImportLayoutMetadata(layoutedNodes, { + signature: importSignature, + direction: layoutDirection, + spacing, + contentDensity, + diagramType: result.diagramType, + }) + ); setEdges(smartEdges); } catch { setNodes(enrichedNodes); diff --git a/src/diagram-types/architecture/plugin.test.ts b/src/diagram-types/architecture/plugin.test.ts index bea77b13..4c80e5b0 100644 --- a/src/diagram-types/architecture/plugin.test.ts +++ b/src/diagram-types/architecture/plugin.test.ts @@ -53,6 +53,22 @@ describe('ARCHITECTURE_PLUGIN', () => { expect(result.edges[0].targetHandle).toBe('left'); }); + it('preserves nested architecture groups and parented group metadata', () => { + const input = ` + architecture-beta + group global[Global] + group prod(cloud)[Prod] in global + service api(server)[API] in prod + `; + + const result = ARCHITECTURE_PLUGIN.parseMermaid(input); + expect(result.error).toBeUndefined(); + expect(result.nodes.find((node) => node.id === 'prod')?.parentId).toBe('global'); + expect(result.nodes.find((node) => node.id === 'prod')?.data.archBoundaryId).toBe('global'); + expect(result.nodes.find((node) => node.id === 'prod')?.data.archProvider).toBe('cloud'); + expect(result.nodes.find((node) => node.id === 'api')?.parentId).toBe('prod'); + }); + it('extracts protocol and port metadata when label follows protocol:port format', () => { const input = ` architecture-beta diff --git a/src/diagram-types/classDiagram/plugin.test.ts b/src/diagram-types/classDiagram/plugin.test.ts index 9749f3fa..45468caf 100644 --- a/src/diagram-types/classDiagram/plugin.test.ts +++ b/src/diagram-types/classDiagram/plugin.test.ts @@ -63,6 +63,24 @@ describe('CLASS_DIAGRAM_PLUGIN', () => { expect(result.edges[0].data.classRelationTargetCardinality).toBe('*'); }); + it('parses multi-parameter generic identifiers with Mermaid ~...~ syntax', () => { + const input = ` + classDiagram + class Map~K, V~ + class Entry + Map~K, V~ --> Entry : stores + `; + + const result = CLASS_DIAGRAM_PLUGIN.parseMermaid(input); + expect(result.error).toBeUndefined(); + expect(result.nodes.find((node) => node.id === 'Map')?.data.label).toBe('Map'); + expect(result.edges[0]).toMatchObject({ + source: 'Map', + target: 'Entry', + }); + expect(result.edges[0].data.classRelationLabel).toBe('stores'); + }); + it('emits diagnostics for malformed class lines and relation syntax', () => { const input = ` classDiagram diff --git a/src/diagram-types/classDiagram/plugin.ts b/src/diagram-types/classDiagram/plugin.ts index 3a760c20..f6f7fe69 100644 --- a/src/diagram-types/classDiagram/plugin.ts +++ b/src/diagram-types/classDiagram/plugin.ts @@ -23,7 +23,9 @@ interface RelationRecord { targetCardinality?: string; } -const CLASS_ID_PATTERN = '[A-Za-z_][\\w.<>~,]*'; +const CLASS_ID_START_PATTERN = '[A-Za-z_]'; +const CLASS_ID_SEGMENT_PATTERN = '(?:[\\w.]|<[^>]+>|~[^~]+~|,)'; +const CLASS_ID_PATTERN = `${CLASS_ID_START_PATTERN}${CLASS_ID_SEGMENT_PATTERN}*`; function normalizeClassIdentifier(value: string): string { return value.trim().replace(/~([^~]+)~/g, '<$1>'); diff --git a/src/diagram-types/erDiagram/plugin.test.ts b/src/diagram-types/erDiagram/plugin.test.ts index 5cd1c84a..3c717dad 100644 --- a/src/diagram-types/erDiagram/plugin.test.ts +++ b/src/diagram-types/erDiagram/plugin.test.ts @@ -140,4 +140,27 @@ describe('ER_DIAGRAM_PLUGIN', () => { ]) ); }); + + it('preserves dotted REFERENCES table paths and field names', () => { + const input = ` + erDiagram + ORDER { + uuid customer_id FK REFERENCES billing.Customer.id + } + `; + + const result = ER_DIAGRAM_PLUGIN.parseMermaid(input); + expect(result.error).toBeUndefined(); + const fields = result.nodes[0].data.erFields ?? []; + expect(fields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'customer_id', + isForeignKey: true, + referencesTable: 'billing.Customer', + referencesField: 'id', + }), + ]) + ); + }); }); diff --git a/src/diagram-types/erDiagram/plugin.ts b/src/diagram-types/erDiagram/plugin.ts index 81ba4ba5..737f6b7a 100644 --- a/src/diagram-types/erDiagram/plugin.ts +++ b/src/diagram-types/erDiagram/plugin.ts @@ -23,6 +23,26 @@ interface RelationRecord { const ENTITY_ID_PATTERN = '[A-Za-z_][\\w.]*'; +function parseReferenceTarget(reference: string): { + referencesTable?: string; + referencesField?: string; +} { + const trimmed = reference.trim(); + if (!trimmed) { + return {}; + } + + const lastDotIndex = trimmed.lastIndexOf('.'); + if (lastDotIndex <= 0 || lastDotIndex === trimmed.length - 1) { + return { referencesTable: trimmed }; + } + + return { + referencesTable: trimmed.slice(0, lastDotIndex), + referencesField: trimmed.slice(lastDotIndex + 1), + }; +} + function createEmptyEntity(id: string): EntityRecord { return { id, @@ -71,10 +91,9 @@ function parseMermaidErField(line: string): ErField { continue; } if (token === 'REFERENCES' && rawConstraints[index + 1]) { - const reference = rawConstraints[index + 1]; - const [referencesTable, referencesField] = reference.split('.'); - field.referencesTable = referencesTable; - field.referencesField = referencesField; + const reference = parseReferenceTarget(rawConstraints[index + 1]); + field.referencesTable = reference.referencesTable; + field.referencesField = reference.referencesField; index += 1; } } diff --git a/src/diagram-types/journey/plugin.test.ts b/src/diagram-types/journey/plugin.test.ts index a77e172c..d011bd66 100644 --- a/src/diagram-types/journey/plugin.test.ts +++ b/src/diagram-types/journey/plugin.test.ts @@ -67,6 +67,23 @@ describe('JOURNEY_PLUGIN', () => { expect(result.nodes[1].data.color).toBe('red'); }); + it('preserves journey tasks and actors that contain colons', () => { + const input = ` + journey + section Incident Flow + HTTP: 500 Error: 1: SRE: On-call + Recover service: 4: API: Team + `; + + const result = JOURNEY_PLUGIN.parseMermaid(input); + expect(result.error).toBeUndefined(); + expect(result.nodes).toHaveLength(2); + expect(result.nodes[0].data.journeyTask).toBe('HTTP: 500 Error'); + expect(result.nodes[0].data.journeyScore).toBe(1); + expect(result.nodes[0].data.journeyActor).toBe('SRE: On-call'); + expect(result.nodes[1].data.journeyActor).toBe('API: Team'); + }); + it('returns diagnostics for malformed section and malformed score-like steps while preserving valid steps', () => { const input = ` journey diff --git a/src/diagram-types/journey/plugin.ts b/src/diagram-types/journey/plugin.ts index 0fac6c90..ff02b742 100644 --- a/src/diagram-types/journey/plugin.ts +++ b/src/diagram-types/journey/plugin.ts @@ -36,39 +36,57 @@ interface ParsedJourneyStep { scoreMalformed: boolean; } +function buildJourneyStep( + task: string, + scoreMalformed: boolean, + score?: number, + actor?: string +): ParsedJourneyStep | null { + const normalizedTask = task.trim(); + if (!normalizedTask) { + return null; + } + const normalizedActor = actor?.trim() || undefined; + + return { + task: normalizedTask, + actor: normalizedActor, + score, + scoreMalformed, + }; +} + +function joinJourneySegments(parts: string[]): string { + return parts.join(': '); +} + function parseJourneyStep(line: string): ParsedJourneyStep | null { const parts = line.split(':').map((item) => item.trim()); if (parts.length === 0) return null; - const task = parts[0]; - if (!task) return null; - if (parts.length === 1) { - return { task, scoreMalformed: false }; + return buildJourneyStep(parts[0], false); } - if (parts.length === 2) { - const score = normalizeScore(parts[1]); + for (let scoreIndex = parts.length - 1; scoreIndex >= 1; scoreIndex -= 1) { + const score = normalizeScore(parts[scoreIndex]); if (score === null) { - return { task, scoreMalformed: true }; + continue; } - return { task, score, scoreMalformed: false }; + return buildJourneyStep( + joinJourneySegments(parts.slice(0, scoreIndex)), + false, + score, + joinJourneySegments(parts.slice(scoreIndex + 1)) + ); } - const score = normalizeScore(parts[1]); - if (score === null) { - return { - task, - actor: parts.slice(2).join(':').trim() || undefined, - scoreMalformed: true, - }; - } - return { - task, - score, - actor: parts.slice(2).join(':').trim() || undefined, - scoreMalformed: false, - }; + return buildJourneyStep( + parts[0], + true, + undefined, + parts.length > 2 ? joinJourneySegments(parts.slice(2)) : undefined + ); } function parseJourney(input: string): { nodes: FlowNode[]; edges: FlowEdge[]; error?: string; diagnostics?: string[] } { diff --git a/src/diagram-types/mindmap/plugin.test.ts b/src/diagram-types/mindmap/plugin.test.ts index f7b0e74f..52146cea 100644 --- a/src/diagram-types/mindmap/plugin.test.ts +++ b/src/diagram-types/mindmap/plugin.test.ts @@ -144,4 +144,19 @@ describe('MINDMAP_PLUGIN', () => { expect(result.nodes.find((node) => node.data.label === 'Topic')?.data.mindmapAlias).toBe('feature'); expect(result.nodes.find((node) => node.data.label === 'Child')?.data.mindmapAlias).toBe('branch'); }); + + it('preserves dotted Mermaid alias prefixes for wrapped nodes', () => { + const input = ` + mindmap + platform.root((Root)) + platform.api[[Topic]] + platform.branch(Child) + `; + + const result = MINDMAP_PLUGIN.parseMermaid(input); + expect(result.error).toBeUndefined(); + expect(result.nodes.find((node) => node.data.label === 'Root')?.data.mindmapAlias).toBe('platform.root'); + expect(result.nodes.find((node) => node.data.label === 'Topic')?.data.mindmapAlias).toBe('platform.api'); + expect(result.nodes.find((node) => node.data.label === 'Child')?.data.mindmapAlias).toBe('platform.branch'); + }); }); diff --git a/src/diagram-types/mindmap/plugin.ts b/src/diagram-types/mindmap/plugin.ts index b91b3926..1b3406a5 100644 --- a/src/diagram-types/mindmap/plugin.ts +++ b/src/diagram-types/mindmap/plugin.ts @@ -17,8 +17,17 @@ interface StructuredNode extends ParsedMindmapNode { const X_GAP = 260; const Y_GAP = 110; const ROOT_GAP = 80; +const MINDMAP_ALIAS_PATTERN = '[A-Za-z_][\\w.-]*'; + +function createWrappedMindmapPattern(open: string, close: string): RegExp { + return new RegExp(`^(${MINDMAP_ALIAS_PATTERN})?\\s*${open}(.+)${close}$`); +} function getIndentDepth(rawLine: string): number { + return Math.floor(getLeadingIndentUnits(rawLine) / 2); +} + +function getLeadingIndentUnits(rawLine: string): number { let indentUnits = 0; for (const char of rawLine) { if (char === ' ') { @@ -31,7 +40,7 @@ function getIndentDepth(rawLine: string): number { } break; } - return Math.floor(indentUnits / 2); + return indentUnits; } function extractMindmapLabel( @@ -53,13 +62,13 @@ function extractMindmapLabel( wrapper: NonNullable; pattern: RegExp; }> = [ - { wrapper: 'double-circle', pattern: /^([A-Za-z_][\w-]*)?\s*\(\((.+)\)\)$/ }, - { wrapper: 'double-square', pattern: /^([A-Za-z_][\w-]*)?\s*\[\[(.+)\]\]$/ }, - { wrapper: 'stadium', pattern: /^([A-Za-z_][\w-]*)?\s*\(\[(.+)\]\)$/ }, - { wrapper: 'subroutine', pattern: /^([A-Za-z_][\w-]*)?\s*\[\((.+)\)\]$/ }, - { wrapper: 'square', pattern: /^([A-Za-z_][\w-]*)?\s*\[(.+)\]$/ }, - { wrapper: 'rounded', pattern: /^([A-Za-z_][\w-]*)?\s*\((.+)\)$/ }, - { wrapper: 'hexagon', pattern: /^([A-Za-z_][\w-]*)?\s*\{\{(.+)\}\}$/ }, + { wrapper: 'double-circle', pattern: createWrappedMindmapPattern('\\(\\(', '\\)\\)') }, + { wrapper: 'double-square', pattern: createWrappedMindmapPattern('\\[\\[', '\\]\\]') }, + { wrapper: 'stadium', pattern: createWrappedMindmapPattern('\\(\\[', '\\]\\)') }, + { wrapper: 'subroutine', pattern: createWrappedMindmapPattern('\\[\\(', '\\)\\]') }, + { wrapper: 'square', pattern: createWrappedMindmapPattern('\\[', '\\]') }, + { wrapper: 'rounded', pattern: createWrappedMindmapPattern('\\(', '\\)') }, + { wrapper: 'hexagon', pattern: createWrappedMindmapPattern('\\{\\{', '\\}\\}') }, ]; for (const definition of wrapperDefinitions) { @@ -76,7 +85,7 @@ function extractMindmapLabel( function hasMalformedWrappedLabel(rawContent: string): boolean { const trimmed = rawContent.trim(); - const compact = trimmed.replace(/^[A-Za-z_][\w-]*\s*/, ''); + const compact = trimmed.replace(new RegExp(`^${MINDMAP_ALIAS_PATTERN}\\s*`), ''); const wrappers: Array<{ open: string; close: string }> = [ { open: '((', close: '))' }, { open: '[[', close: ']]' }, @@ -118,13 +127,7 @@ function parseMindmap(input: string): { nodes: FlowNode[]; edges: FlowEdge[]; er const parsedLabel = extractMindmapLabel(rawLine); if (!parsedLabel) continue; - const indentUnits = rawLine - .split('') - .reduce((sum, char) => { - if (char === ' ') return sum + 1; - if (char === '\t') return sum + 2; - return sum; - }, 0); + const indentUnits = getLeadingIndentUnits(rawLine); if (indentUnits % 2 !== 0) { diagnostics.push(`Odd indentation width at line ${lineNumber}; mindmap expects 2-space indentation steps.`); } diff --git a/src/diagram-types/sequence/plugin.test.ts b/src/diagram-types/sequence/plugin.test.ts index 55509bfc..bf1bc8b2 100644 --- a/src/diagram-types/sequence/plugin.test.ts +++ b/src/diagram-types/sequence/plugin.test.ts @@ -183,4 +183,29 @@ describe('SEQUENCE_PLUGIN', () => { branchKind: 'start', }); }); + + it('preserves critical and option branch metadata on sequence messages', () => { + const input = `sequenceDiagram + participant A + participant B + critical primary path + A->>B: Request + option fallback path + B-->>A: Error + end`; + + const result = SEQUENCE_PLUGIN.parseMermaid(input); + + expect(result.error).toBeUndefined(); + expect(result.edges[0].data?.seqFragment).toMatchObject({ + type: 'critical', + condition: 'primary path', + branchKind: 'start', + }); + expect(result.edges[1].data?.seqFragment).toMatchObject({ + type: 'critical', + condition: 'fallback path', + branchKind: 'option', + }); + }); }); diff --git a/src/diagram-types/sequence/plugin.ts b/src/diagram-types/sequence/plugin.ts index 84ae1cfe..7bd215d7 100644 --- a/src/diagram-types/sequence/plugin.ts +++ b/src/diagram-types/sequence/plugin.ts @@ -19,7 +19,7 @@ interface ParsedFragment { id: string; type: 'alt' | 'loop' | 'opt' | 'par' | 'break' | 'critical'; condition: string; - branchKind: 'start' | 'else' | 'and'; + branchKind: 'start' | 'else' | 'and' | 'option'; startOrder: number; endOrder: number; } @@ -92,6 +92,50 @@ function parseSequence(input: string): { startOrder: number; }> = []; + function pushCompletedFragmentBranch( + fragment: { + id: string; + type: ParsedFragment['type']; + condition: string; + branchKind: ParsedFragment['branchKind']; + startOrder: number; + } + ): void { + fragments.push({ + id: `${fragment.id}-branch-${fragments.length + 1}`, + type: fragment.type, + condition: fragment.condition, + branchKind: fragment.branchKind, + startOrder: fragment.startOrder, + endOrder: messageOrder, + }); + } + + function switchFragmentBranch( + line: string, + params: { + keyword: 'else' | 'and' | 'option'; + allowedType: 'alt' | 'par' | 'critical'; + branchKind: 'else' | 'and' | 'option'; + } + ): boolean { + const match = line.match(new RegExp(`^${params.keyword}\\s+(.+)$`, 'i')); + if (!match || fragmentStack.length === 0) { + return false; + } + + const top = fragmentStack[fragmentStack.length - 1]; + if (top.type !== params.allowedType) { + return true; + } + + pushCompletedFragmentBranch(top); + top.condition = match[1].trim(); + top.branchKind = params.branchKind; + top.startOrder = messageOrder; + return true; + } + function ensureParticipant(name: string): void { if (knownIds.has(name)) return; participants.push({ id: name, label: name, kind: 'participant' }); @@ -145,55 +189,40 @@ function parseSequence(input: string): { continue; } - const elseMatch = line.match(/^else\s+(.+)$/i); - if (elseMatch && fragmentStack.length > 0) { - const top = fragmentStack[fragmentStack.length - 1]; - if (top.type === 'alt') { - fragments.push({ - id: `${top.id}-branch-${fragments.length + 1}`, - type: top.type, - condition: top.condition, - branchKind: top.branchKind, - startOrder: top.startOrder, - endOrder: messageOrder, - }); - top.condition = elseMatch[1].trim(); - top.branchKind = 'else'; - top.startOrder = messageOrder; - } + if ( + switchFragmentBranch(line, { + keyword: 'else', + allowedType: 'alt', + branchKind: 'else', + }) + ) { continue; } - const andMatch = line.match(/^and\s+(.+)$/i); - if (andMatch && fragmentStack.length > 0) { - const top = fragmentStack[fragmentStack.length - 1]; - if (top.type === 'par') { - fragments.push({ - id: `${top.id}-branch-${fragments.length + 1}`, - type: top.type, - condition: top.condition, - branchKind: top.branchKind, - startOrder: top.startOrder, - endOrder: messageOrder, - }); - top.condition = andMatch[1].trim(); - top.branchKind = 'and'; - top.startOrder = messageOrder; - } + if ( + switchFragmentBranch(line, { + keyword: 'and', + allowedType: 'par', + branchKind: 'and', + }) + ) { + continue; + } + + if ( + switchFragmentBranch(line, { + keyword: 'option', + allowedType: 'critical', + branchKind: 'option', + }) + ) { continue; } if (/^end\b/i.test(line)) { if (fragmentStack.length > 0) { const top = fragmentStack.pop()!; - fragments.push({ - id: `${top.id}-branch-${fragments.length + 1}`, - type: top.type, - condition: top.condition, - branchKind: top.branchKind, - startOrder: top.startOrder, - endOrder: messageOrder, - }); + pushCompletedFragmentBranch(top); } continue; } diff --git a/src/diagram-types/stateDiagram/plugin.test.ts b/src/diagram-types/stateDiagram/plugin.test.ts index ab53bca1..5424b803 100644 --- a/src/diagram-types/stateDiagram/plugin.test.ts +++ b/src/diagram-types/stateDiagram/plugin.test.ts @@ -51,6 +51,23 @@ describe('STATE_DIAGRAM_PLUGIN', () => { expect(busyNode?.parentId).toBe('Working'); }); + it('keeps standalone state declarations parented inside composite blocks', () => { + const input = ` + stateDiagram-v2 + state Working { + state Busy + state Idle + Busy --> Idle + } + Idle --> [*] + `; + + const result = STATE_DIAGRAM_PLUGIN.parseMermaid(input); + expect(result.error).toBeUndefined(); + expect(result.nodes.find((node) => node.id === 'Busy')?.parentId).toBe('Working'); + expect(result.nodes.find((node) => node.id === 'Idle')?.parentId).toBe('Working'); + }); + it('renders state notes as annotation nodes instead of rejecting them', () => { const input = ` stateDiagram-v2 @@ -66,6 +83,21 @@ describe('STATE_DIAGRAM_PLUGIN', () => { expect(result.diagnostics?.some((message) => message.includes('note syntax'))).not.toBe(true); }); + it('accepts quoted note targets for aliased composite states', () => { + const input = ` + stateDiagram-v2 + state "Working Set" as WorkingSet { + [*] --> Busy + } + note left of "WorkingSet": Parent note + `; + + const result = STATE_DIAGRAM_PLUGIN.parseMermaid(input); + expect(result.error).toBeUndefined(); + expect(result.nodes.some((node) => node.data.stateNoteTarget === 'WorkingSet')).toBe(true); + expect(result.diagnostics?.some((message) => message.includes('note syntax'))).not.toBe(true); + }); + it('parses explicit fork and join control states', () => { const input = ` stateDiagram-v2 diff --git a/src/diagram-types/stateDiagram/plugin.ts b/src/diagram-types/stateDiagram/plugin.ts index e3ae8516..e1fb48f2 100644 --- a/src/diagram-types/stateDiagram/plugin.ts +++ b/src/diagram-types/stateDiagram/plugin.ts @@ -17,6 +17,8 @@ interface StateControlRecord { kind: 'fork' | 'join'; } +const STATE_DIAGRAM_NOTE_RE = /^note\s+(left of|right of|over)\s+("?)([^":]+)\2\s*:\s*(.+)$/i; + function normalizeStateTransitionLabels(input: string): string { const lines = input.replace(/\r\n/g, '\n').split('\n'); const normalized = lines.map((rawLine) => { @@ -36,6 +38,48 @@ function normalizeStateTransitionLabels(input: string): string { return normalized.join('\n'); } +function extractDeclaredStateId(line: string): string | null { + const aliasCompositeMatch = line.match( + /^state\s+"([^"]+)"\s+as\s+([A-Za-z_][\w.-]*)(?:\s+<<(fork|join)>>)?\s*\{$/i + ); + if (aliasCompositeMatch) { + return aliasCompositeMatch[2].trim(); + } + + const aliasMatch = line.match( + /^state\s+"([^"]+)"\s+as\s+([A-Za-z_][\w.-]*)(?:\s+<<(fork|join)>>)?\s*$/i + ); + if (aliasMatch) { + return aliasMatch[2].trim(); + } + + const compositeMatch = line.match(/^state\s+("?)([^"{]+)\1\s*\{$/i); + if (compositeMatch) { + return compositeMatch[2].trim(); + } + + const simpleMatch = line.match(/^state\s+([A-Za-z_][\w.-]*)(?:\s+<<(fork|join)>>)?\s*$/i); + if (simpleMatch) { + return simpleMatch[1].trim(); + } + + const descriptionMatch = line.match(/^([A-Za-z_][\w.-]*)\s*:\s*(.+)$/); + if (descriptionMatch) { + return descriptionMatch[1].trim(); + } + + return null; +} + +function extractTransitionStateIds(line: string): string[] { + const transitionMatch = line.match(/^(.+?)\s+(<-->|<--|-->|==>|-.->)\s+(.+?)(?:\s*:\s*(.+))?$/); + if (!transitionMatch) { + return []; + } + + return [transitionMatch[1].trim(), transitionMatch[3].trim()].filter((value) => value !== '[*]'); +} + function collectStateDiagramDiagnostics(input: string): { diagnostics: string[]; direction?: 'TB' | 'LR' } { const diagnostics: string[] = []; const lines = input.replace(/\r\n/g, '\n').split('\n'); @@ -65,7 +109,7 @@ function collectStateDiagramDiagnostics(input: string): { diagnostics: string[]; } if (/^note\b/i.test(line)) { - const noteMatch = line.match(/^note\s+(left of|right of|over)\s+\S+\s*:\s*.+$/i); + const noteMatch = line.match(STATE_DIAGRAM_NOTE_RE); if (!noteMatch) { diagnostics.push(`Invalid stateDiagram note syntax at line ${lineNumber}: "${line}"`); } @@ -188,16 +232,14 @@ function applyCompositeStateParenting(nodes: FlowNode[], input: string): FlowNod continue; } - const transitionMatch = line.match(/^(.+?)\s+(<-->|<--|-->|==>|-.->)\s+(.+?)(?:\s*:\s*(.+))?$/); - if (!transitionMatch) { - continue; + const declaredStateIds = new Set(); + const declaredStateId = extractDeclaredStateId(line); + if (declaredStateId) { + declaredStateIds.add(declaredStateId); } + extractTransitionStateIds(line).forEach((stateId) => declaredStateIds.add(stateId)); - const stateIds = [transitionMatch[1].trim(), transitionMatch[3].trim()].filter( - (value) => value !== '[*]' - ); - - for (const stateId of stateIds) { + for (const stateId of declaredStateIds) { const nodeIndex = nodeIndexById.get(stateId); if (typeof nodeIndex !== 'number') { continue; @@ -218,7 +260,7 @@ function parseStateDiagramNotes(input: string, nodes: FlowNode[]): StateNoteReco .split('\n') .forEach((rawLine, index) => { const line = rawLine.trim(); - const match = line.match(/^note\s+(left of|right of|over)\s+("?)([^":]+)\2\s*:\s*(.+)$/i); + const match = line.match(STATE_DIAGRAM_NOTE_RE); if (!match || !knownNodeIds.has(match[3])) { return; } diff --git a/src/hooks/node-operations/sectionBounds.ts b/src/hooks/node-operations/sectionBounds.ts index 518c352a..3cf530a8 100644 --- a/src/hooks/node-operations/sectionBounds.ts +++ b/src/hooks/node-operations/sectionBounds.ts @@ -2,9 +2,9 @@ import type { FlowNode } from '@/lib/types'; import { getNodeParentId } from '@/lib/nodeParent'; import { resolveNodeSize } from '@/components/nodeHelpers'; -export const SECTION_MIN_WIDTH = 500; -export const SECTION_MIN_HEIGHT = 400; -export const SECTION_PADDING_X = 32; +export const SECTION_MIN_WIDTH = 200; +export const SECTION_MIN_HEIGHT = 160; +export const SECTION_PADDING_X = 20; export const SECTION_PADDING_BOTTOM = 32; // Title now floats ABOVE the section border — no internal header space needed export const SECTION_HEADER_HEIGHT = 16; diff --git a/src/hooks/node-operations/utils.test.ts b/src/hooks/node-operations/utils.test.ts index a9cd4eb9..cbdf787e 100644 --- a/src/hooks/node-operations/utils.test.ts +++ b/src/hooks/node-operations/utils.test.ts @@ -53,12 +53,12 @@ describe('section node utilities', () => { expect(section).toBeTruthy(); expect(section?.type).toBe('section'); - expect(section?.position).toEqual({ x: 168, y: 164 }); - expect(section?.style).toMatchObject({ width: 500, height: 400 }); + expect(section?.position).toEqual({ x: 180, y: 164 }); + expect(section?.style).toMatchObject({ width: 340, height: 188 }); expect(childA?.parentId).toBe('section-1'); expect(childA?.extent).toBeUndefined(); - expect(childA?.position).toEqual({ x: 32, y: 16 }); - expect(childB?.position).toEqual({ x: 212, y: 96 }); + expect(childA?.position).toEqual({ x: 20, y: 16 }); + expect(childB?.position).toEqual({ x: 200, y: 96 }); }); it('parents a dragged node into the deepest section without auto-fitting manual sections', () => { @@ -175,7 +175,7 @@ describe('section node utilities', () => { const fittedNodes = fitSectionToChildren(section, [section, child]); const fittedSection = fittedNodes.find((node) => node.id === 'section-1'); - expect(fittedSection?.position).toEqual({ x: 308, y: 264 }); + expect(fittedSection?.position).toEqual({ x: 320, y: 264 }); }); it('releases a child from its parent section while preserving absolute position', () => { diff --git a/src/lib/entityFields.test.ts b/src/lib/entityFields.test.ts index 75997701..5ad2cf12 100644 --- a/src/lib/entityFields.test.ts +++ b/src/lib/entityFields.test.ts @@ -29,6 +29,6 @@ describe('entityFields', () => { referencesField: 'id', }); - expect(result).toBe('uuid customer_id FK UK NN REFERENCES CUSTOMER'); + expect(result).toBe('uuid customer_id FK UK NN REFERENCES CUSTOMER.id'); }); }); diff --git a/src/lib/entityFields.ts b/src/lib/entityFields.ts index e9027b63..8085a6d6 100644 --- a/src/lib/entityFields.ts +++ b/src/lib/entityFields.ts @@ -20,6 +20,16 @@ function isErField(value: unknown): value is ErField { return Boolean(value) && typeof value === 'object' && 'name' in (value as Record); } +function formatMermaidReferenceTarget(field: ErField): string | null { + const referencesTable = field.referencesTable?.trim(); + if (!referencesTable) { + return null; + } + + const referencesField = field.referencesField?.trim(); + return referencesField ? `${referencesTable}.${referencesField}` : referencesTable; +} + export function parseErField(input: string): ErField { const normalizedInput = input.trim(); if (!normalizedInput) { @@ -108,8 +118,9 @@ export function stringifyMermaidErField(field: ErField): string { if (field.isForeignKey) segments.push('FK'); if (field.isUnique) segments.push('UK'); if (field.isNotNull) segments.push('NN'); - if (field.referencesTable?.trim()) { - segments.push('REFERENCES', field.referencesTable.trim()); + const referenceTarget = formatMermaidReferenceTarget(field); + if (referenceTarget) { + segments.push('REFERENCES', referenceTarget); } return segments.join(' ').trim(); diff --git a/src/lib/mermaidParserHelpers.ts b/src/lib/mermaidParserHelpers.ts index b3814e98..c1c4f111 100644 --- a/src/lib/mermaidParserHelpers.ts +++ b/src/lib/mermaidParserHelpers.ts @@ -25,9 +25,10 @@ export const SKIP_PATTERNS = [ ]; const LINK_STYLE_RE = /^linkStyle\s+([\d,\s]+)\s+(.+)$/i; -const CLASS_DEF_RE = /^classDef\s+(\w+)\s+(.+)$/i; -const STYLE_RE = /^style\s+(\w+)\s+(.+)$/i; -const MERMAID_NODE_ID_RE = /^[a-zA-Z0-9_][\w.-]*$/; +const MERMAID_NODE_ID_RE_SOURCE = '[a-zA-Z0-9_][\\w.-]*'; +const CLASS_DEF_RE = /^classDef\s+([\w-]+)\s+(.+)$/i; +const STYLE_RE = new RegExp(`^style\\s+(${MERMAID_NODE_ID_RE_SOURCE})\\s+(.+)$`, 'i'); +const MERMAID_NODE_ID_RE = new RegExp(`^${MERMAID_NODE_ID_RE_SOURCE}$`); export { CLASS_DEF_RE, STYLE_RE }; diff --git a/src/lib/types.ts b/src/lib/types.ts index e6320cb7..36c262c6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -171,7 +171,7 @@ export interface SequenceNodeData { seqFragment?: { type: 'alt' | 'loop' | 'opt' | 'par' | 'break' | 'critical'; condition: string; - branchKind?: 'start' | 'else' | 'and'; + branchKind?: 'start' | 'else' | 'and' | 'option'; edgeIds: string[]; } | null; seqFragmentId?: string; @@ -258,7 +258,7 @@ export interface EdgeData { seqFragment?: { type: 'alt' | 'loop' | 'opt' | 'par' | 'break' | 'critical'; condition: string; - branchKind?: 'start' | 'else' | 'and'; + branchKind?: 'start' | 'else' | 'and' | 'option'; edgeIds: string[]; } | null; waypoint?: { diff --git a/src/services/architectureRoundTrip.test.ts b/src/services/architectureRoundTrip.test.ts index 2ddbda43..4489ade3 100644 --- a/src/services/architectureRoundTrip.test.ts +++ b/src/services/architectureRoundTrip.test.ts @@ -82,4 +82,32 @@ describe('architecture round-trip', () => { expect(second.nodes.find((node) => node.id === 'app')?.data.archResourceType).toBe('container'); expect(second.nodes.find((node) => node.id === 'data')?.data.archResourceType).toBe('database_container'); }); + + it('preserves nested architecture groups through import/export/import', () => { + const source = ` + architecture-beta + group global[Global] + group prod(cloud)[Prod] in global + service api(server)[API] in prod + `; + + const first = parseMermaidByType(source); + expect(first.error).toBeUndefined(); + expect(first.diagramType).toBe('architecture'); + expect(first.nodes.find((node) => node.id === 'prod')?.parentId).toBe('global'); + expect(first.nodes.find((node) => node.id === 'prod')?.data.archBoundaryId).toBe('global'); + expect(first.nodes.find((node) => node.id === 'api')?.parentId).toBe('prod'); + + const exported = toMermaid(first.nodes, first.edges); + expect(exported).toContain('group global[Global]'); + expect(exported).toContain('group prod(cloud)[Prod] in global'); + expect(exported).toContain('service api(server)[API] in prod'); + + const second = parseMermaidByType(exported); + expect(second.error).toBeUndefined(); + expect(second.diagramType).toBe('architecture'); + expect(second.nodes.find((node) => node.id === 'prod')?.parentId).toBe('global'); + expect(second.nodes.find((node) => node.id === 'prod')?.data.archBoundaryId).toBe('global'); + expect(second.nodes.find((node) => node.id === 'api')?.parentId).toBe('prod'); + }); }); diff --git a/src/services/composeDiagramForDisplay.test.ts b/src/services/composeDiagramForDisplay.test.ts index 8fce5a57..1fd9b40e 100644 --- a/src/services/composeDiagramForDisplay.test.ts +++ b/src/services/composeDiagramForDisplay.test.ts @@ -31,13 +31,20 @@ describe('composeDiagramForDisplay', () => { const nodes = [createNode('a'), createNode('b')]; const edges = [createEdge('e1', 'a', 'b')]; - await composeDiagramForDisplay(nodes, edges, { direction: 'LR', algorithm: 'layered', spacing: 'compact' }); + await composeDiagramForDisplay(nodes, edges, { + direction: 'LR', + algorithm: 'layered', + spacing: 'compact', + contentDensity: 'compact', + }); expect(getElkLayout).toHaveBeenCalledWith(nodes, edges, { direction: 'LR', algorithm: 'layered', spacing: 'compact', + contentDensity: 'compact', diagramType: undefined, + source: undefined, }); }); diff --git a/src/services/composeDiagramForDisplay.ts b/src/services/composeDiagramForDisplay.ts index 44a4d8a7..a38b78aa 100644 --- a/src/services/composeDiagramForDisplay.ts +++ b/src/services/composeDiagramForDisplay.ts @@ -2,8 +2,10 @@ import type { DiagramType, FlowEdge, FlowNode } from '@/lib/types'; import { autoFitSectionsToChildren } from '@/hooks/node-operations/sectionOperations'; import type { LayoutAlgorithm, LayoutOptions } from '@/services/elkLayout'; import { relayoutMindmapComponent, syncMindmapEdges } from '@/lib/mindmapLayout'; +import { relayoutSequenceDiagram } from '@/services/sequenceLayout'; -interface ComposeDiagramForDisplayOptions extends Pick { +interface ComposeDiagramForDisplayOptions + extends Pick { direction?: LayoutOptions['direction']; algorithm?: LayoutAlgorithm; diagramType?: DiagramType | string; @@ -55,12 +57,18 @@ export async function composeDiagramForDisplay( return relayoutAllMindmapComponents(nodes, edges); } + if (options.diagramType === 'sequence') { + return relayoutSequenceDiagram(nodes, edges); + } + const { getElkLayout } = await import('@/services/elkLayout'); const layouted = await getElkLayout(nodes, edges, { direction: options.direction ?? 'TB', algorithm: options.algorithm, spacing: options.spacing ?? 'normal', + contentDensity: options.contentDensity, diagramType: options.diagramType, + source: options.source, }); return { diff --git a/src/services/elk-layout/options.test.ts b/src/services/elk-layout/options.test.ts new file mode 100644 index 00000000..a029775c --- /dev/null +++ b/src/services/elk-layout/options.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { buildResolvedLayoutConfiguration } from './options'; + +describe('buildResolvedLayoutConfiguration', () => { + it('tightens import spacing for compact short-label diagrams', () => { + const config = buildResolvedLayoutConfiguration({ + direction: 'TB', + spacing: 'compact', + contentDensity: 'compact', + diagramType: 'flowchart', + source: 'import', + }); + + expect(config.dims.nodeNode).toBe('26'); + expect(config.dims.nodeLayer).toBe('42'); + }); + + it('keeps architecture diagrams from collapsing below readable spacing', () => { + const config = buildResolvedLayoutConfiguration({ + direction: 'TB', + spacing: 'compact', + contentDensity: 'compact', + diagramType: 'architecture', + source: 'import', + }); + + expect(config.dims.nodeNode).toBe('56'); + expect(config.dims.nodeLayer).toBe('88'); + }); +}); diff --git a/src/services/elk-layout/options.ts b/src/services/elk-layout/options.ts index 67cffd7f..37ed78a0 100644 --- a/src/services/elk-layout/options.ts +++ b/src/services/elk-layout/options.ts @@ -10,7 +10,6 @@ import type { ResolvedLayoutConfiguration, } from './types'; -// Map user-friendly direction codes to ELK direction values const DIRECTION_MAP: Record = { TB: 'DOWN', LR: 'RIGHT', @@ -20,32 +19,68 @@ const DIRECTION_MAP: Record = { function getSpacingDimensions( spacing: LayoutOptions['spacing'] = 'normal', - isHorizontal: boolean + isHorizontal: boolean, + options: LayoutOptions ): { nodeNode: string; nodeLayer: string; component: string; } { - let nodeNode = 80; - let nodeLayer = 150; + let nodeNode = 56; + let nodeLayer = 84; switch (spacing) { case 'compact': - nodeNode = 60; - nodeLayer = 120; + nodeNode = 40; + nodeLayer = 60; break; case 'loose': - nodeNode = 150; - nodeLayer = 250; + nodeNode = 76; + nodeLayer = 116; break; case 'normal': default: - nodeNode = 80; - nodeLayer = 150; + nodeNode = 56; + nodeLayer = 84; + } + + if (options.source === 'import') { + nodeNode -= 6; + nodeLayer -= 8; + } + + switch (options.contentDensity) { + case 'compact': + nodeNode -= 8; + nodeLayer -= 10; + break; + case 'verbose': + nodeNode += 10; + nodeLayer += 14; + break; + default: + break; + } + + switch (options.diagramType) { + case 'architecture': + case 'infrastructure': + nodeNode = Math.max(nodeNode, 56); + nodeLayer = Math.max(nodeLayer, 88); + break; + case 'flowchart': + case 'stateDiagram': + case 'classDiagram': + case 'erDiagram': + nodeNode = Math.min(nodeNode, spacing === 'loose' ? 72 : 52); + nodeLayer = Math.min(nodeLayer, spacing === 'loose' ? 108 : 76); + break; + default: + break; } if (isHorizontal) { - nodeLayer *= 1.2; + nodeLayer = Math.round(nodeLayer * 1.12); } return { @@ -59,25 +94,6 @@ function isArchitectureLikeDiagram(diagramType: string | undefined): boolean { return diagramType === 'architecture' || diagramType === 'infrastructure'; } -function applyDiagramTypeSpacingHeuristics( - dims: { nodeNode: string; nodeLayer: string; component: string }, - options: LayoutOptions -): { nodeNode: string; nodeLayer: string; component: string } { - if (!isArchitectureLikeDiagram(options.diagramType)) { - return dims; - } - - const nodeNode = Math.round(Number(dims.nodeNode) * 1.35); - const nodeLayer = Math.round(Number(dims.nodeLayer) * 1.3); - const component = Math.round(Number(dims.component) * 1.25); - - return { - nodeNode: String(nodeNode), - nodeLayer: String(nodeLayer), - component: String(component), - }; -} - function getAlgorithmOptions( algorithm: LayoutAlgorithm, layerSpacing: number, @@ -135,7 +151,9 @@ function getAlgorithmOptions( } else if (algorithm === 'mrtree') { Object.assign(algorithmOptions, { 'elk.separateConnectedComponents': 'true', - 'elk.portConstraints': 'FIXED_SIDE', // Lock to centers to force centralized trunk grouping + // FIXED_SIDE constrains ports to a node face, preventing mrtree from + // routing edges through the center and producing cleaner trunk grouping. + 'elk.portConstraints': 'FIXED_SIDE', 'elk.spacing.edgeEdge': '12', }); } else if (algorithm === 'force') { @@ -195,10 +213,7 @@ export function buildResolvedLayoutConfiguration( const elkDirection = DIRECTION_MAP[direction] || 'DOWN'; const isHorizontal = direction === 'LR' || direction === 'RL'; - const dims = applyDiagramTypeSpacingHeuristics( - getSpacingDimensions(spacing, isHorizontal), - options - ); + const dims = getSpacingDimensions(spacing, isHorizontal, options); const algoOptions = getAlgorithmOptions(algorithm, parseFloat(dims.nodeLayer), options); const deterministicSeedOptions = getDeterministicSeedOptions(algorithm); const layoutOptions = { diff --git a/src/services/elk-layout/textSizing.ts b/src/services/elk-layout/textSizing.ts new file mode 100644 index 00000000..466cc464 --- /dev/null +++ b/src/services/elk-layout/textSizing.ts @@ -0,0 +1,107 @@ +export interface TextBoxEstimateOptions { + minWidth?: number; + minHeight?: number; + maxWidth?: number; + charWidth?: number; + lineHeight?: number; + horizontalPadding?: number; + verticalPadding?: number; +} + +export interface TextBoxEstimate { + width: number; + height: number; + lineCount: number; +} + +const DEFAULT_CHAR_WIDTH = 9.5; +const DEFAULT_LINE_HEIGHT = 22; +const DEFAULT_HORIZONTAL_PADDING = 12; +const DEFAULT_VERTICAL_PADDING = 16; +export const DEFAULT_MAX_WIDTH = 200; + +function splitLongToken(token: string, maxCharsPerLine: number): string[] { + if (token.length <= maxCharsPerLine) { + return [token]; + } + + const parts: string[] = []; + for (let index = 0; index < token.length; index += maxCharsPerLine) { + parts.push(token.slice(index, index + maxCharsPerLine)); + } + return parts; +} + +function estimateWrappedLineLengths( + label: string, + maxCharsPerLine: number +): number[] { + const normalized = label.trim(); + if (!normalized) { + return [0]; + } + + const paragraphs = normalized.split(/\r?\n/); + const lineLengths: number[] = []; + + for (const paragraph of paragraphs) { + const words = paragraph.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) { + lineLengths.push(0); + continue; + } + + let currentLineLength = 0; + for (const word of words) { + const parts = splitLongToken(word, maxCharsPerLine); + for (const part of parts) { + if (currentLineLength === 0) { + currentLineLength = part.length; + continue; + } + + const nextLength = currentLineLength + 1 + part.length; + if (nextLength <= maxCharsPerLine) { + currentLineLength = nextLength; + continue; + } + + lineLengths.push(currentLineLength); + currentLineLength = part.length; + } + } + + lineLengths.push(currentLineLength); + } + + return lineLengths.length > 0 ? lineLengths : [0]; +} + +export function estimateWrappedTextBox( + label: string, + options: TextBoxEstimateOptions = {} +): TextBoxEstimate { + const charWidth = options.charWidth ?? DEFAULT_CHAR_WIDTH; + const lineHeight = options.lineHeight ?? DEFAULT_LINE_HEIGHT; + const horizontalPadding = options.horizontalPadding ?? DEFAULT_HORIZONTAL_PADDING; + const verticalPadding = options.verticalPadding ?? DEFAULT_VERTICAL_PADDING; + const maxWidth = options.maxWidth ?? DEFAULT_MAX_WIDTH; + const minWidth = options.minWidth ?? 0; + const minHeight = options.minHeight ?? 0; + const usableTextWidth = Math.max(maxWidth - horizontalPadding * 2, charWidth); + const maxCharsPerLine = Math.max(1, Math.floor(usableTextWidth / charWidth)); + const lineLengths = estimateWrappedLineLengths(label, maxCharsPerLine); + const longestLine = Math.max(...lineLengths, 0); + const lineCount = Math.max(1, lineLengths.length); + const estimatedWidth = Math.min( + maxWidth, + Math.ceil(longestLine * charWidth + horizontalPadding * 2) + ); + const estimatedHeight = Math.ceil(lineCount * lineHeight + verticalPadding * 2); + + return { + width: Math.max(minWidth, estimatedWidth), + height: Math.max(minHeight, estimatedHeight), + lineCount, + }; +} diff --git a/src/services/elk-layout/types.ts b/src/services/elk-layout/types.ts index cd832ee7..a69b0cd7 100644 --- a/src/services/elk-layout/types.ts +++ b/src/services/elk-layout/types.ts @@ -7,8 +7,11 @@ export interface LayoutOptions { direction?: 'TB' | 'LR' | 'RL' | 'BT'; algorithm?: LayoutAlgorithm; spacing?: 'compact' | 'normal' | 'loose'; + contentDensity?: 'compact' | 'balanced' | 'verbose'; preset?: 'hierarchical' | 'orthogonal-compact' | 'orthogonal-spacious'; diagramType?: string; + /** 'import' triggers compact node size estimates and tighter spacing defaults */ + source?: 'import' | 'canvas'; } export type ResolvedLayoutConfiguration = { diff --git a/src/services/elkLayout.test.ts b/src/services/elkLayout.test.ts index 395e74d4..bbc9d063 100644 --- a/src/services/elkLayout.test.ts +++ b/src/services/elkLayout.test.ts @@ -163,7 +163,7 @@ describe('buildResolvedLayoutConfiguration', () => { expect(compact.layoutOptions['elk.layered.nodePlacement.favorStraightEdges']).toBe('true'); expect(compact.layoutOptions['elk.layered.mergeEdges']).toBe('true'); expect(compact.layoutOptions['elk.layered.unnecessaryBendpoints']).toBe('true'); - expect(Number(compact.dims.nodeNode)).toBe(60); + expect(Number(compact.dims.nodeNode)).toBe(40); }); it('applies more spacious layered heuristics for architecture diagrams', () => { @@ -179,9 +179,10 @@ describe('buildResolvedLayoutConfiguration', () => { diagramType: 'architecture', }); - expect(Number(architecture.dims.nodeNode)).toBeGreaterThan(Number(standard.dims.nodeNode)); - expect(Number(architecture.dims.nodeLayer)).toBeGreaterThan(Number(standard.dims.nodeLayer)); - expect(Number(architecture.dims.component)).toBeGreaterThan(Number(standard.dims.component)); + // Architecture enforces a minimum spacing floor, so it is >= normal, not strictly greater. + expect(Number(architecture.dims.nodeNode)).toBeGreaterThanOrEqual(Number(standard.dims.nodeNode)); + expect(Number(architecture.dims.nodeLayer)).toBeGreaterThanOrEqual(Number(standard.dims.nodeLayer)); + expect(Number(architecture.dims.component)).toBeGreaterThanOrEqual(Number(standard.dims.component)); expect(architecture.layoutOptions['elk.layered.nodePlacement.strategy']).toBe('BRANDES_KOEPF'); expect(architecture.layoutOptions['elk.spacing.edgeNode']).toBe('24'); expect(architecture.layoutOptions['elk.spacing.edgeEdge']).toBe('18'); @@ -199,7 +200,7 @@ describe('buildResolvedLayoutConfiguration', () => { }); expect(config.layoutOptions['elk.hierarchyHandling']).toBe('INCLUDE_CHILDREN'); - expect(config.layoutOptions['elk.padding']).toBe('[top=16,left=32,bottom=32,right=32]'); + expect(config.layoutOptions['elk.padding']).toBe('[top=16,left=20,bottom=32,right=20]'); }); }); @@ -626,8 +627,9 @@ describe('shouldUseLightweightLayoutPostProcessing', () => { expect(shouldUseLightweightLayoutPostProcessing(16, 72, 'flowchart')).toBe(true); }); - it('switches architecture diagrams earlier because icon-heavy edge normalization is expensive', () => { - expect(shouldUseLightweightLayoutPostProcessing(24, 20, 'architecture')).toBe(true); - expect(shouldUseLightweightLayoutPostProcessing(12, 36, 'infrastructure')).toBe(true); + it('switches architecture diagrams to lightweight post-processing for large graphs', () => { + expect(shouldUseLightweightLayoutPostProcessing(40, 20, 'architecture')).toBe(true); + expect(shouldUseLightweightLayoutPostProcessing(12, 60, 'infrastructure')).toBe(true); + expect(shouldUseLightweightLayoutPostProcessing(24, 20, 'architecture')).toBe(false); }); }); diff --git a/src/services/elkLayout.ts b/src/services/elkLayout.ts index 4c2af1df..ab02fd70 100644 --- a/src/services/elkLayout.ts +++ b/src/services/elkLayout.ts @@ -3,6 +3,8 @@ import { NODE_HEIGHT, NODE_WIDTH } from '@/constants'; import { getIconAssetNodeMinSize, resolveNodeSize } from '@/components/nodeHelpers'; import { SECTION_CONTENT_PADDING_TOP, + SECTION_MIN_HEIGHT, + SECTION_MIN_WIDTH, SECTION_PADDING_BOTTOM, SECTION_PADDING_X, } from '@/hooks/node-operations/sectionBounds'; @@ -18,6 +20,7 @@ import { resolveLayoutPresetOptions, } from './elk-layout/options'; import type { FlowNodeWithMeasuredDimensions, LayoutOptions } from './elk-layout/types'; +import { estimateWrappedTextBox, DEFAULT_MAX_WIDTH } from './elk-layout/textSizing'; import { getNodeHandleIdForSide } from '@/lib/nodeHandles'; interface ElkLayoutEngine { @@ -95,7 +98,51 @@ async function getElkInstance(): Promise { return elkInstancePromise; } -function buildElkNode(node: FlowNode, childrenByParent: Map): ElkNode { +const IMPORT_NODE_MIN_WIDTH = 160; +const IMPORT_NODE_MIN_HEIGHT = 52; +const IMPORT_NODE_MAX_WIDTH = 240; + +function estimateNodeSize( + node: FlowNode, + nodeMinWidth: number, + nodeMinHeight: number +): { width: number; height: number } { + const isImportSized = nodeMinWidth < NODE_WIDTH; + const estimate = estimateWrappedTextBox(String(node.data?.label ?? ''), { + minWidth: nodeMinWidth, + minHeight: nodeMinHeight, + maxWidth: isImportSized ? IMPORT_NODE_MAX_WIDTH : DEFAULT_MAX_WIDTH, + }); + + // Only apply resolvedSize as a floor when it represents an explicit user-set + // dimension, not the canvas default. For import-sized nodes, resolveNodeSize() + // returns 250px which would silently defeat import compaction. + if (isImportSized) { + return estimate; + } + + const resolvedSize = resolveNodeSize(node); + return { + width: Math.max(resolvedSize.width, estimate.width), + height: Math.max(resolvedSize.height, estimate.height), + }; +} + +function hasInternalEdges( + childIds: Set, + edges: FlowEdge[] +): boolean { + return edges.some((e) => childIds.has(e.source) && childIds.has(e.target)); +} + +function buildElkNode( + node: FlowNode, + childrenByParent: Map, + allEdges: FlowEdge[], + nodeMinWidth = NODE_WIDTH, + nodeMinHeight = NODE_HEIGHT, + rootElkDirection = 'DOWN' +): ElkNode { const children = childrenByParent.get(node.id) || []; const nodeWithMeasuredDimensions = node as FlowNodeWithMeasuredDimensions; @@ -108,29 +155,40 @@ function buildElkNode(node: FlowNode, childrenByParent: Map) width = width ?? minSize.minWidth; height = height ?? minSize.minHeight; } else { - const resolvedSize = resolveNodeSize(node); - const label = node.data?.label || ''; - const estimatedWidth = Math.max(resolvedSize.width, NODE_WIDTH, label.length * 8 + 40); - const estimatedHeight = Math.max( - resolvedSize.height, - NODE_HEIGHT, - Math.ceil(label.length / 40) * 20 + 60 - ); - - width = width ?? estimatedWidth; - height = height ?? estimatedHeight; + const estimatedSize = estimateNodeSize(node, nodeMinWidth, nodeMinHeight); + width = width ?? estimatedSize.width; + height = height ?? estimatedSize.height; } } const hasChildren = children.length > 0; + + // Subgraphs with no internal edges (pure parallel siblings) lay out + // horizontally regardless of root direction — matching Mermaid's Dagre + // which places same-rank disconnected nodes side by side. + const childIds = hasChildren ? new Set(children.map((c) => c.id)) : null; + const parallelChildren = childIds !== null && !hasInternalEdges(childIds, allEdges); + + // Compound nodes must explicitly inherit the root elk.direction — ELK does + // not cascade it automatically, so without this subgraphs always use ELK's + // built-in default (DOWN) regardless of the root graph setting. + const compoundLayoutOptions = hasChildren + ? { + ...ELK_COMPOUND_LAYOUT_OPTIONS, + 'elk.direction': parallelChildren ? 'RIGHT' : rootElkDirection, + } + : {}; + return { id: node.id, width: hasChildren ? undefined : width, height: hasChildren ? undefined : height, - children: children.map((child) => buildElkNode(child, childrenByParent)), + children: children.map((child) => + buildElkNode(child, childrenByParent, allEdges, nodeMinWidth, nodeMinHeight, rootElkDirection) + ), layoutOptions: { 'elk.padding': ELK_SECTION_PADDING, - ...(hasChildren ? ELK_COMPOUND_LAYOUT_OPTIONS : {}), + ...compoundLayoutOptions, }, }; } @@ -278,15 +336,11 @@ function getNodeBoundsFromPositionMap( const pos = positionMap.get(node.id); const x = pos?.x ?? node.position.x; const y = pos?.y ?? node.position.y; - const label = node.data?.label ?? ''; - const width = - pos?.width ?? - (node as FlowNodeWithMeasuredDimensions).measured?.width ?? - Math.max(NODE_WIDTH, label.length * 8 + 40); - const height = - pos?.height ?? - (node as FlowNodeWithMeasuredDimensions).measured?.height ?? - Math.max(NODE_HEIGHT, Math.ceil(label.length / 40) * 20 + 60); + const measured = (node as FlowNodeWithMeasuredDimensions).measured; + const needsEstimate = !pos?.width || !pos?.height; + const estimate = needsEstimate ? estimateNodeSize(node, NODE_WIDTH, NODE_HEIGHT) : null; + const width = pos?.width ?? measured?.width ?? estimate!.width; + const height = pos?.height ?? measured?.height ?? estimate!.height; return { left: x, right: x + width, @@ -297,6 +351,118 @@ function getNodeBoundsFromPositionMap( }; } +function getFallbackNodeSize( + node: FlowNode, + nodeMinWidth: number, + nodeMinHeight: number +): { width: number; height: number } { + const measured = node as FlowNodeWithMeasuredDimensions; + if (measured.measured?.width && measured.measured?.height) { + return { + width: measured.measured.width, + height: measured.measured.height, + }; + } + + if (node.data?.assetPresentation === 'icon') { + const minSize = getIconAssetNodeMinSize(Boolean(node.data?.label?.trim())); + return { width: minSize.minWidth, height: minSize.minHeight }; + } + + return estimateNodeSize(node, nodeMinWidth, nodeMinHeight); +} + +function getFallbackSpacing(options: LayoutOptions): { primary: number; secondary: number } { + const isImport = options.source === 'import'; + const primaryBase = isImport ? 36 : 56; + const secondaryBase = isImport ? 52 : 84; + + switch (options.contentDensity) { + case 'compact': + return { primary: primaryBase - 8, secondary: secondaryBase - 10 }; + case 'verbose': + return { primary: primaryBase + 10, secondary: secondaryBase + 14 }; + default: + return { primary: primaryBase, secondary: secondaryBase }; + } +} + +function applyRecursiveFallbackLayout( + nodes: FlowNode[], + options: LayoutOptions, + nodeMinWidth: number, + nodeMinHeight: number +): FlowNode[] { + const { topLevelNodes, childrenByParent } = normalizeLayoutInputsForDeterminism(nodes, []); + const positionedNodes = new Map(); + const isHorizontal = options.direction === 'LR' || options.direction === 'RL'; + const spacing = getFallbackSpacing(options); + + function layoutNode( + node: FlowNode, + origin: { x: number; y: number } + ): { width: number; height: number } { + const directChildren = childrenByParent.get(node.id) ?? []; + const hasChildren = directChildren.length > 0; + const nextNode: FlowNode = { + ...node, + position: origin, + }; + + if (!hasChildren) { + positionedNodes.set(node.id, nextNode); + return getFallbackNodeSize(node, nodeMinWidth, nodeMinHeight); + } + + let cursorX = SECTION_PADDING_X; + let cursorY = SECTION_CONTENT_PADDING_TOP; + let maxChildRight = cursorX; + let maxChildBottom = cursorY; + + for (const child of directChildren) { + const childBounds = layoutNode(child, { x: cursorX, y: cursorY }); + maxChildRight = Math.max(maxChildRight, cursorX + childBounds.width); + maxChildBottom = Math.max(maxChildBottom, cursorY + childBounds.height); + + if (isHorizontal) { + cursorX += childBounds.width + spacing.primary; + } else { + cursorY += childBounds.height + spacing.primary; + } + } + + const width = Math.max(maxChildRight + SECTION_PADDING_X, SECTION_MIN_WIDTH); + const height = Math.max(maxChildBottom + SECTION_PADDING_BOTTOM, SECTION_MIN_HEIGHT); + + positionedNodes.set(node.id, { + ...nextNode, + style: + node.type === 'group' || node.type === 'section' || node.type === 'container' + ? { + ...node.style, + width, + height, + } + : node.style, + }); + + return { width, height }; + } + + let cursorX = 0; + let cursorY = 0; + for (const node of topLevelNodes) { + const laidOutSize = layoutNode(node, { x: cursorX, y: cursorY }); + if (isHorizontal) { + cursorX += laidOutSize.width + spacing.secondary; + } else { + cursorY += laidOutSize.height + spacing.secondary; + } + } + + return nodes.map((node) => positionedNodes.get(node.id) ?? node); +} + function inferHandleSideFromPoint( bounds: NodeBounds, point: { x: number; y: number }, @@ -507,7 +673,7 @@ export function shouldUseLightweightLayoutPostProcessing( return false; } - return nodeCount >= 24 || edgeCount >= 36; + return nodeCount >= 40 || edgeCount >= 60; } export async function getElkLayout( @@ -563,10 +729,16 @@ export async function getElkLayout( ? sortTopLevelNodesForArchitecture(topLevelNodes, buildDynamicLayerOrder(nodes)) : topLevelNodes; + const isImport = options.source === 'import'; + const nodeMinWidth = isImport ? IMPORT_NODE_MIN_WIDTH : NODE_WIDTH; + const nodeMinHeight = isImport ? IMPORT_NODE_MIN_HEIGHT : NODE_HEIGHT; + const elkGraph: ElkNode = { id: 'root', layoutOptions, - children: orderedTopLevelNodes.map((node) => buildElkNode(node, childrenByParent)), + children: orderedTopLevelNodes.map((node) => + buildElkNode(node, childrenByParent, sortedEdges, nodeMinWidth, nodeMinHeight, layoutOptions['elk.direction'] ?? 'DOWN') + ), edges: sortedEdges.map((edge) => ({ id: edge.id, sources: [edge.source], @@ -640,7 +812,11 @@ export async function getElkLayout( return { nodes: laidOutNodes, edges: laidOutEdges }; } catch (err) { logger.error('ELK layout error.', { error: err }); - return { nodes, edges }; + const fallbackNodes = applyRecursiveFallbackLayout(nodes, options, nodeMinWidth, nodeMinHeight); + return { + nodes: fallbackNodes, + edges: resolveLayoutedEdgeHandles(fallbackNodes, edges), + }; } } diff --git a/src/services/export/mermaid/architectureMermaid.ts b/src/services/export/mermaid/architectureMermaid.ts index f2ae23b2..87809e53 100644 --- a/src/services/export/mermaid/architectureMermaid.ts +++ b/src/services/export/mermaid/architectureMermaid.ts @@ -2,6 +2,21 @@ import type { FlowEdge, FlowNode } from '@/lib/types'; import { handleIdToSide as handleIdToFlowSide } from '@/lib/nodeHandles'; import { sanitizeId, sanitizeLabel, sanitizeEdgeLabel } from '../formatting'; +const ARCHITECTURE_NODE_KINDS = new Set([ + 'service', + 'person', + 'system', + 'container', + 'component', + 'database_container', + 'router', + 'switch', + 'firewall', + 'load_balancer', + 'cdn', + 'dns', +]); + function normalizeArchitectureDirection(direction: string | undefined): '-->' | '<--' | '<-->' { if (direction === '<--' || direction === '<-->') return direction; return '-->'; @@ -25,6 +40,30 @@ function handleIdToSide(handleId: string | null | undefined): 'L' | 'R' | 'T' | return undefined; } +function toArchitectureNodeStatement(node: FlowNode): string { + const id = sanitizeId(node.id); + const label = sanitizeLabel(node.data.label); + const kind = (node.data.archResourceType || 'service').toLowerCase(); + const parent = node.data.archBoundaryId ? sanitizeId(node.data.archBoundaryId) : ''; + const provider = typeof node.data.archProvider === 'string' ? node.data.archProvider : ''; + const icon = + provider && provider !== 'custom' && !(kind === 'group' && provider === 'group') + ? `(${sanitizeLabel(provider)})` + : ''; + const suffix = parent ? ` in ${parent}` : ''; + + if (kind === 'group') { + return ` group ${id}${icon}[${label}]${suffix}`; + } + + if (kind === 'junction') { + return ` junction ${id}${icon}[${label}]${suffix}`; + } + const statementKind = ARCHITECTURE_NODE_KINDS.has(kind) ? kind : 'service'; + + return ` ${statementKind} ${id}${icon}[${label}]${suffix}`; +} + export function toArchitectureMermaid(nodes: FlowNode[], edges: FlowEdge[]): string { const lines: string[] = ['architecture-beta']; const titleNode = nodes.find( @@ -37,42 +76,7 @@ export function toArchitectureMermaid(nodes: FlowNode[], edges: FlowEdge[]): str } nodes.forEach((node) => { - const id = sanitizeId(node.id); - const label = sanitizeLabel(node.data.label); - const kind = (node.data.archResourceType || 'service').toLowerCase(); - const parent = node.data.archBoundaryId ? sanitizeId(node.data.archBoundaryId) : ''; - const icon = - node.data.archProvider && node.data.archProvider !== 'custom' - ? `(${sanitizeLabel(node.data.archProvider)})` - : ''; - const suffix = parent ? ` in ${parent}` : ''; - - if (kind === 'group') { - lines.push(` group ${id}[${label}]`); - return; - } - - if (kind === 'junction') { - lines.push(` junction ${id}${icon}[${label}]${suffix}`); - return; - } - const architectureKinds = new Set([ - 'service', - 'person', - 'system', - 'container', - 'component', - 'database_container', - 'router', - 'switch', - 'firewall', - 'load_balancer', - 'cdn', - 'dns', - ]); - const statementKind = architectureKinds.has(kind) ? kind : 'service'; - - lines.push(` ${statementKind} ${id}${icon}[${label}]${suffix}`); + lines.push(toArchitectureNodeStatement(node)); }); edges.forEach((edge) => { diff --git a/src/services/export/mermaid/sequenceMermaid.ts b/src/services/export/mermaid/sequenceMermaid.ts index ded30ae1..671a4389 100644 --- a/src/services/export/mermaid/sequenceMermaid.ts +++ b/src/services/export/mermaid/sequenceMermaid.ts @@ -25,7 +25,7 @@ function sortSequenceActivations( type SequenceFragmentState = { type: string; condition: string; - branchKind?: 'start' | 'else' | 'and'; + branchKind?: 'start' | 'else' | 'and' | 'option'; }; function syncFragmentState( @@ -47,6 +47,10 @@ function syncFragmentState( lines.push(` and ${nextFrag.condition}`); return nextFrag; } + if (nextFrag.type === 'critical' && nextFrag.branchKind === 'option') { + lines.push(` option ${nextFrag.condition}`); + return nextFrag; + } lines.push(' end'); currentFrag = null; } diff --git a/src/services/exportService.test.ts b/src/services/exportService.test.ts index 248e5add..33cd509d 100644 --- a/src/services/exportService.test.ts +++ b/src/services/exportService.test.ts @@ -41,6 +41,34 @@ describe('toMermaid', () => { expect(output).toContain('api:R <--> L:db : HTTPS:443'); }); + it('preserves nested architecture groups during export', () => { + const nodes: FlowNode[] = [ + { + id: 'global', + type: 'architecture', + position: { x: 0, y: 0 }, + data: { label: 'Global', archResourceType: 'group' }, + }, + { + id: 'prod', + type: 'architecture', + position: { x: 240, y: 0 }, + data: { label: 'Prod', archResourceType: 'group', archProvider: 'cloud', archBoundaryId: 'global' }, + }, + { + id: 'api', + type: 'architecture', + position: { x: 480, y: 0 }, + data: { label: 'API', archResourceType: 'service', archBoundaryId: 'prod' }, + }, + ]; + + const output = toMermaid(nodes, []); + expect(output).toContain('group global[Global]'); + expect(output).toContain('group prod(cloud)[Prod] in global'); + expect(output).toContain('service api[API] in prod'); + }); + it('keeps flowchart export path for mixed or non-architecture nodes', () => { const nodes: FlowNode[] = [ { id: 'a', type: 'process', position: { x: 0, y: 0 }, data: { label: 'A' } }, diff --git a/src/services/importLayoutMetadata.ts b/src/services/importLayoutMetadata.ts new file mode 100644 index 00000000..30568e75 --- /dev/null +++ b/src/services/importLayoutMetadata.ts @@ -0,0 +1,94 @@ +import type { FlowNode } from '@/lib/types'; +import type { LayoutOptions } from '@/services/elk-layout/types'; + +export interface ImportLayoutMetadata { + signature: string; + direction: NonNullable; + spacing: NonNullable; + contentDensity: NonNullable; + diagramType?: string; +} + +const IMPORT_PENDING_LAYOUT_KEY = '_importPendingLayout'; +const IMPORT_LAYOUT_SIGNATURE_KEY = '_importLayoutSignature'; +const IMPORT_LAYOUT_DIRECTION_KEY = '_importLayoutDirection'; +const IMPORT_LAYOUT_SPACING_KEY = '_importLayoutSpacing'; +const IMPORT_LAYOUT_CONTENT_DENSITY_KEY = '_importLayoutContentDensity'; +const IMPORT_LAYOUT_DIAGRAM_TYPE_KEY = '_importLayoutDiagramType'; + +export function attachImportLayoutMetadata( + nodes: FlowNode[], + metadata: ImportLayoutMetadata +): FlowNode[] { + return nodes.map((node) => ({ + ...node, + data: { + ...node.data, + [IMPORT_PENDING_LAYOUT_KEY]: true, + [IMPORT_LAYOUT_SIGNATURE_KEY]: metadata.signature, + [IMPORT_LAYOUT_DIRECTION_KEY]: metadata.direction, + [IMPORT_LAYOUT_SPACING_KEY]: metadata.spacing, + [IMPORT_LAYOUT_CONTENT_DENSITY_KEY]: metadata.contentDensity, + [IMPORT_LAYOUT_DIAGRAM_TYPE_KEY]: metadata.diagramType, + }, + })); +} + +export function clearImportLayoutMetadata(nodes: FlowNode[]): FlowNode[] { + return nodes.map((node) => { + if (!isImportPendingLayoutNode(node)) { + return node; + } + + const data = { ...node.data }; + delete data[IMPORT_PENDING_LAYOUT_KEY]; + delete data[IMPORT_LAYOUT_SIGNATURE_KEY]; + delete data[IMPORT_LAYOUT_DIRECTION_KEY]; + delete data[IMPORT_LAYOUT_SPACING_KEY]; + delete data[IMPORT_LAYOUT_CONTENT_DENSITY_KEY]; + delete data[IMPORT_LAYOUT_DIAGRAM_TYPE_KEY]; + + return { + ...node, + data, + }; + }); +} + +export function isImportPendingLayoutNode(node: FlowNode): boolean { + return node.data?.[IMPORT_PENDING_LAYOUT_KEY] === true; +} + +export function readImportLayoutMetadata(nodes: FlowNode[]): ImportLayoutMetadata | null { + const node = nodes.find(isImportPendingLayoutNode); + if (!node) { + return null; + } + + const signature = node.data?.[IMPORT_LAYOUT_SIGNATURE_KEY]; + const direction = node.data?.[IMPORT_LAYOUT_DIRECTION_KEY]; + const spacing = node.data?.[IMPORT_LAYOUT_SPACING_KEY]; + const contentDensity = node.data?.[IMPORT_LAYOUT_CONTENT_DENSITY_KEY]; + + if ( + typeof signature !== 'string' + || (direction !== 'TB' && direction !== 'LR' && direction !== 'RL' && direction !== 'BT') + || (spacing !== 'compact' && spacing !== 'normal' && spacing !== 'loose') + || (contentDensity !== 'compact' && contentDensity !== 'balanced' && contentDensity !== 'verbose') + ) { + return null; + } + + const diagramType = + typeof node.data?.[IMPORT_LAYOUT_DIAGRAM_TYPE_KEY] === 'string' + ? String(node.data?.[IMPORT_LAYOUT_DIAGRAM_TYPE_KEY]) + : undefined; + + return { + signature, + direction, + spacing, + contentDensity, + diagramType, + }; +} diff --git a/src/services/mermaid/compatFixtureCorpus.test.ts b/src/services/mermaid/compatFixtureCorpus.test.ts new file mode 100644 index 00000000..0858b249 --- /dev/null +++ b/src/services/mermaid/compatFixtureCorpus.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import { parseMermaidByType } from './parseMermaidByType'; +import type { MermaidImportStatus } from './importContracts'; +import { MERMAID_COMPAT_FIXTURES } from '../../../scripts/mermaid-compat-fixtures.mjs'; + +interface MermaidCompatFixture { + name: string; + family: string; + bucket: 'editable_full' | 'editable_partial' | 'valid_but_not_editable' | 'invalid_source'; + expectedOfficial: 'valid' | 'invalid' | 'environment_limited'; + expectedEditableGate: 'supported_family' | 'unsupported_family' | 'invalid_source'; + expectedImportState: MermaidImportStatus; + source: string; + structuralAssertions?: { + minNodes?: number; + maxNodes?: number; + minEdges?: number; + maxEdges?: number; + diagnosticsMin?: number; + minSections?: number; + minParticipants?: number; + minNotes?: number; + minAnnotations?: number; + requiredLabels?: string[]; + requiredNodeIds?: string[]; + requiredParentIds?: Record; + }; +} + +function countNodesOfType(nodes: Array<{ type?: string }>, type: string): number { + return nodes.filter((node) => node.type === type).length; +} + +function shouldExposeDiagramType(importState: MermaidImportStatus): boolean { + return importState !== 'invalid_source' && importState !== 'unsupported_family'; +} + +function hasDegradingDiagnostics( + diagnostics: Array<{ severity?: string; editableImpact?: string }> | undefined +): boolean { + return (diagnostics ?? []).some( + (diagnostic) => + diagnostic.severity === 'warning' + || diagnostic.editableImpact === 'partial' + || diagnostic.editableImpact === 'blocked' + ); +} + +describe('Mermaid compat fixture corpus', () => { + it('enforces import-state and structure expectations for the shared fixture corpus', () => { + const fixtures = MERMAID_COMPAT_FIXTURES as MermaidCompatFixture[]; + + for (const fixture of fixtures) { + const result = parseMermaidByType(fixture.source); + + expect(result.originalSource?.trim(), fixture.name).toBe(fixture.source.trim()); + expect(result.importState, fixture.name).toBe(fixture.expectedImportState); + + if (shouldExposeDiagramType(fixture.expectedImportState)) { + expect(result.diagramType, fixture.name).toBeDefined(); + } + + if (fixture.expectedEditableGate === 'unsupported_family') { + expect(result.importState, fixture.name).toBe('unsupported_family'); + } + + if (fixture.expectedImportState !== 'editable_full') { + expect(result.originalSource, fixture.name).toContain(fixture.source.trim().split('\n')[0]); + } + if (fixture.expectedImportState === 'editable_full') { + expect(hasDegradingDiagnostics(result.structuredDiagnostics), fixture.name).toBe(false); + } else if (fixture.expectedImportState === 'editable_partial') { + expect(hasDegradingDiagnostics(result.structuredDiagnostics), fixture.name).toBe(true); + } + + const assertions = fixture.structuralAssertions; + if (!assertions) { + continue; + } + + if (typeof assertions.minNodes === 'number') { + expect(result.nodes.length, fixture.name).toBeGreaterThanOrEqual(assertions.minNodes); + } + if (typeof assertions.maxNodes === 'number') { + expect(result.nodes.length, fixture.name).toBeLessThanOrEqual(assertions.maxNodes); + } + if (typeof assertions.minEdges === 'number') { + expect(result.edges.length, fixture.name).toBeGreaterThanOrEqual(assertions.minEdges); + } + if (typeof assertions.maxEdges === 'number') { + expect(result.edges.length, fixture.name).toBeLessThanOrEqual(assertions.maxEdges); + } + if (typeof assertions.diagnosticsMin === 'number') { + expect(result.structuredDiagnostics?.length ?? 0, fixture.name).toBeGreaterThanOrEqual( + assertions.diagnosticsMin + ); + } + if (typeof assertions.minSections === 'number') { + expect(countNodesOfType(result.nodes, 'section'), fixture.name).toBeGreaterThanOrEqual( + assertions.minSections + ); + } + if (typeof assertions.minParticipants === 'number') { + expect( + countNodesOfType(result.nodes, 'sequence_participant'), + fixture.name + ).toBeGreaterThanOrEqual(assertions.minParticipants); + } + if (typeof assertions.minNotes === 'number') { + expect(countNodesOfType(result.nodes, 'sequence_note'), fixture.name).toBeGreaterThanOrEqual( + assertions.minNotes + ); + } + if (typeof assertions.minAnnotations === 'number') { + expect(countNodesOfType(result.nodes, 'annotation'), fixture.name).toBeGreaterThanOrEqual( + assertions.minAnnotations + ); + } + for (const label of assertions.requiredLabels ?? []) { + expect( + result.nodes.some((node) => String(node.data?.label ?? '').includes(label)), + `${fixture.name} should preserve label "${label}"` + ).toBe(true); + } + for (const nodeId of assertions.requiredNodeIds ?? []) { + expect( + result.nodes.some((node) => node.id === nodeId), + `${fixture.name} should preserve node id "${nodeId}"` + ).toBe(true); + } + for (const [nodeId, parentId] of Object.entries(assertions.requiredParentIds ?? {})) { + expect( + result.nodes.find((node) => node.id === nodeId)?.parentId, + `${fixture.name} should preserve parent "${parentId}" for node "${nodeId}"` + ).toBe(parentId); + } + } + }); +}); diff --git a/src/services/mermaid/compatReportHarness.test.ts b/src/services/mermaid/compatReportHarness.test.ts index e9a6c2e6..f41da811 100644 --- a/src/services/mermaid/compatReportHarness.test.ts +++ b/src/services/mermaid/compatReportHarness.test.ts @@ -1,9 +1,8 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { execFileSync } from 'node:child_process'; import { parseMermaidByType } from './parseMermaidByType'; +import type { MermaidImportStatus } from './importContracts'; +import { MERMAID_COMPAT_FIXTURES } from '../../../scripts/mermaid-compat-fixtures.mjs'; describe('mermaid compat report harness', () => { it('emits corpus-driven family summary output', () => { @@ -13,7 +12,7 @@ describe('mermaid compat report harness', () => { }); const report = JSON.parse(output); - expect(report.summary.totalFixtures).toBeGreaterThanOrEqual(35); + expect(report.summary.totalFixtures).toBeGreaterThanOrEqual(36); expect(report.summary.supportedFamilies).toBeGreaterThan(0); expect(report.summary.officialExpectationMatches).toBeGreaterThan(0); expect(Array.isArray(report.familySummary)).toBe(true); @@ -31,12 +30,10 @@ describe('mermaid compat report harness', () => { }); it('measures actual OpenFlowKit import outcomes for the fixture corpus', () => { - const __filename = fileURLToPath(import.meta.url); - const __dirname = path.dirname(__filename); - const fixturesPath = path.resolve(__dirname, '../../../scripts/mermaid-compat-fixtures.json'); - const fixtures = JSON.parse(fs.readFileSync(fixturesPath, 'utf8')) as Array<{ + const fixtures = MERMAID_COMPAT_FIXTURES as Array<{ name: string; family: string; + expectedImportState: MermaidImportStatus; expectedOfficial: 'valid' | 'invalid' | 'environment_limited'; expectedEditableGate: 'supported_family' | 'unsupported_family' | 'invalid_source'; source: string; @@ -56,21 +53,31 @@ describe('mermaid compat report harness', () => { expect(result.originalSource?.trim(), fixture.name).toBe(fixture.source.trim()); expect(Array.isArray(result.structuredDiagnostics), fixture.name).toBe(true); + expect(result.importState, fixture.name).toBe(fixture.expectedImportState); if (fixture.expectedEditableGate === 'unsupported_family') { expect(result.importState, fixture.name).toBe('unsupported_family'); continue; } + if (fixture.expectedEditableGate === 'invalid_source') { + expect(result.importState, fixture.name).toBe('invalid_source'); + continue; + } + expect(result.importState, fixture.name).not.toBe('unsupported_family'); - expect(result.importState, fixture.name).not.toBe('invalid_source'); if (fixture.expectedOfficial === 'valid') { expect(result.diagramType, fixture.name).toBeDefined(); } } - expect(outcomeCounts.editable_full + outcomeCounts.editable_partial).toBeGreaterThan(0); + expect( + outcomeCounts.editable_full + + outcomeCounts.editable_partial + + outcomeCounts.unsupported_construct + ).toBeGreaterThan(0); expect(outcomeCounts.unsupported_family).toBeGreaterThan(0); + expect(outcomeCounts.invalid_source).toBeGreaterThan(0); }); }); diff --git a/src/services/mermaid/mermaidLayoutCorpus.test.ts b/src/services/mermaid/mermaidLayoutCorpus.test.ts new file mode 100644 index 00000000..037efbd2 --- /dev/null +++ b/src/services/mermaid/mermaidLayoutCorpus.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest'; +import { composeDiagramForDisplay } from '@/services/composeDiagramForDisplay'; +import { parseMermaidByType } from './parseMermaidByType'; +import type { MermaidImportStatus } from './importContracts'; +import { MERMAID_COMPAT_FIXTURES } from '../../../scripts/mermaid-compat-fixtures.mjs'; + +interface MermaidLayoutFixture { + name: string; + source: string; + expectedImportState: MermaidImportStatus; + layoutAssertions?: { + maxBoundingWidth?: number; + maxBoundingHeight?: number; + requireUniquePositions?: boolean; + minSections?: number; + minParticipants?: number; + requireSequenceLaneAlignment?: boolean; + requireNotesBelowParticipants?: boolean; + orderedLabelsLeftToRight?: string[]; + orderedLabelsTopToBottom?: string[]; + sameRowLabels?: string[]; + sameColumnLabels?: string[]; + verticalFlowLabels?: string[]; + horizontalFlowLabels?: string[]; + }; +} + +function getBounds(nodes: Array<{ position: { x: number; y: number } }>): { + width: number; + height: number; +} { + if (nodes.length === 0) { + return { width: 0, height: 0 }; + } + + const xs = nodes.map((node) => node.position.x); + const ys = nodes.map((node) => node.position.y); + return { + width: Math.max(...xs) - Math.min(...xs), + height: Math.max(...ys) - Math.min(...ys), + }; +} + +function findNodeByLabel( + nodes: Array<{ data?: { label?: unknown }; position: { x: number; y: number } }>, + label: string +): { position: { x: number; y: number } } | undefined { + return nodes.find((node) => String(node.data?.label ?? '').includes(label)); +} + +function expectLabelsPresent( + nodes: Array<{ data?: { label?: unknown }; position: { x: number; y: number } }>, + labels: string[], + fixtureName: string, + direction: 'row' | 'column' +): Array<{ position: { x: number; y: number } }> { + return labels.map((label) => { + const node = findNodeByLabel(nodes, label); + expect( + node, + `${fixtureName} should include label "${label}" for ${direction} alignment` + ).toBeDefined(); + return node!; + }); +} + +describe('Mermaid layout corpus invariants', () => { + it('keeps representative imported diagrams compact and structurally clear', async () => { + const fixtures = (MERMAID_COMPAT_FIXTURES as MermaidLayoutFixture[]).filter( + (fixture) => fixture.layoutAssertions + ); + + for (const fixture of fixtures) { + const parsed = parseMermaidByType(fixture.source); + expect(parsed.importState, fixture.name).toBe(fixture.expectedImportState); + expect(parsed.error, fixture.name).toBeUndefined(); + + const layouted = await composeDiagramForDisplay(parsed.nodes, parsed.edges, { + direction: parsed.direction ?? 'TB', + spacing: 'compact', + diagramType: parsed.diagramType, + source: 'import', + }); + + const visibleNodes = layouted.nodes.filter((node) => !node.hidden); + const bounds = getBounds(visibleNodes); + const assertions = fixture.layoutAssertions!; + + if (typeof assertions.maxBoundingWidth === 'number') { + expect(bounds.width, fixture.name).toBeLessThanOrEqual(assertions.maxBoundingWidth); + } + if (typeof assertions.maxBoundingHeight === 'number') { + expect(bounds.height, fixture.name).toBeLessThanOrEqual(assertions.maxBoundingHeight); + } + if (assertions.requireUniquePositions) { + const uniquePositions = new Set( + visibleNodes.map((node) => `${Math.round(node.position.x)}:${Math.round(node.position.y)}`) + ); + expect(uniquePositions.size, fixture.name).toBeGreaterThan(1); + } + if (typeof assertions.minSections === 'number') { + expect( + layouted.nodes.filter((node) => node.type === 'section').length, + fixture.name + ).toBeGreaterThanOrEqual(assertions.minSections); + } + if (typeof assertions.minParticipants === 'number') { + const participants = layouted.nodes.filter((node) => node.type === 'sequence_participant'); + expect(participants.length, fixture.name).toBeGreaterThanOrEqual(assertions.minParticipants); + + if (assertions.requireSequenceLaneAlignment) { + const yValues = new Set(participants.map((node) => Math.round(node.position.y))); + expect(yValues.size, fixture.name).toBeLessThanOrEqual(2); + const xValues = participants.map((node) => node.position.x); + expect([...xValues].sort((a, b) => a - b), fixture.name).toEqual(xValues); + } + } + if (assertions.requireNotesBelowParticipants) { + const participants = layouted.nodes.filter((node) => node.type === 'sequence_participant'); + const notes = layouted.nodes.filter((node) => node.type === 'sequence_note'); + const participantBottom = Math.max(...participants.map((node) => node.position.y)); + expect(notes.length, fixture.name).toBeGreaterThan(0); + expect(notes.every((node) => node.position.y >= participantBottom), fixture.name).toBe(true); + } + if (Array.isArray(assertions.orderedLabelsLeftToRight)) { + const orderedNodes = assertions.orderedLabelsLeftToRight.map((label) => { + const node = findNodeByLabel(visibleNodes, label); + expect(node, `${fixture.name} should include label "${label}" for left-to-right order`).toBeDefined(); + return node!; + }); + for (let index = 1; index < orderedNodes.length; index += 1) { + expect( + orderedNodes[index - 1].position.x, + `${fixture.name} should keep "${assertions.orderedLabelsLeftToRight[index - 1]}" left of "${assertions.orderedLabelsLeftToRight[index]}"` + ).toBeLessThanOrEqual(orderedNodes[index].position.x); + } + } + if (Array.isArray(assertions.orderedLabelsTopToBottom)) { + const orderedNodes = expectLabelsPresent( + visibleNodes, + assertions.orderedLabelsTopToBottom, + fixture.name, + 'column' + ); + for (let index = 1; index < orderedNodes.length; index += 1) { + expect( + orderedNodes[index - 1].position.y, + `${fixture.name} should keep "${assertions.orderedLabelsTopToBottom[index - 1]}" above "${assertions.orderedLabelsTopToBottom[index]}"` + ).toBeLessThanOrEqual(orderedNodes[index].position.y); + } + } + if (Array.isArray(assertions.sameRowLabels)) { + const alignedNodes = expectLabelsPresent( + visibleNodes, + assertions.sameRowLabels, + fixture.name, + 'row' + ); + const referenceY = Math.round(alignedNodes[0].position.y); + expect( + alignedNodes.every((node) => Math.abs(Math.round(node.position.y) - referenceY) <= 8), + `${fixture.name} should keep ${assertions.sameRowLabels.join(', ')} on the same row` + ).toBe(true); + } + if (Array.isArray(assertions.sameColumnLabels)) { + const alignedNodes = expectLabelsPresent( + visibleNodes, + assertions.sameColumnLabels, + fixture.name, + 'column' + ); + const referenceX = Math.round(alignedNodes[0].position.x); + expect( + alignedNodes.every((node) => Math.abs(Math.round(node.position.x) - referenceX) <= 8), + `${fixture.name} should keep ${assertions.sameColumnLabels.join(', ')} in the same column` + ).toBe(true); + } + if (Array.isArray(assertions.verticalFlowLabels)) { + const orderedNodes = expectLabelsPresent( + visibleNodes, + assertions.verticalFlowLabels, + fixture.name, + 'column' + ); + for (let index = 1; index < orderedNodes.length; index += 1) { + const deltaX = Math.abs(orderedNodes[index].position.x - orderedNodes[index - 1].position.x); + const deltaY = orderedNodes[index].position.y - orderedNodes[index - 1].position.y; + expect( + deltaY, + `${fixture.name} should keep "${assertions.verticalFlowLabels[index]}" below "${assertions.verticalFlowLabels[index - 1]}"` + ).toBeGreaterThanOrEqual(0); + expect( + Math.abs(deltaY), + `${fixture.name} should move more vertically than horizontally between "${assertions.verticalFlowLabels[index - 1]}" and "${assertions.verticalFlowLabels[index]}"` + ).toBeGreaterThanOrEqual(deltaX); + } + } + if (Array.isArray(assertions.horizontalFlowLabels)) { + const orderedNodes = expectLabelsPresent( + visibleNodes, + assertions.horizontalFlowLabels, + fixture.name, + 'row' + ); + for (let index = 1; index < orderedNodes.length; index += 1) { + const deltaX = orderedNodes[index].position.x - orderedNodes[index - 1].position.x; + const deltaY = Math.abs(orderedNodes[index].position.y - orderedNodes[index - 1].position.y); + expect( + deltaX, + `${fixture.name} should keep "${assertions.horizontalFlowLabels[index]}" right of "${assertions.horizontalFlowLabels[index - 1]}"` + ).toBeGreaterThanOrEqual(0); + expect( + Math.abs(deltaX), + `${fixture.name} should move more horizontally than vertically between "${assertions.horizontalFlowLabels[index - 1]}" and "${assertions.horizontalFlowLabels[index]}"` + ).toBeGreaterThanOrEqual(deltaY); + } + } + } + }); +}); diff --git a/src/services/mermaid/parseMermaidByType.test.ts b/src/services/mermaid/parseMermaidByType.test.ts index 42944b83..a7a0557e 100644 --- a/src/services/mermaid/parseMermaidByType.test.ts +++ b/src/services/mermaid/parseMermaidByType.test.ts @@ -84,6 +84,23 @@ describe('parseMermaidByType', () => { expect(result.nodes.length).toBeGreaterThan(0); }); + it('keeps standalone composite state declarations parented through the dispatcher', () => { + const result = parseMermaidByType(` + stateDiagram-v2 + state Working { + state Busy + state Idle + Busy --> Idle + } + Idle --> [*] + `); + + expect(result.error).toBeUndefined(); + expect(result.diagramType).toBe('stateDiagram'); + expect(result.nodes.find((node) => node.id === 'Busy')?.parentId).toBe('Working'); + expect(result.nodes.find((node) => node.id === 'Idle')?.parentId).toBe('Working'); + }); + it('parses classDiagram through plugin dispatcher', () => { const result = parseMermaidByType(` classDiagram @@ -183,6 +200,26 @@ describe('parseMermaidByType', () => { expect(result.nodes.every((node) => node.type === 'mindmap')).toBe(true); }); + it('keeps dotted wrapped mindmap aliases in editable_full when no diagnostics are present', () => { + const result = parseMermaidByType(` + mindmap + platform.root((Root)) + platform.api[[Child A]] + platform.branch(Child B) + `); + + expect(result.diagramType).toBe('mindmap'); + expect(result.error).toBeUndefined(); + expect(result.structuredDiagnostics).toEqual([]); + expect(result.importState).toBe('editable_full'); + expect(result.nodes.find((node) => node.data.label === 'Root')?.data.mindmapAlias).toBe( + 'platform.root' + ); + expect( + result.nodes.find((node) => node.data.label === 'Child A')?.data.mindmapAlias + ).toBe('platform.api'); + }); + it('parses journey through plugin dispatcher', () => { const result = parseMermaidByType(` journey @@ -311,6 +348,8 @@ describe('parseMermaidByType', () => { expect(result.error).toContain('Missing chart type declaration'); expect(result.importState).toBe('invalid_source'); + expect(result.structuredDiagnostics?.length).toBeGreaterThan(0); + expect(result.structuredDiagnostics?.[0]?.severity).toBe('error'); }); it('parses semicolon-terminated node declarations correctly', () => { diff --git a/src/services/mermaid/parseMermaidByType.ts b/src/services/mermaid/parseMermaidByType.ts index 7ddf9683..ed3d75e0 100644 --- a/src/services/mermaid/parseMermaidByType.ts +++ b/src/services/mermaid/parseMermaidByType.ts @@ -34,12 +34,18 @@ const SUPPORTED_MERMAID_FAMILIES: DiagramType[] = [ 'sequence', ]; +const SUPPORTED_MERMAID_FAMILY_LIST = SUPPORTED_MERMAID_FAMILIES.join(', '); + +function getUnsupportedEditableModeError(typeLabel: string): string { + return `Mermaid "${typeLabel}" is not supported yet in editable mode. Supported families: ${SUPPORTED_MERMAID_FAMILY_LIST}.`; +} + function getUnsupportedTypeError(diagramType: DiagramType): string { - return `Mermaid "${diagramType}" is not supported yet in editable mode. Supported families: flowchart, stateDiagram, classDiagram, erDiagram, mindmap, journey, architecture, sequence.`; + return getUnsupportedEditableModeError(diagramType); } function getUnsupportedHeaderError(rawType: string): string { - return `Mermaid "${rawType}" is not supported yet in editable mode. Supported families: flowchart, stateDiagram, classDiagram, erDiagram, mindmap, journey, architecture, sequence.`; + return getUnsupportedEditableModeError(rawType); } function finalizeResult( @@ -52,8 +58,12 @@ function finalizeResult( officialMermaidAccepted?: boolean; } ): MermaidDispatchParseResult { + const diagnosticInput = + result.structuredDiagnostics + ?? result.diagnostics + ?? (result.error ? [result.error] : []); const structuredDiagnostics = normalizeMermaidImportDiagnostics({ - diagnostics: result.structuredDiagnostics ?? result.diagnostics, + diagnostics: diagnosticInput, family: params.family, parseBlocked: Boolean(result.error), officialMermaidAccepted: params.officialMermaidAccepted, @@ -124,14 +134,21 @@ export function parseMermaidByType( ); } - return { - nodes: [], - edges: [], - error: - 'Missing chart type declaration. Start with "flowchart TD", "stateDiagram-v2", or another Mermaid diagram type header.', - importState: 'invalid_source', - originalSource: input, - }; + return finalizeResult( + input, + { + nodes: [], + edges: [], + error: + 'Missing chart type declaration. Start with "flowchart TD", "stateDiagram-v2", or another Mermaid diagram type header.', + importState: 'invalid_source', + }, + { + hasHeader: false, + isSupportedFamily: false, + officialMermaidAccepted: oracleValidation.isValid, + } + ); } if (!SUPPORTED_MERMAID_FAMILIES.includes(detectedType)) { diff --git a/src/services/mermaidParser.test.ts b/src/services/mermaidParser.test.ts index 9b76898f..67741977 100644 --- a/src/services/mermaidParser.test.ts +++ b/src/services/mermaidParser.test.ts @@ -324,6 +324,39 @@ describe('mermaidParser', () => { }); }); + it('applies style directives to dotted node ids', () => { + const input = ` + flowchart TD + api.gateway[Gateway] + style api.gateway fill:#dff,stroke:#08c,color:#024 + `; + const result = parseMermaid(input); + + expect(result.error).toBeUndefined(); + expect(result.nodes.find((node) => node.id === 'api.gateway')?.style).toMatchObject({ + backgroundColor: '#dff', + borderColor: '#08c', + color: '#024', + }); + }); + + it('preserves nested flowchart subgraph parenting', () => { + const input = ` + flowchart TD + subgraph platform[Platform] + subgraph api[API] + gateway[Gateway] --> service[Service] + end + end + `; + const result = parseMermaid(input); + + expect(result.error).toBeUndefined(); + expect(result.nodes.find((node) => node.id === 'api')?.parentId).toBe('platform'); + expect(result.nodes.find((node) => node.id === 'gateway')?.parentId).toBe('api'); + expect(result.nodes.find((node) => node.id === 'service')?.parentId).toBe('api'); + }); + it('should handle duplicate edges between same pair', () => { const input = ` flowchart TD diff --git a/src/services/remainingFamiliesRoundTrip.test.ts b/src/services/remainingFamiliesRoundTrip.test.ts index 8e354c1a..0f910890 100644 --- a/src/services/remainingFamiliesRoundTrip.test.ts +++ b/src/services/remainingFamiliesRoundTrip.test.ts @@ -37,6 +37,33 @@ describe('remaining Mermaid family round-trip', () => { expect(second.nodes.find((node) => node.data.label === 'Child B')?.data.mindmapWrapper).toBe('rounded'); }); + it('preserves dotted mindmap aliases through parse/export/parse', () => { + const source = ` + mindmap + platform.root((Root)) + platform.api[[Child A]] + platform.branch(Child B) + `; + + const first = parseMermaidByType(source); + expect(first.error).toBeUndefined(); + expect(first.diagramType).toBe('mindmap'); + expect(first.nodes.find((node) => node.data.label === 'Root')?.data.mindmapAlias).toBe('platform.root'); + expect(first.nodes.find((node) => node.data.label === 'Child A')?.data.mindmapAlias).toBe('platform.api'); + + const exported = toMermaid(first.nodes, first.edges); + expect(exported).toContain('platform.root((Root))'); + expect(exported).toContain('platform.api[[Child A]]'); + expect(exported).toContain('platform.branch(Child B)'); + + const second = parseMermaidByType(exported); + expect(second.error).toBeUndefined(); + expect(second.diagramType).toBe('mindmap'); + expect(second.nodes.find((node) => node.data.label === 'Root')?.data.mindmapAlias).toBe('platform.root'); + expect(second.nodes.find((node) => node.data.label === 'Child A')?.data.mindmapAlias).toBe('platform.api'); + expect(second.nodes.find((node) => node.data.label === 'Child B')?.data.mindmapAlias).toBe('platform.branch'); + }); + it('preserves journey family through parse/export/parse', () => { const source = ` journey @@ -65,6 +92,34 @@ describe('remaining Mermaid family round-trip', () => { expect(second.nodes[0].data.journeyTitle).toBe('Checkout'); }); + it('preserves journey steps with colon-rich task and actor text through parse/export/parse', () => { + const source = ` + journey + title Incident Response + section Alerts + HTTP: 500 Error: 1: SRE: On-call + Recover service: 4: API: Team + `; + + const first = parseMermaidByType(source); + expect(first.error).toBeUndefined(); + expect(first.diagramType).toBe('journey'); + expect(first.nodes[0].data.journeyTask).toBe('HTTP: 500 Error'); + expect(first.nodes[0].data.journeyActor).toBe('SRE: On-call'); + + const exported = toMermaid(first.nodes, first.edges); + expect(exported.startsWith('journey')).toBe(true); + expect(exported).toContain('HTTP: 500 Error: 1: SRE: On-call'); + expect(exported).toContain('Recover service: 4: API: Team'); + + const second = parseMermaidByType(exported); + expect(second.error).toBeUndefined(); + expect(second.diagramType).toBe('journey'); + expect(second.nodes[0].data.journeyTask).toBe('HTTP: 500 Error'); + expect(second.nodes[0].data.journeyActor).toBe('SRE: On-call'); + expect(second.nodes[1].data.journeyActor).toBe('API: Team'); + }); + it('preserves classDiagram family relation semantics through parse/export/parse', () => { const source = ` classDiagram @@ -124,6 +179,30 @@ describe('remaining Mermaid family round-trip', () => { expect(second.edges[0].data?.classRelationTargetCardinality).toBe('*'); }); + it('preserves classDiagram multi-parameter generic identifiers through parse/export/parse', () => { + const source = ` + classDiagram + class Map~K, V~ + class Entry + Map~K, V~ --> Entry : stores + `; + + const first = parseMermaidByType(source); + expect(first.error).toBeUndefined(); + expect(first.diagramType).toBe('classDiagram'); + expect(first.nodes.find((node) => node.id === 'Map')).toBeDefined(); + + const exported = toMermaid(first.nodes, first.edges); + expect(exported).toContain('class Map~K, V~'); + expect(exported).toContain('Map~K, V~ --> Entry : stores'); + + const second = parseMermaidByType(exported); + expect(second.error).toBeUndefined(); + expect(second.diagramType).toBe('classDiagram'); + expect(second.nodes.find((node) => node.id === 'Map')).toBeDefined(); + expect(second.edges[0].data?.classRelationLabel).toBe('stores'); + }); + it('preserves erDiagram family relation semantics through parse/export/parse', () => { const source = ` erDiagram @@ -208,6 +287,37 @@ describe('remaining Mermaid family round-trip', () => { ); }); + it('preserves dotted erDiagram REFERENCES targets through parse/export/parse', () => { + const source = ` + erDiagram + ORDER { + uuid customer_id FK REFERENCES billing.Customer.id + } + `; + + const first = parseMermaidByType(source); + expect(first.error).toBeUndefined(); + expect(first.diagramType).toBe('erDiagram'); + + const exported = toMermaid(first.nodes, first.edges); + expect(exported).toContain('uuid customer_id FK REFERENCES billing.Customer.id'); + + const second = parseMermaidByType(exported); + expect(second.error).toBeUndefined(); + expect(second.diagramType).toBe('erDiagram'); + const fields = second.nodes[0].data.erFields ?? []; + expect(fields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'customer_id', + isForeignKey: true, + referencesTable: 'billing.Customer', + referencesField: 'id', + }), + ]) + ); + }); + it('preserves sequence notes and aliases through parse/export/parse', () => { const source = ` sequenceDiagram @@ -374,4 +484,39 @@ describe('remaining Mermaid family round-trip', () => { branchKind: 'start', }); }); + + it('preserves sequence critical/option branches through parse/export/parse', () => { + const source = ` + sequenceDiagram + participant A + participant B + critical primary path + A->>B: Request + option fallback path + B-->>A: Error + end + `; + + const first = parseMermaidByType(source); + expect(first.error).toBeUndefined(); + expect(first.diagramType).toBe('sequence'); + + const exported = toMermaid(first.nodes, first.edges); + expect(exported).toContain('critical primary path'); + expect(exported).toContain('option fallback path'); + + const second = parseMermaidByType(exported); + expect(second.error).toBeUndefined(); + expect(second.diagramType).toBe('sequence'); + expect(second.edges[0].data?.seqFragment).toMatchObject({ + type: 'critical', + condition: 'primary path', + branchKind: 'start', + }); + expect(second.edges[1].data?.seqFragment).toMatchObject({ + type: 'critical', + condition: 'fallback path', + branchKind: 'option', + }); + }); }); diff --git a/src/services/sequence/layoutConstants.ts b/src/services/sequence/layoutConstants.ts new file mode 100644 index 00000000..a5af7d26 --- /dev/null +++ b/src/services/sequence/layoutConstants.ts @@ -0,0 +1,7 @@ +export const SEQ_BOX_H = 48; +export const SEQ_ACTOR_EXTRA_H = 40; +export const SEQ_LIFELINE_H = 500; +export const SEQ_MSG_OFFSET = 20; +export const SEQ_MSG_SPACING = 52; +export const SEQ_NODE_W = 140; +export const SEQ_LANE_GAP = 84; diff --git a/src/services/sequenceLayout.test.ts b/src/services/sequenceLayout.test.ts new file mode 100644 index 00000000..3869ea8b --- /dev/null +++ b/src/services/sequenceLayout.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import type { FlowEdge, FlowNode } from '@/lib/types'; +import { relayoutSequenceDiagram } from './sequenceLayout'; + +function createParticipant( + id: string, + x: number, + kind: 'participant' | 'actor' = 'participant' +): FlowNode { + return { + id, + type: 'sequence_participant', + position: { x, y: 120 }, + data: { + label: id, + seqParticipantKind: kind, + }, + } as FlowNode; +} + +function createNote(id: string, target: string, order: number): FlowNode { + return { + id, + type: 'sequence_note', + position: { x: 0, y: 0 }, + data: { + label: 'Shared context', + seqNoteTarget: target, + seqNotePosition: 'over', + seqMessageOrder: order, + }, + } as FlowNode; +} + +function createFragment(id: string, order: number): FlowNode { + return { + id, + type: 'annotation', + position: { x: 0, y: 0 }, + data: { + label: 'ALT', + subLabel: 'happy path', + seqFragmentId: id, + seqMessageOrder: order, + }, + } as FlowNode; +} + +function createMessage(id: string, source: string, target: string, order: number): FlowEdge { + return { + id, + source, + target, + type: 'sequence_message', + data: { + seqMessageOrder: order, + }, + } as FlowEdge; +} + +describe('relayoutSequenceDiagram', () => { + it('repositions sequence participants into evenly spaced top lanes', () => { + const nodes = [ + createParticipant('api', 240), + createParticipant('client', 0, 'actor'), + createParticipant('db', 480), + ]; + + const result = relayoutSequenceDiagram(nodes, []); + const participants = result.nodes.filter((node) => node.type === 'sequence_participant'); + + expect(participants.map((node) => node.position.y)).toEqual([0, 40, 40]); + expect(participants[0].position.x).toBeLessThan(participants[1].position.x); + expect(participants[1].position.x).toBeLessThan(participants[2].position.x); + }); + + it('keeps sequence notes and fragments aligned to message order timeline', () => { + const nodes = [ + createParticipant('client', 0, 'actor'), + createParticipant('api', 220), + createNote('note-1', 'api', 2), + createFragment('fragment-1', 1), + ]; + const edges = [createMessage('e-1', 'client', 'api', 1)]; + + const result = relayoutSequenceDiagram(nodes, edges); + const note = result.nodes.find((node) => node.id === 'note-1'); + const fragment = result.nodes.find((node) => node.id === 'fragment-1'); + + expect(note?.position.y).toBeGreaterThan(150); + expect(fragment?.position.x).toBeLessThan(0); + expect(result.edges).toBe(edges); + }); +}); diff --git a/src/services/sequenceLayout.ts b/src/services/sequenceLayout.ts new file mode 100644 index 00000000..e7af616d --- /dev/null +++ b/src/services/sequenceLayout.ts @@ -0,0 +1,201 @@ +import { resolveNodeSize } from '@/components/nodeHelpers'; +import type { FlowEdge, FlowNode } from '@/lib/types'; +import { estimateWrappedTextBox } from '@/services/elk-layout/textSizing'; +import { + SEQ_ACTOR_EXTRA_H, + SEQ_BOX_H, + SEQ_LANE_GAP, + SEQ_MSG_OFFSET, + SEQ_MSG_SPACING, + SEQ_NODE_W, +} from '@/services/sequence/layoutConstants'; + +function sortByPosition(items: T[]): T[] { + return [...items].sort((left, right) => { + const leftOrder = + 'data' in left && typeof left.data?.seqMessageOrder === 'number' ? left.data.seqMessageOrder : null; + const rightOrder = + 'data' in right && typeof right.data?.seqMessageOrder === 'number' ? right.data.seqMessageOrder : null; + + if (leftOrder !== null || rightOrder !== null) { + if (leftOrder === null) return 1; + if (rightOrder === null) return -1; + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + } + + const leftX = 'position' in left ? left.position.x : 0; + const rightX = 'position' in right ? right.position.x : 0; + if (leftX !== rightX) { + return leftX - rightX; + } + + return left.id.localeCompare(right.id); + }); +} + +function getMeasuredNodeSize(node: FlowNode, minWidth: number, minHeight: number): { width: number; height: number } { + const measuredNode = node as FlowNode & { measured?: { width?: number; height?: number } }; + const measuredWidth = measuredNode.measured?.width; + const measuredHeight = measuredNode.measured?.height; + if (typeof measuredWidth === 'number' && typeof measuredHeight === 'number') { + return { + width: Math.max(measuredWidth, minWidth), + height: Math.max(measuredHeight, minHeight), + }; + } + + const resolved = resolveNodeSize(node); + return { + width: Math.max(resolved.width, minWidth), + height: Math.max(resolved.height, minHeight), + }; +} + +function getSequenceTimelineY(order: number): number { + return SEQ_BOX_H + SEQ_ACTOR_EXTRA_H + SEQ_MSG_OFFSET + order * SEQ_MSG_SPACING; +} + +function buildParticipantCenters(participants: FlowNode[]): Map { + const centers = new Map(); + let currentLeft = 0; + + for (const participant of participants) { + const size = getMeasuredNodeSize(participant, SEQ_NODE_W, SEQ_BOX_H + SEQ_ACTOR_EXTRA_H); + centers.set(participant.id, { + left: currentLeft, + center: currentLeft + size.width / 2, + width: size.width, + }); + currentLeft += size.width + SEQ_LANE_GAP; + } + + return centers; +} + +function relayoutParticipants( + participants: FlowNode[], + centers: Map +): FlowNode[] { + return participants.map((participant) => ({ + ...participant, + position: { + x: centers.get(participant.id)?.left ?? participant.position.x, + y: participant.data?.seqParticipantKind === 'actor' ? 0 : SEQ_ACTOR_EXTRA_H, + }, + })); +} + +function relayoutNotes( + notes: FlowNode[], + centers: Map +): FlowNode[] { + return sortByPosition(notes).map((note) => { + const order = + typeof note.data?.seqMessageOrder === 'number' ? note.data.seqMessageOrder : 0; + const noteSize = estimateWrappedTextBox(String(note.data?.label ?? ''), { + minWidth: 120, + minHeight: 56, + maxWidth: 180, + lineHeight: 18, + verticalPadding: 14, + }); + const targetIds = Array.isArray(note.data?.seqNoteTargets) + ? note.data.seqNoteTargets.filter((targetId): targetId is string => typeof targetId === 'string') + : typeof note.data?.seqNoteTarget === 'string' + ? [note.data.seqNoteTarget] + : []; + const targetCenters = targetIds + .map((targetId) => centers.get(targetId)) + .filter((value): value is { left: number; center: number; width: number } => Boolean(value)); + const primaryCenter = targetCenters[0]; + const sharedCenter = + targetCenters.length >= 2 + ? (targetCenters[0].center + targetCenters[targetCenters.length - 1].center) / 2 + : primaryCenter?.center; + + let x = note.position.x; + if (note.data?.seqNotePosition === 'left' && primaryCenter) { + x = primaryCenter.left - noteSize.width - 32; + } else if (note.data?.seqNotePosition === 'right' && primaryCenter) { + x = primaryCenter.left + primaryCenter.width + 32; + } else if (typeof sharedCenter === 'number') { + x = sharedCenter - noteSize.width / 2; + } + + return { + ...note, + position: { + x, + y: getSequenceTimelineY(order) - 18, + }, + style: { + ...note.style, + width: noteSize.width, + minHeight: noteSize.height, + }, + }; + }); +} + +function relayoutFragments( + fragments: FlowNode[], + participants: FlowNode[] +): FlowNode[] { + const leftEdge = participants[0]?.position.x ?? 0; + + return sortByPosition(fragments).map((fragment, index) => { + const order = + typeof fragment.data?.seqMessageOrder === 'number' ? fragment.data.seqMessageOrder : index; + const fragmentSize = estimateWrappedTextBox(String(fragment.data?.subLabel ?? fragment.data?.label ?? ''), { + minWidth: 136, + minHeight: 54, + maxWidth: 196, + lineHeight: 18, + verticalPadding: 14, + }); + + return { + ...fragment, + position: { + x: leftEdge - fragmentSize.width - 36, + y: getSequenceTimelineY(order) - 28, + }, + style: { + ...fragment.style, + width: fragmentSize.width, + minHeight: fragmentSize.height, + }, + }; + }); +} + +export function relayoutSequenceDiagram( + nodes: FlowNode[], + edges: FlowEdge[] +): { nodes: FlowNode[]; edges: FlowEdge[] } { + const participants = sortByPosition(nodes.filter((node) => node.type === 'sequence_participant')); + if (participants.length === 0) { + return { nodes, edges }; + } + + const centers = buildParticipantCenters(participants); + const notes = nodes.filter((node) => node.type === 'sequence_note'); + const fragments = nodes.filter( + (node) => node.type === 'annotation' && typeof node.data?.seqFragmentId === 'string' + ); + const remainingNodes = nodes.filter( + (node) => + node.type !== 'sequence_participant' + && node.type !== 'sequence_note' + && !(node.type === 'annotation' && typeof node.data?.seqFragmentId === 'string') + ); + + const layoutedParticipants = relayoutParticipants(participants, centers); + const layoutedNotes = relayoutNotes(notes, centers); + const layoutedFragments = relayoutFragments(fragments, layoutedParticipants); + + return { + nodes: [...layoutedParticipants, ...layoutedNotes, ...layoutedFragments, ...remainingNodes], + edges, + }; +} diff --git a/src/services/smartEdgeRouting.ts b/src/services/smartEdgeRouting.ts index 3862bd6c..2d3b7371 100644 --- a/src/services/smartEdgeRouting.ts +++ b/src/services/smartEdgeRouting.ts @@ -4,6 +4,7 @@ import type { ViewSettings } from '@/store/types'; import { getNodeParentId } from '@/lib/nodeParent'; import { getNodeHandleIdForSide, type HandleSide } from '@/lib/nodeHandles'; import { resolveNodeSize } from '@/components/nodeHelpers'; +import { estimateWrappedTextBox, DEFAULT_MAX_WIDTH } from './elk-layout/textSizing'; // Walks the parent hierarchy to get the canvas-absolute position of a node. // Uses node.position (relative) rather than positionAbsolute, which can be @@ -38,12 +39,14 @@ function getNodeDimensions(node: FlowNode): { width: number; height: number } { return resolved; } - // Match ELK's label-based estimation so handle assignment uses the same + // Match ELK's text-based estimation so handle assignment uses the same // assumed size that ELK used when computing node positions. - const label = node.data?.label ?? ''; - const estimatedWidth = Math.max(NODE_WIDTH, label.length * 8 + 40); - const estimatedHeight = Math.max(NODE_HEIGHT, Math.ceil(label.length / 40) * 20 + 60); - return { width: estimatedWidth, height: estimatedHeight }; + const estimate = estimateWrappedTextBox(String(node.data?.label ?? ''), { + minWidth: NODE_WIDTH, + minHeight: NODE_HEIGHT, + maxWidth: DEFAULT_MAX_WIDTH, + }); + return { width: estimate.width, height: estimate.height }; } type RoutingContext = { diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index eed8c3c4..f4810c49 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.test.tsx","./src/app.tsx","./src/constants.test.ts","./src/constants.ts","./src/index.tsx","./src/store.test.ts","./src/store.ts","./src/theme.test.ts","./src/theme.ts","./src/app/routestate.test.ts","./src/app/routestate.ts","./src/components/annotationnode.tsx","./src/components/architecturerulespanel.tsx","./src/components/cinematicexportoverlay.tsx","./src/components/commandbar.test.tsx","./src/components/commandbar.tsx","./src/components/connectmenu.test.tsx","./src/components/connectmenu.tsx","./src/components/connectmenusections.tsx","./src/components/contextmenu.test.tsx","./src/components/contextmenu.tsx","./src/components/customconnectionline.test.tsx","./src/components/customconnectionline.tsx","./src/components/customedge.tsx","./src/components/customnode.handleinteraction.test.tsx","./src/components/customnode.tsx","./src/components/customnodecontent.tsx","./src/components/designsystem.integration.test.tsx","./src/components/diagramviewer.tsx","./src/components/errorboundary.tsx","./src/components/exportmenu.tsx","./src/components/exportmenupanel.test.tsx","./src/components/exportmenupanel.tsx","./src/components/flowcanvas.tsx","./src/components/floweditor.test.tsx","./src/components/floweditor.tsx","./src/components/floweditoremptystate.tsx","./src/components/floweditorlayoutoverlay.tsx","./src/components/floweditorpanels.test.tsx","./src/components/floweditorpanels.tsx","./src/components/flowtabs.test.tsx","./src/components/flowtabs.tsx","./src/components/groupnode.tsx","./src/components/homepage.integration.test.tsx","./src/components/homepage.tsx","./src/components/iconassetnodebody.tsx","./src/components/iconmap.test.tsx","./src/components/iconmap.ts","./src/components/imagenode.tsx","./src/components/importrecoverydialog.test.tsx","./src/components/importrecoverydialog.tsx","./src/components/inlinetexteditsurface.test.tsx","./src/components/inlinetexteditsurface.tsx","./src/components/keyboardshortcutsmodal.tsx","./src/components/languageselector.tsx","./src/components/markdownrenderer.tsx","./src/components/memoizedmarkdown.tsx","./src/components/mermaiddiagnosticsbanner.test.tsx","./src/components/mermaiddiagnosticsbanner.tsx","./src/components/navigationcontrols.tsx","./src/components/nodebadges.tsx","./src/components/nodechrome.tsx","./src/components/nodequickcreatebuttons.tsx","./src/components/nodeshapesvg.tsx","./src/components/nodetransformcontrols.tsx","./src/components/playbackcontrols.tsx","./src/components/propertiespanel.tsx","./src/components/rightrail.tsx","./src/components/sectionnode.tsx","./src/components/shareembedmodal.tsx","./src/components/sharemodal.test.tsx","./src/components/sharemodal.tsx","./src/components/sidebarshell.tsx","./src/components/snapshotspanel.test.tsx","./src/components/snapshotspanel.tsx","./src/components/studioaipanel.test.tsx","./src/components/studioaipanel.tsx","./src/components/studioaipanelsections.tsx","./src/components/studiocodepanel.test.tsx","./src/components/studiocodepanel.tsx","./src/components/studiopanel.test.tsx","./src/components/studiopanel.tsx","./src/components/studioplaybackpanel.test.tsx","./src/components/studioplaybackpanel.tsx","./src/components/swimlanenode.tsx","./src/components/textnode.tsx","./src/components/themetoggle.tsx","./src/components/toolbar.tsx","./src/components/tooltip.tsx","./src/components/topnav.tsx","./src/components/welcomemodal.tsx","./src/components/annotationtheme.ts","./src/components/container-nodes.handleinteraction.test.tsx","./src/components/editorsurfacetiers.ts","./src/components/handleinteraction.test.ts","./src/components/handleinteraction.ts","./src/components/handleinteractionusage.test.ts","./src/components/lightweight-nodes.handleinteraction.test.tsx","./src/components/markdownsyntax.ts","./src/components/nodehelpers.test.ts","./src/components/nodehelpers.ts","./src/components/sharemodalcontent.ts","./src/components/studioaicopy.ts","./src/components/studioaipanelexamples.ts","./src/components/transformdiagnostics.test.ts","./src/components/transformdiagnostics.ts","./src/components/useactivenodeselection.ts","./src/components/useexportmenu.test.tsx","./src/components/useexportmenu.ts","./src/components/settingsmodal/aisettings.tsx","./src/components/settingsmodal/canvassettings.tsx","./src/components/settingsmodal/generalsettings.test.tsx","./src/components/settingsmodal/generalsettings.tsx","./src/components/settingsmodal/settingsmodal.test.tsx","./src/components/settingsmodal/settingsmodal.tsx","./src/components/settingsmodal/shortcutssettings.tsx","./src/components/settingsmodal/ai/advancedendpointsection.tsx","./src/components/settingsmodal/ai/customheaderseditor.tsx","./src/components/settingsmodal/ai/privacysection.tsx","./src/components/settingsmodal/ai/providericon.tsx","./src/components/settingsmodal/ai/providersection.tsx","./src/components/settingsmodal/ai/step.tsx","./src/components/add-items/additemregistry.tsx","./src/components/app/docssiteredirect.test.tsx","./src/components/app/docssiteredirect.tsx","./src/components/app/mobileworkspacegate.test.tsx","./src/components/app/mobileworkspacegate.tsx","./src/components/app/routeloadingfallback.test.tsx","./src/components/app/routeloadingfallback.tsx","./src/components/app/mobileworkspacegatecopy.ts","./src/components/app/routeloadingcopy.ts","./src/components/architecture-lint/lintrulespanel.tsx","./src/components/architecture-lint/ruleform.tsx","./src/components/architecture-lint/visualeditor.tsx","./src/components/command-bar/assetsview.test.tsx","./src/components/command-bar/assetsview.tsx","./src/components/command-bar/codebaseimportpanels.tsx","./src/components/command-bar/codebaseimportsection.tsx","./src/components/command-bar/designsystemeditor.tsx","./src/components/command-bar/designsystemview.tsx","./src/components/command-bar/diagramminipreview.tsx","./src/components/command-bar/figmaimportpanel.tsx","./src/components/command-bar/importsurfaceprimitives.tsx","./src/components/command-bar/importview.tsx","./src/components/command-bar/importviewpanels.tsx","./src/components/command-bar/layersview.tsx","./src/components/command-bar/layoutview.tsx","./src/components/command-bar/pagesview.tsx","./src/components/command-bar/rootview.test.tsx","./src/components/command-bar/rootview.tsx","./src/components/command-bar/searchview.tsx","./src/components/command-bar/templatesview.test.tsx","./src/components/command-bar/templatesview.tsx","./src/components/command-bar/viewheader.tsx","./src/components/command-bar/visualsview.tsx","./src/components/command-bar/applycodechanges.test.ts","./src/components/command-bar/applycodechanges.ts","./src/components/command-bar/assetsviewconstants.ts","./src/components/command-bar/importdetection.test.ts","./src/components/command-bar/importdetection.ts","./src/components/command-bar/importnativeparsers.ts","./src/components/command-bar/importviewmodel.test.ts","./src/components/command-bar/importviewmodel.ts","./src/components/command-bar/mermaidimportparser.ts","./src/components/command-bar/searchquery.test.ts","./src/components/command-bar/searchquery.ts","./src/components/command-bar/types.ts","./src/components/command-bar/useaiviewstate.test.tsx","./src/components/command-bar/useaiviewstate.ts","./src/components/command-bar/usecloudassetcatalog.ts","./src/components/command-bar/usecommandbarcommands.test.tsx","./src/components/command-bar/usecommandbarcommands.tsx","./src/components/custom-edge/customedgewrapper.tsx","./src/components/custom-edge/edgemarkerdefs.tsx","./src/components/custom-edge/sequencemessageedge.test.tsx","./src/components/custom-edge/sequencemessageedge.tsx","./src/components/custom-edge/animatededgepresentation.test.ts","./src/components/custom-edge/animatededgepresentation.ts","./src/components/custom-edge/classrelationsemantics.test.ts","./src/components/custom-edge/classrelationsemantics.ts","./src/components/custom-edge/edgerendermode.ts","./src/components/custom-edge/pathutils.test.ts","./src/components/custom-edge/pathutils.ts","./src/components/custom-edge/pathutilsgeometry.ts","./src/components/custom-edge/pathutilssiblingrouting.ts","./src/components/custom-edge/pathutilstypes.ts","./src/components/custom-edge/relationroutingsemantics.test.ts","./src/components/custom-edge/relationroutingsemantics.ts","./src/components/custom-edge/standardedgemarkers.test.ts","./src/components/custom-edge/standardedgemarkers.ts","./src/components/custom-nodes/architecturenode.handleinteraction.test.tsx","./src/components/custom-nodes/architecturenode.tsx","./src/components/custom-nodes/browsernode.tsx","./src/components/custom-nodes/classentitynode.handleinteraction.test.tsx","./src/components/custom-nodes/classnode.tsx","./src/components/custom-nodes/entitynode.tsx","./src/components/custom-nodes/journeynode.tsx","./src/components/custom-nodes/mindmapnode.tsx","./src/components/custom-nodes/mobilenode.tsx","./src/components/custom-nodes/sequencenotenode.tsx","./src/components/custom-nodes/sequenceparticipantnode.tsx","./src/components/custom-nodes/structurednodehandles.tsx","./src/components/custom-nodes/visualnodes.handleinteraction.test.tsx","./src/components/custom-nodes/browservariantrenderer.tsx","./src/components/custom-nodes/mobilevariantrenderer.tsx","./src/components/custom-nodes/structuredlistnavigation.test.ts","./src/components/custom-nodes/structuredlistnavigation.ts","./src/components/custom-nodes/variantconstants.ts","./src/components/diagram-diff/diffmodebanner.tsx","./src/components/flow-canvas/flowcanvasalignmentguidesoverlay.tsx","./src/components/flow-canvas/flowcanvasoverlays.tsx","./src/components/flow-canvas/flowcanvassurface.test.tsx","./src/components/flow-canvas/flowcanvassurface.tsx","./src/components/flow-canvas/streamingoverlay.tsx","./src/components/flow-canvas/alignmentguides.test.ts","./src/components/flow-canvas/alignmentguides.ts","./src/components/flow-canvas/flowcanvasreactflowcontracts.ts","./src/components/flow-canvas/flowcanvastypes.test.ts","./src/components/flow-canvas/flowcanvastypes.tsx","./src/components/flow-canvas/largegraphsafetymode.test.ts","./src/components/flow-canvas/largegraphsafetymode.ts","./src/components/flow-canvas/nodechromecoverage.test.ts","./src/components/flow-canvas/pastehelpers.test.ts","./src/components/flow-canvas/pastehelpers.ts","./src/components/flow-canvas/useflowcanvasalignmentguides.ts","./src/components/flow-canvas/useflowcanvasconnectionstate.ts","./src/components/flow-canvas/useflowcanvascontextactions.ts","./src/components/flow-canvas/useflowcanvasdragdrop.ts","./src/components/flow-canvas/useflowcanvasinteractionlod.ts","./src/components/flow-canvas/useflowcanvasmenus.ts","./src/components/flow-canvas/useflowcanvasmenusandactions.test.tsx","./src/components/flow-canvas/useflowcanvasmenusandactions.ts","./src/components/flow-canvas/useflowcanvasnodedraghandlers.ts","./src/components/flow-canvas/useflowcanvaspaste.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.test.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.ts","./src/components/flow-canvas/useflowcanvasselectiontools.test.tsx","./src/components/flow-canvas/useflowcanvasselectiontools.ts","./src/components/flow-canvas/useflowcanvasviewstate.test.ts","./src/components/flow-canvas/useflowcanvasviewstate.ts","./src/components/flow-canvas/useflowcanvaszoomlod.ts","./src/components/flow-editor/collaborationpresenceoverlay.test.tsx","./src/components/flow-editor/collaborationpresenceoverlay.tsx","./src/components/flow-editor/floweditorchrome.tsx","./src/components/flow-editor/buildfloweditorcontrollerparams.ts","./src/components/flow-editor/buildfloweditorscreencontrollerparams.ts","./src/components/flow-editor/chromeproptypes.ts","./src/components/flow-editor/floweditorchromeprops.ts","./src/components/flow-editor/infradslapply.test.ts","./src/components/flow-editor/infradslapply.ts","./src/components/flow-editor/panelprops.test.ts","./src/components/flow-editor/panelprops.ts","./src/components/flow-editor/shouldexitstudioonselection.test.ts","./src/components/flow-editor/shouldexitstudioonselection.ts","./src/components/flow-editor/usecollaborationnodepositions.ts","./src/components/flow-editor/usefloweditorcontroller.ts","./src/components/flow-editor/usefloweditorinteractionbindings.ts","./src/components/flow-editor/usefloweditorpanelactions.ts","./src/components/flow-editor/usefloweditorpanelprops.ts","./src/components/flow-editor/usefloweditorruntime.ts","./src/components/flow-editor/usefloweditorscreenbehavior.ts","./src/components/flow-editor/usefloweditorscreenmodel.ts","./src/components/flow-editor/usefloweditorscreenstate.ts","./src/components/flow-editor/usefloweditorshellcontroller.test.tsx","./src/components/flow-editor/usefloweditorshellcontroller.ts","./src/components/flow-editor/usefloweditorstudiocontroller.test.tsx","./src/components/flow-editor/usefloweditorstudiocontroller.ts","./src/components/flow-editor/useinfradslapply.ts","./src/components/home/githubcard.tsx","./src/components/home/homedashboard.tsx","./src/components/home/homeflowdialogs.tsx","./src/components/home/homesettingsview.tsx","./src/components/home/homesidebar.tsx","./src/components/home/hometemplatesview.tsx","./src/components/home/sidebarfooter.tsx","./src/components/home/welcomemodalstate.ts","./src/components/icons/assetsicon.tsx","./src/components/icons/openflowlogo.tsx","./src/components/infra-sync/infrasyncpanel.test.tsx","./src/components/infra-sync/infrasyncpanel.tsx","./src/components/journey/journeyscorecontrol.test.tsx","./src/components/journey/journeyscorecontrol.tsx","./src/components/properties/bulknodeproperties.test.tsx","./src/components/properties/bulknodeproperties.tsx","./src/components/properties/bulknodepropertiesfamilysections.tsx","./src/components/properties/bulknodepropertiessections.tsx","./src/components/properties/bulknodepropertiesutilitysections.tsx","./src/components/properties/colorpicker.test.tsx","./src/components/properties/colorpicker.tsx","./src/components/properties/customcolorpopover.tsx","./src/components/properties/diagramnodepropertiesrouter.test.ts","./src/components/properties/diagramnodepropertiesrouter.tsx","./src/components/properties/edgeproperties.tsx","./src/components/properties/iconpicker.tsx","./src/components/properties/icontilepickerprimitives.tsx","./src/components/properties/imageupload.tsx","./src/components/properties/inspectorprimitives.tsx","./src/components/properties/nodeactionbuttons.tsx","./src/components/properties/nodecontentsection.tsx","./src/components/properties/nodeimagesettingssection.tsx","./src/components/properties/nodeproperties.test.tsx","./src/components/properties/nodeproperties.tsx","./src/components/properties/nodewireframevariantsection.tsx","./src/components/properties/propertysliderrow.tsx","./src/components/properties/segmentedchoice.tsx","./src/components/properties/shapeselector.tsx","./src/components/properties/swatchpicker.tsx","./src/components/properties/bulknodepropertiesmodel.test.ts","./src/components/properties/bulknodepropertiesmodel.ts","./src/components/properties/colorpickerutils.ts","./src/components/properties/propertyinputbehavior.test.ts","./src/components/properties/propertyinputbehavior.ts","./src/components/properties/sectionactionbuilder.ts","./src/components/properties/shared.ts","./src/components/properties/wireframevariants.ts","./src/components/properties/edge/architectureedgesemanticssection.tsx","./src/components/properties/edge/edgecolorsection.test.tsx","./src/components/properties/edge/edgecolorsection.tsx","./src/components/properties/edge/edgeconditionsection.tsx","./src/components/properties/edge/edgelabelsection.tsx","./src/components/properties/edge/edgerelationsection.test.tsx","./src/components/properties/edge/edgerelationsection.tsx","./src/components/properties/edge/edgeroutesection.test.tsx","./src/components/properties/edge/edgeroutesection.tsx","./src/components/properties/edge/edgestylesection.tsx","./src/components/properties/edge/sequencemessagesection.tsx","./src/components/properties/edge/architecturesemantics.test.ts","./src/components/properties/edge/architecturesemantics.ts","./src/components/properties/edge/edgecolorutils.ts","./src/components/properties/edge/edgelabelmodel.test.ts","./src/components/properties/edge/edgelabelmodel.ts","./src/components/properties/edge/errelationoptions.ts","./src/components/properties/families/architecturenodeproperties.test.tsx","./src/components/properties/families/architecturenodeproperties.tsx","./src/components/properties/families/architecturenodesection.tsx","./src/components/properties/families/classdiagramnodeproperties.tsx","./src/components/properties/families/classmemberlisteditor.tsx","./src/components/properties/families/classnodesection.tsx","./src/components/properties/families/erdiagramnodeproperties.tsx","./src/components/properties/families/entityfieldlisteditor.tsx","./src/components/properties/families/entitynodesection.tsx","./src/components/properties/families/journeynodeproperties.tsx","./src/components/properties/families/journeynodesection.tsx","./src/components/properties/families/mindmapnodeproperties.test.tsx","./src/components/properties/families/mindmapnodeproperties.tsx","./src/components/properties/families/sequencenodeproperties.tsx","./src/components/properties/families/sequencenodesection.tsx","./src/components/properties/families/specializednodecolorsections.test.tsx","./src/components/properties/families/structuredtextlisteditor.tsx","./src/components/properties/families/architectureoptions.ts","./src/components/studio-code-panel/usestudiocodepanelcontroller.test.tsx","./src/components/studio-code-panel/usestudiocodepanelcontroller.ts","./src/components/templates/templatepresentation.tsx","./src/components/toolbar/toolbaraddmenu.test.tsx","./src/components/toolbar/toolbaraddmenu.tsx","./src/components/toolbar/toolbaraddmenupanel.tsx","./src/components/toolbar/toolbarhistorycontrols.tsx","./src/components/toolbar/toolbarmodecontrols.tsx","./src/components/toolbar/toolbarbuttonstyles.ts","./src/components/top-nav/savestatusindicator.tsx","./src/components/top-nav/topnavactions.tsx","./src/components/top-nav/topnavbrand.tsx","./src/components/top-nav/topnavmenu.test.tsx","./src/components/top-nav/topnavmenu.tsx","./src/components/top-nav/topnavmenupanel.tsx","./src/components/top-nav/usetopnavstate.ts","./src/components/ui/button.tsx","./src/components/ui/collapsiblesection.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/searchfield.tsx","./src/components/ui/segmentedtabs.tsx","./src/components/ui/select.test.tsx","./src/components/ui/select.tsx","./src/components/ui/sidebaritem.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toastcontext.tsx","./src/components/ui/editorfieldstyles.ts","./src/config/aiproviders.test.ts","./src/config/aiproviders.ts","./src/config/rolloutflags.ts","./src/context/architecturelintcontext.tsx","./src/context/cinematicexportcontext.tsx","./src/context/diagramdiffcontext.tsx","./src/context/themecontext.tsx","./src/diagram-types/bootstrap.test.ts","./src/diagram-types/bootstrap.ts","./src/diagram-types/builtinplugins.ts","./src/diagram-types/builtinpropertypanels.ts","./src/diagram-types/registerbuiltinplugins.test.ts","./src/diagram-types/registerbuiltinplugins.ts","./src/diagram-types/registerbuiltinpropertypanels.test.ts","./src/diagram-types/registerbuiltinpropertypanels.ts","./src/diagram-types/architecture/fuzzcorpus.test.ts","./src/diagram-types/architecture/plugin.test.ts","./src/diagram-types/architecture/plugin.ts","./src/diagram-types/classdiagram/fuzzcorpus.test.ts","./src/diagram-types/classdiagram/plugin.test.ts","./src/diagram-types/classdiagram/plugin.ts","./src/diagram-types/core/contracts.ts","./src/diagram-types/core/index.ts","./src/diagram-types/core/propertypanels.test.ts","./src/diagram-types/core/propertypanels.ts","./src/diagram-types/core/registry.ts","./src/diagram-types/erdiagram/fuzzcorpus.test.ts","./src/diagram-types/erdiagram/plugin.test.ts","./src/diagram-types/erdiagram/plugin.ts","./src/diagram-types/flowchart/plugin.ts","./src/diagram-types/journey/fuzzcorpus.test.ts","./src/diagram-types/journey/plugin.test.ts","./src/diagram-types/journey/plugin.ts","./src/diagram-types/mindmap/fuzzcorpus.test.ts","./src/diagram-types/mindmap/plugin.test.ts","./src/diagram-types/mindmap/plugin.ts","./src/diagram-types/sequence/plugin.test.ts","./src/diagram-types/sequence/plugin.ts","./src/diagram-types/statediagram/plugin.test.ts","./src/diagram-types/statediagram/plugin.ts","./src/docs/docsroutes.ts","./src/docs/publicdocscatalog.js","./src/hooks/edgeconnectinteractions.test.ts","./src/hooks/edgeconnectinteractions.ts","./src/hooks/flowexportviewport.test.ts","./src/hooks/flowexportviewport.ts","./src/hooks/mindmaptopicactionrequest.ts","./src/hooks/nodelabeleditrequest.test.ts","./src/hooks/nodelabeleditrequest.ts","./src/hooks/nodequickcreaterequest.test.ts","./src/hooks/nodequickcreaterequest.ts","./src/hooks/snapshotpolicy.test.ts","./src/hooks/snapshotpolicy.ts","./src/hooks/useaigeneration.ts","./src/hooks/useanalyticspreference.ts","./src/hooks/useanimatededgeperformancewarning.test.ts","./src/hooks/useanimatededgeperformancewarning.ts","./src/hooks/useassetcatalog.ts","./src/hooks/usecinematicexport.ts","./src/hooks/useclipboardoperations.ts","./src/hooks/usedesignsystem.ts","./src/hooks/useedgeinteractions.ts","./src/hooks/useedgeoperations.ts","./src/hooks/usefloweditoractions.test.ts","./src/hooks/usefloweditoractions.ts","./src/hooks/usefloweditorcallbacks.ts","./src/hooks/usefloweditorcollaboration.test.ts","./src/hooks/usefloweditorcollaboration.ts","./src/hooks/usefloweditoruistate.ts","./src/hooks/useflowexport.ts","./src/hooks/useflowhistory.test.ts","./src/hooks/useflowhistory.ts","./src/hooks/useflowoperations.ts","./src/hooks/usegithubstars.ts","./src/hooks/useinfrasync.ts","./src/hooks/useinlinenodetextedit.test.ts","./src/hooks/useinlinenodetextedit.ts","./src/hooks/usekeyboardshortcuts.test.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/uselayoutoperations.ts","./src/hooks/usemarkdowneditor.ts","./src/hooks/usemenukeyboardnavigation.ts","./src/hooks/usemodifierkeys.test.ts","./src/hooks/usemodifierkeys.ts","./src/hooks/usenodeoperations.ts","./src/hooks/useplayback.ts","./src/hooks/useprovidershapepreview.ts","./src/hooks/userecentimports.ts","./src/hooks/useshiftheld.ts","./src/hooks/usesnapshots.ts","./src/hooks/usestaticexport.ts","./src/hooks/usestoragepressureguard.ts","./src/hooks/usestructuredlisteditor.ts","./src/hooks/usestyleclipboard.test.ts","./src/hooks/usestyleclipboard.ts","./src/hooks/ai-generation/chathistorystorage.test.ts","./src/hooks/ai-generation/chathistorystorage.ts","./src/hooks/ai-generation/codetoarchitecture.test.ts","./src/hooks/ai-generation/codetoarchitecture.ts","./src/hooks/ai-generation/codebaseanalyzer.test.ts","./src/hooks/ai-generation/codebaseanalyzer.ts","./src/hooks/ai-generation/codebaseparser.test.ts","./src/hooks/ai-generation/codebaseparser.ts","./src/hooks/ai-generation/codebasetonativediagram.test.ts","./src/hooks/ai-generation/codebasetonativediagram.ts","./src/hooks/ai-generation/graphcomposer.test.ts","./src/hooks/ai-generation/graphcomposer.ts","./src/hooks/ai-generation/nodeactionprompts.test.ts","./src/hooks/ai-generation/nodeactionprompts.ts","./src/hooks/ai-generation/openapiparser.test.ts","./src/hooks/ai-generation/openapiparser.ts","./src/hooks/ai-generation/openapitosequence.ts","./src/hooks/ai-generation/positionpreservingapply.test.ts","./src/hooks/ai-generation/positionpreservingapply.ts","./src/hooks/ai-generation/readiness.test.ts","./src/hooks/ai-generation/readiness.ts","./src/hooks/ai-generation/requestlifecycle.test.ts","./src/hooks/ai-generation/requestlifecycle.ts","./src/hooks/ai-generation/sqlparser.test.ts","./src/hooks/ai-generation/sqlparser.ts","./src/hooks/ai-generation/sqltoerd.ts","./src/hooks/ai-generation/streamingparser.ts","./src/hooks/ai-generation/streamingstore.ts","./src/hooks/ai-generation/terraformtocloud.ts","./src/hooks/edge-operations/utils.test.ts","./src/hooks/edge-operations/utils.ts","./src/hooks/flow-editor-actions/exporthandlers.test.ts","./src/hooks/flow-editor-actions/exporthandlers.ts","./src/hooks/flow-editor-actions/helpers.ts","./src/hooks/flow-editor-actions/layouthandlers.test.ts","./src/hooks/flow-editor-actions/layouthandlers.ts","./src/hooks/flow-export/diagramdocumenttransfer.test.ts","./src/hooks/flow-export/diagramdocumenttransfer.ts","./src/hooks/flow-export/exportcapture.test.ts","./src/hooks/flow-export/exportcapture.ts","./src/hooks/flow-operations/useflowcoreactions.ts","./src/hooks/node-operations/createconnectedsibling.ts","./src/hooks/node-operations/dragstopreconcilepolicy.test.ts","./src/hooks/node-operations/dragstopreconcilepolicy.ts","./src/hooks/node-operations/nodefactories.ts","./src/hooks/node-operations/routingduringdrag.test.ts","./src/hooks/node-operations/routingduringdrag.ts","./src/hooks/node-operations/sectionbounds.ts","./src/hooks/node-operations/sectionhittesting.ts","./src/hooks/node-operations/sectionoperations.ts","./src/hooks/node-operations/usearchitecturenodeoperations.test.ts","./src/hooks/node-operations/usearchitecturenodeoperations.ts","./src/hooks/node-operations/usemindmapnodeoperations.test.ts","./src/hooks/node-operations/usemindmapnodeoperations.ts","./src/hooks/node-operations/usenodedragoperations.test.ts","./src/hooks/node-operations/usenodedragoperations.ts","./src/hooks/node-operations/usenodeoperationadders.ts","./src/hooks/node-operations/utils.test.ts","./src/hooks/node-operations/utils.ts","./src/i18n/config.test.ts","./src/i18n/config.ts","./src/i18n/strictmodelocalecoverage.test.ts","./src/i18n/usedlocalecoverage.test.ts","./src/lib/aiiconspipeline.test.ts","./src/lib/architecturetemplatedata.ts","./src/lib/architecturetemplates.test.ts","./src/lib/architecturetemplates.ts","./src/lib/brand.ts","./src/lib/classmembers.ts","./src/lib/colorutils.ts","./src/lib/connectcreationpolicy.test.ts","./src/lib/connectcreationpolicy.ts","./src/lib/date.ts","./src/lib/designtokens.ts","./src/lib/devperformance.ts","./src/lib/entityfields.test.ts","./src/lib/entityfields.ts","./src/lib/ertoclassconversion.test.ts","./src/lib/ertoclassconversion.ts","./src/lib/exportfilename.test.ts","./src/lib/exportfilename.ts","./src/lib/flowminddslparserv2.test.ts","./src/lib/flowminddslparserv2.ts","./src/lib/fuzzymatch.ts","./src/lib/genericshapepolicy.ts","./src/lib/iconmatcher.test.ts","./src/lib/iconmatcher.ts","./src/lib/iconresolver.test.ts","./src/lib/iconresolver.ts","./src/lib/id.ts","./src/lib/index.ts","./src/lib/legacybranding.ts","./src/lib/logger.ts","./src/lib/mermaidenrichmentpipeline.test.ts","./src/lib/mermaidparser.ts","./src/lib/mermaidparserhelpers.ts","./src/lib/mermaidparsermodel.ts","./src/lib/mindmaplayout.test.ts","./src/lib/mindmaplayout.ts","./src/lib/mindmaplayoutengine.ts","./src/lib/mindmaptree.ts","./src/lib/nodebulkediting.test.ts","./src/lib/nodebulkediting.ts","./src/lib/nodeenricher.test.ts","./src/lib/nodeenricher.ts","./src/lib/nodehandles.ts","./src/lib/nodeiconstate.test.ts","./src/lib/nodeiconstate.ts","./src/lib/nodeparent.ts","./src/lib/nodestyledata.test.ts","./src/lib/nodestyledata.ts","./src/lib/openflowdslparser.ts","./src/lib/openflowdslparserv2.ts","./src/lib/reactflowcompat.ts","./src/lib/reconnectedge.test.ts","./src/lib/reconnectedge.ts","./src/lib/relationsemantics.test.ts","./src/lib/relationsemantics.ts","./src/lib/releasestaleelkroutes.test.ts","./src/lib/releasestaleelkroutes.ts","./src/lib/result.ts","./src/lib/semanticclassifier.test.ts","./src/lib/semanticclassifier.ts","./src/lib/storagepressure.test.ts","./src/lib/storagepressure.ts","./src/lib/types.ts","./src/lib/xsssafety.test.tsx","./src/services/aligndistribute.ts","./src/services/aiservice.test.ts","./src/services/aiservice.ts","./src/services/aiserviceschemas.ts","./src/services/animatedexport.test.ts","./src/services/animatedexport.ts","./src/services/architectureroundtrip.test.ts","./src/services/assetcatalog.test.ts","./src/services/assetcatalog.ts","./src/services/assetpresentation.ts","./src/services/canonicalserialization.test.ts","./src/services/canonicalserialization.ts","./src/services/composediagramfordisplay.test.ts","./src/services/composediagramfordisplay.ts","./src/services/diagramdocument.test.ts","./src/services/diagramdocument.ts","./src/services/diagramdocumentschemas.ts","./src/services/domainlibrary.test.ts","./src/services/domainlibrary.ts","./src/services/elklayout.test.ts","./src/services/elklayout.ts","./src/services/exportservice.test.ts","./src/services/exportservice.ts","./src/services/figmaexportservice.ts","./src/services/flowchartroundtrip.test.ts","./src/services/geminiservice.ts","./src/services/geminisysteminstruction.ts","./src/services/gifencoder.test.ts","./src/services/gifencoder.ts","./src/services/githubfetcher.test.ts","./src/services/githubfetcher.ts","./src/services/iconassetcatalog.ts","./src/services/importfidelity.test.ts","./src/services/importfidelity.ts","./src/services/mermaidparser.test.ts","./src/services/openflowdslexporter.test.ts","./src/services/openflowdslexporter.ts","./src/services/openflowdslparser.test.ts","./src/services/openflowroundtripgolden.test.ts","./src/services/openflowroundtripgoldenfixtures.ts","./src/services/operationfeedback.ts","./src/services/remainingfamiliesroundtrip.test.ts","./src/services/smartedgerouting.test.ts","./src/services/smartedgerouting.ts","./src/services/statediagramroundtrip.test.ts","./src/services/templates.selector.test.ts","./src/services/templates.ts","./src/services/zipextractor.ts","./src/services/ai/contextserializer.test.ts","./src/services/ai/contextserializer.ts","./src/services/analytics/analytics.ts","./src/services/analytics/analyticssettings.test.ts","./src/services/analytics/analyticssettings.ts","./src/services/analytics/surfaceanalyticsclient.ts","./src/services/architecturelint/defaultrules.ts","./src/services/architecturelint/ruleengine.test.ts","./src/services/architecturelint/ruleengine.ts","./src/services/architecturelint/rulelibrary.ts","./src/services/architecturelint/types.ts","./src/services/architecturelint/workspacerules.ts","./src/services/collaboration/bootstrap.test.ts","./src/services/collaboration/bootstrap.ts","./src/services/collaboration/canvasdiff.test.ts","./src/services/collaboration/canvasdiff.ts","./src/services/collaboration/comments.test.ts","./src/services/collaboration/comments.ts","./src/services/collaboration/contracts.test.ts","./src/services/collaboration/contracts.ts","./src/services/collaboration/hookutils.ts","./src/services/collaboration/operationlog.test.ts","./src/services/collaboration/operationlog.ts","./src/services/collaboration/presenceviewmodel.test.ts","./src/services/collaboration/presenceviewmodel.ts","./src/services/collaboration/reducer.test.ts","./src/services/collaboration/reducer.ts","./src/services/collaboration/roomconfig.ts","./src/services/collaboration/roomlink.test.ts","./src/services/collaboration/roomlink.ts","./src/services/collaboration/runtimecontroller.test.ts","./src/services/collaboration/runtimecontroller.ts","./src/services/collaboration/runtimehookutils.test.ts","./src/services/collaboration/runtimehookutils.ts","./src/services/collaboration/schemas.ts","./src/services/collaboration/session.test.ts","./src/services/collaboration/session.ts","./src/services/collaboration/storebridge.test.ts","./src/services/collaboration/storebridge.ts","./src/services/collaboration/transport.test.ts","./src/services/collaboration/transport.ts","./src/services/collaboration/transportfactory.test.ts","./src/services/collaboration/transportfactory.ts","./src/services/collaboration/types.ts","./src/services/collaboration/versioning.test.ts","./src/services/collaboration/versioning.ts","./src/services/collaboration/yjspeertransport.test.ts","./src/services/collaboration/yjspeertransport.ts","./src/services/diagramdiff/diffengine.ts","./src/services/elk-layout/boundaryfanout.ts","./src/services/elk-layout/determinism.ts","./src/services/elk-layout/options.ts","./src/services/elk-layout/types.ts","./src/services/export/cinematicbuildplan.test.ts","./src/services/export/cinematicbuildplan.ts","./src/services/export/cinematicexport.ts","./src/services/export/cinematicexporttheme.test.ts","./src/services/export/cinematicexporttheme.ts","./src/services/export/cinematicrenderstate.test.ts","./src/services/export/cinematicrenderstate.ts","./src/services/export/formatting.ts","./src/services/export/mermaidbuilder.test.ts","./src/services/export/mermaidbuilder.ts","./src/services/export/mermaidexportquality.test.ts","./src/services/export/pdfdocument.test.ts","./src/services/export/pdfdocument.ts","./src/services/export/plantumlbuilder.ts","./src/services/export/mermaid/architecturemermaid.ts","./src/services/export/mermaid/classdiagrammermaid.ts","./src/services/export/mermaid/erdiagrammermaid.ts","./src/services/export/mermaid/journeymermaid.ts","./src/services/export/mermaid/mindmapmermaid.ts","./src/services/export/mermaid/sequencemermaid.ts","./src/services/export/mermaid/statediagrammermaid.ts","./src/services/figma/edgehelpers.ts","./src/services/figma/iconhelpers.ts","./src/services/figma/nodelayers.ts","./src/services/figma/themehelpers.ts","./src/services/figmaimport/figmaapiclient.ts","./src/services/flowpilot/assetgrounding.test.ts","./src/services/flowpilot/assetgrounding.ts","./src/services/flowpilot/prompting.ts","./src/services/flowpilot/responsepolicy.test.ts","./src/services/flowpilot/responsepolicy.ts","./src/services/flowpilot/skills.ts","./src/services/flowpilot/thread.ts","./src/services/flowpilot/types.ts","./src/services/infrasync/dockercomposeparser.test.ts","./src/services/infrasync/dockercomposeparser.ts","./src/services/infrasync/infratodsl.test.ts","./src/services/infrasync/infratodsl.ts","./src/services/infrasync/kubernetesparser.test.ts","./src/services/infrasync/kubernetesparser.ts","./src/services/infrasync/terraformstateparser.test.ts","./src/services/infrasync/terraformstateparser.ts","./src/services/infrasync/types.ts","./src/services/mermaid/compatreportharness.test.ts","./src/services/mermaid/detectdiagramtype.test.ts","./src/services/mermaid/detectdiagramtype.ts","./src/services/mermaid/diagnosticformatting.test.ts","./src/services/mermaid/diagnosticformatting.ts","./src/services/mermaid/diagnosticssnapshot.test.ts","./src/services/mermaid/diagnosticssnapshot.ts","./src/services/mermaid/editablepartialcorpus.test.ts","./src/services/mermaid/importcontracts.ts","./src/services/mermaid/importstatepresentation.test.ts","./src/services/mermaid/importstatepresentation.ts","./src/services/mermaid/officialmermaidvalidation.test.ts","./src/services/mermaid/officialmermaidvalidation.ts","./src/services/mermaid/parsemermaidbytype.test.ts","./src/services/mermaid/parsemermaidbytype.ts","./src/services/mermaid/strictmodediagnosticspresentation.test.ts","./src/services/mermaid/strictmodediagnosticspresentation.ts","./src/services/mermaid/strictmodeguidance.test.ts","./src/services/mermaid/strictmodeguidance.ts","./src/services/mermaid/strictmodeuxregression.test.ts","./src/services/mermaid/supportmatrix.test.ts","./src/services/mermaid/supportmatrix.ts","./src/services/offline/registerappshellserviceworker.test.ts","./src/services/offline/registerappshellserviceworker.ts","./src/services/onboarding/config.ts","./src/services/onboarding/eventschemas.ts","./src/services/onboarding/events.test.ts","./src/services/onboarding/events.ts","./src/services/playback/contracts.test.ts","./src/services/playback/contracts.ts","./src/services/playback/model.test.ts","./src/services/playback/model.ts","./src/services/playback/studio.test.ts","./src/services/playback/studio.ts","./src/services/sequence/sequencemessage.test.ts","./src/services/sequence/sequencemessage.ts","./src/services/shapelibrary/bootstrap.test.ts","./src/services/shapelibrary/bootstrap.ts","./src/services/shapelibrary/ingestionpipeline.test.ts","./src/services/shapelibrary/ingestionpipeline.ts","./src/services/shapelibrary/manifestvalidation.test.ts","./src/services/shapelibrary/manifestvalidation.ts","./src/services/shapelibrary/providercatalog.test.ts","./src/services/shapelibrary/providercatalog.ts","./src/services/shapelibrary/registry.test.ts","./src/services/shapelibrary/registry.ts","./src/services/shapelibrary/starterpacks.ts","./src/services/shapelibrary/types.ts","./src/services/storage/fallbackstorage.ts","./src/services/storage/flowdocumentmodel.test.ts","./src/services/storage/flowdocumentmodel.ts","./src/services/storage/flowpersiststorage.test.ts","./src/services/storage/flowpersiststorage.ts","./src/services/storage/indexeddbhelpers.ts","./src/services/storage/indexeddbschema.test.ts","./src/services/storage/indexeddbschema.ts","./src/services/storage/indexeddbstatestorage.test.ts","./src/services/storage/indexeddbstatestorage.ts","./src/services/storage/localfirstrepository.ts","./src/services/storage/localfirstruntime.ts","./src/services/storage/persisteddocumentadapters.test.ts","./src/services/storage/persisteddocumentadapters.ts","./src/services/storage/persistencetypes.ts","./src/services/storage/snapshotstorage.test.ts","./src/services/storage/snapshotstorage.ts","./src/services/storage/storageruntime.test.ts","./src/services/storage/storageruntime.ts","./src/services/storage/storageschemas.test.ts","./src/services/storage/storageschemas.ts","./src/services/storage/storagetelemetry.test.ts","./src/services/storage/storagetelemetry.ts","./src/services/storage/storagetelemetrysink.test.ts","./src/services/storage/storagetelemetrysink.ts","./src/services/storage/uilocalstorage.test.ts","./src/services/storage/uilocalstorage.ts","./src/services/templatelibrary/registry.test.ts","./src/services/templatelibrary/registry.ts","./src/services/templatelibrary/startertemplates.assets.test.ts","./src/services/templatelibrary/startertemplates.test.ts","./src/services/templatelibrary/startertemplates.ts","./src/services/templatelibrary/templatefactories.ts","./src/services/templatelibrary/types.ts","./src/store/actionfactory.ts","./src/store/aisettings.test.ts","./src/store/aisettings.ts","./src/store/aisettingspersistence.ts","./src/store/aisettingsschemas.test.ts","./src/store/aisettingsschemas.ts","./src/store/canvashooks.ts","./src/store/createflowstore.test.ts","./src/store/createflowstore.ts","./src/store/createflowstorepersistoptions.ts","./src/store/createflowstorestate.ts","./src/store/defaults.ts","./src/store/designsystemhooks.ts","./src/store/documenthooks.ts","./src/store/documentstatesync.test.ts","./src/store/documentstatesync.ts","./src/store/editorpagehooks.ts","./src/store/historyhooks.ts","./src/store/historystate.ts","./src/store/persistence.test.ts","./src/store/persistence.ts","./src/store/persistenceschemas.ts","./src/store/selectionhooks.ts","./src/store/selectors.test.ts","./src/store/selectors.ts","./src/store/tabhooks.ts","./src/store/types.ts","./src/store/viewhooks.ts","./src/store/workspacedocumentmodel.test.ts","./src/store/workspacedocumentmodel.ts","./src/store/actions/createaiandselectionactions.ts","./src/store/actions/createcanvasactions.ts","./src/store/actions/createdesignsystemactions.ts","./src/store/actions/createhistoryactions.test.ts","./src/store/actions/createhistoryactions.ts","./src/store/actions/createlayeractions.ts","./src/store/actions/createtabactions.ts","./src/store/actions/createviewactions.ts","./src/store/actions/createworkspacedocumentactions.ts","./src/store/actions/synctabnodesedges.ts","./src/store/slices/createcanvaseditorslice.ts","./src/store/slices/createexperienceslice.ts","./src/store/slices/createworkspaceslice.ts"],"version":"5.8.3"} \ No newline at end of file +{"root":["./src/app.test.tsx","./src/app.tsx","./src/constants.test.ts","./src/constants.ts","./src/index.tsx","./src/store.test.ts","./src/store.ts","./src/theme.test.ts","./src/theme.ts","./src/app/routestate.test.ts","./src/app/routestate.ts","./src/components/annotationnode.tsx","./src/components/architecturerulespanel.tsx","./src/components/cinematicexportoverlay.tsx","./src/components/commandbar.test.tsx","./src/components/commandbar.tsx","./src/components/connectmenu.test.tsx","./src/components/connectmenu.tsx","./src/components/connectmenusections.tsx","./src/components/contextmenu.test.tsx","./src/components/contextmenu.tsx","./src/components/customconnectionline.test.tsx","./src/components/customconnectionline.tsx","./src/components/customedge.tsx","./src/components/customnode.handleinteraction.test.tsx","./src/components/customnode.tsx","./src/components/customnodecontent.tsx","./src/components/designsystem.integration.test.tsx","./src/components/diagramviewer.tsx","./src/components/errorboundary.tsx","./src/components/exportmenu.tsx","./src/components/exportmenupanel.test.tsx","./src/components/exportmenupanel.tsx","./src/components/flowcanvas.tsx","./src/components/floweditor.test.tsx","./src/components/floweditor.tsx","./src/components/floweditoremptystate.tsx","./src/components/floweditorlayoutoverlay.tsx","./src/components/floweditorpanels.test.tsx","./src/components/floweditorpanels.tsx","./src/components/flowtabs.test.tsx","./src/components/flowtabs.tsx","./src/components/groupnode.tsx","./src/components/homepage.integration.test.tsx","./src/components/homepage.tsx","./src/components/iconassetnodebody.tsx","./src/components/iconmap.test.tsx","./src/components/iconmap.ts","./src/components/imagenode.tsx","./src/components/importrecoverydialog.test.tsx","./src/components/importrecoverydialog.tsx","./src/components/inlinetexteditsurface.test.tsx","./src/components/inlinetexteditsurface.tsx","./src/components/keyboardshortcutsmodal.tsx","./src/components/languageselector.tsx","./src/components/markdownrenderer.tsx","./src/components/memoizedmarkdown.tsx","./src/components/mermaiddiagnosticsbanner.test.tsx","./src/components/mermaiddiagnosticsbanner.tsx","./src/components/navigationcontrols.tsx","./src/components/nodebadges.tsx","./src/components/nodechrome.tsx","./src/components/nodequickcreatebuttons.tsx","./src/components/nodeshapesvg.tsx","./src/components/nodetransformcontrols.tsx","./src/components/playbackcontrols.tsx","./src/components/propertiespanel.tsx","./src/components/rightrail.tsx","./src/components/sectionnode.tsx","./src/components/shareembedmodal.tsx","./src/components/sharemodal.test.tsx","./src/components/sharemodal.tsx","./src/components/sidebarshell.tsx","./src/components/snapshotspanel.test.tsx","./src/components/snapshotspanel.tsx","./src/components/studioaipanel.test.tsx","./src/components/studioaipanel.tsx","./src/components/studioaipanelsections.tsx","./src/components/studiocodepanel.test.tsx","./src/components/studiocodepanel.tsx","./src/components/studiopanel.test.tsx","./src/components/studiopanel.tsx","./src/components/studioplaybackpanel.test.tsx","./src/components/studioplaybackpanel.tsx","./src/components/swimlanenode.tsx","./src/components/textnode.tsx","./src/components/themetoggle.tsx","./src/components/toolbar.tsx","./src/components/tooltip.tsx","./src/components/topnav.tsx","./src/components/welcomemodal.tsx","./src/components/annotationtheme.ts","./src/components/container-nodes.handleinteraction.test.tsx","./src/components/editorsurfacetiers.ts","./src/components/handleinteraction.test.ts","./src/components/handleinteraction.ts","./src/components/handleinteractionusage.test.ts","./src/components/lightweight-nodes.handleinteraction.test.tsx","./src/components/markdownsyntax.ts","./src/components/nodehelpers.test.ts","./src/components/nodehelpers.ts","./src/components/sharemodalcontent.ts","./src/components/studioaicopy.ts","./src/components/studioaipanelexamples.ts","./src/components/transformdiagnostics.test.ts","./src/components/transformdiagnostics.ts","./src/components/useactivenodeselection.ts","./src/components/useexportmenu.test.tsx","./src/components/useexportmenu.ts","./src/components/settingsmodal/aisettings.tsx","./src/components/settingsmodal/canvassettings.tsx","./src/components/settingsmodal/generalsettings.test.tsx","./src/components/settingsmodal/generalsettings.tsx","./src/components/settingsmodal/settingsmodal.test.tsx","./src/components/settingsmodal/settingsmodal.tsx","./src/components/settingsmodal/shortcutssettings.tsx","./src/components/settingsmodal/ai/advancedendpointsection.tsx","./src/components/settingsmodal/ai/customheaderseditor.tsx","./src/components/settingsmodal/ai/privacysection.tsx","./src/components/settingsmodal/ai/providericon.tsx","./src/components/settingsmodal/ai/providersection.tsx","./src/components/settingsmodal/ai/step.tsx","./src/components/add-items/additemregistry.tsx","./src/components/app/docssiteredirect.test.tsx","./src/components/app/docssiteredirect.tsx","./src/components/app/mobileworkspacegate.test.tsx","./src/components/app/mobileworkspacegate.tsx","./src/components/app/routeloadingfallback.test.tsx","./src/components/app/routeloadingfallback.tsx","./src/components/app/mobileworkspacegatecopy.ts","./src/components/app/routeloadingcopy.ts","./src/components/architecture-lint/lintrulespanel.tsx","./src/components/architecture-lint/ruleform.tsx","./src/components/architecture-lint/visualeditor.tsx","./src/components/command-bar/assetsview.test.tsx","./src/components/command-bar/assetsview.tsx","./src/components/command-bar/codebaseimportpanels.tsx","./src/components/command-bar/codebaseimportsection.tsx","./src/components/command-bar/designsystemeditor.tsx","./src/components/command-bar/designsystemview.tsx","./src/components/command-bar/diagramminipreview.tsx","./src/components/command-bar/figmaimportpanel.tsx","./src/components/command-bar/importsurfaceprimitives.tsx","./src/components/command-bar/importview.tsx","./src/components/command-bar/importviewpanels.tsx","./src/components/command-bar/layersview.tsx","./src/components/command-bar/layoutview.tsx","./src/components/command-bar/pagesview.tsx","./src/components/command-bar/rootview.test.tsx","./src/components/command-bar/rootview.tsx","./src/components/command-bar/searchview.tsx","./src/components/command-bar/templatesview.test.tsx","./src/components/command-bar/templatesview.tsx","./src/components/command-bar/viewheader.tsx","./src/components/command-bar/visualsview.tsx","./src/components/command-bar/applycodechanges.test.ts","./src/components/command-bar/applycodechanges.ts","./src/components/command-bar/assetsviewconstants.ts","./src/components/command-bar/importdetection.test.ts","./src/components/command-bar/importdetection.ts","./src/components/command-bar/importnativeparsers.ts","./src/components/command-bar/importviewmodel.test.ts","./src/components/command-bar/importviewmodel.ts","./src/components/command-bar/mermaidimportparser.ts","./src/components/command-bar/searchquery.test.ts","./src/components/command-bar/searchquery.ts","./src/components/command-bar/types.ts","./src/components/command-bar/useaiviewstate.test.tsx","./src/components/command-bar/useaiviewstate.ts","./src/components/command-bar/usecloudassetcatalog.ts","./src/components/command-bar/usecommandbarcommands.test.tsx","./src/components/command-bar/usecommandbarcommands.tsx","./src/components/custom-edge/customedgewrapper.tsx","./src/components/custom-edge/edgemarkerdefs.tsx","./src/components/custom-edge/sequencemessageedge.test.tsx","./src/components/custom-edge/sequencemessageedge.tsx","./src/components/custom-edge/animatededgepresentation.test.ts","./src/components/custom-edge/animatededgepresentation.ts","./src/components/custom-edge/classrelationsemantics.test.ts","./src/components/custom-edge/classrelationsemantics.ts","./src/components/custom-edge/edgerendermode.ts","./src/components/custom-edge/pathutils.test.ts","./src/components/custom-edge/pathutils.ts","./src/components/custom-edge/pathutilsgeometry.ts","./src/components/custom-edge/pathutilssiblingrouting.ts","./src/components/custom-edge/pathutilstypes.ts","./src/components/custom-edge/relationroutingsemantics.test.ts","./src/components/custom-edge/relationroutingsemantics.ts","./src/components/custom-edge/standardedgemarkers.test.ts","./src/components/custom-edge/standardedgemarkers.ts","./src/components/custom-nodes/architecturenode.handleinteraction.test.tsx","./src/components/custom-nodes/architecturenode.tsx","./src/components/custom-nodes/browsernode.tsx","./src/components/custom-nodes/classentitynode.handleinteraction.test.tsx","./src/components/custom-nodes/classnode.tsx","./src/components/custom-nodes/entitynode.tsx","./src/components/custom-nodes/journeynode.tsx","./src/components/custom-nodes/mindmapnode.tsx","./src/components/custom-nodes/mobilenode.tsx","./src/components/custom-nodes/sequencenotenode.tsx","./src/components/custom-nodes/sequenceparticipantnode.tsx","./src/components/custom-nodes/structurednodehandles.tsx","./src/components/custom-nodes/visualnodes.handleinteraction.test.tsx","./src/components/custom-nodes/browservariantrenderer.tsx","./src/components/custom-nodes/mobilevariantrenderer.tsx","./src/components/custom-nodes/structuredlistnavigation.test.ts","./src/components/custom-nodes/structuredlistnavigation.ts","./src/components/custom-nodes/variantconstants.ts","./src/components/diagram-diff/diffmodebanner.tsx","./src/components/flow-canvas/flowcanvasalignmentguidesoverlay.tsx","./src/components/flow-canvas/flowcanvasoverlays.tsx","./src/components/flow-canvas/flowcanvassurface.test.tsx","./src/components/flow-canvas/flowcanvassurface.tsx","./src/components/flow-canvas/streamingoverlay.tsx","./src/components/flow-canvas/alignmentguides.test.ts","./src/components/flow-canvas/alignmentguides.ts","./src/components/flow-canvas/flowcanvasreactflowcontracts.ts","./src/components/flow-canvas/flowcanvastypes.test.ts","./src/components/flow-canvas/flowcanvastypes.tsx","./src/components/flow-canvas/largegraphsafetymode.test.ts","./src/components/flow-canvas/largegraphsafetymode.ts","./src/components/flow-canvas/nodechromecoverage.test.ts","./src/components/flow-canvas/pastehelpers.test.ts","./src/components/flow-canvas/pastehelpers.ts","./src/components/flow-canvas/useflowcanvasalignmentguides.ts","./src/components/flow-canvas/useflowcanvasconnectionstate.ts","./src/components/flow-canvas/useflowcanvascontextactions.ts","./src/components/flow-canvas/useflowcanvasdragdrop.ts","./src/components/flow-canvas/useflowcanvasinteractionlod.ts","./src/components/flow-canvas/useflowcanvasmenus.ts","./src/components/flow-canvas/useflowcanvasmenusandactions.test.tsx","./src/components/flow-canvas/useflowcanvasmenusandactions.ts","./src/components/flow-canvas/useflowcanvasnodedraghandlers.ts","./src/components/flow-canvas/useflowcanvaspaste.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.test.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.ts","./src/components/flow-canvas/useflowcanvasselectiontools.test.tsx","./src/components/flow-canvas/useflowcanvasselectiontools.ts","./src/components/flow-canvas/useflowcanvasviewstate.test.ts","./src/components/flow-canvas/useflowcanvasviewstate.ts","./src/components/flow-canvas/useflowcanvaszoomlod.ts","./src/components/flow-editor/collaborationpresenceoverlay.test.tsx","./src/components/flow-editor/collaborationpresenceoverlay.tsx","./src/components/flow-editor/floweditorchrome.tsx","./src/components/flow-editor/buildfloweditorcontrollerparams.ts","./src/components/flow-editor/buildfloweditorscreencontrollerparams.ts","./src/components/flow-editor/chromeproptypes.ts","./src/components/flow-editor/floweditorchromeprops.ts","./src/components/flow-editor/infradslapply.test.ts","./src/components/flow-editor/infradslapply.ts","./src/components/flow-editor/panelprops.test.ts","./src/components/flow-editor/panelprops.ts","./src/components/flow-editor/shouldexitstudioonselection.test.ts","./src/components/flow-editor/shouldexitstudioonselection.ts","./src/components/flow-editor/usecollaborationnodepositions.ts","./src/components/flow-editor/usefloweditorcontroller.ts","./src/components/flow-editor/usefloweditorinteractionbindings.ts","./src/components/flow-editor/usefloweditorpanelactions.ts","./src/components/flow-editor/usefloweditorpanelprops.ts","./src/components/flow-editor/usefloweditorruntime.ts","./src/components/flow-editor/usefloweditorscreenbehavior.ts","./src/components/flow-editor/usefloweditorscreenmodel.ts","./src/components/flow-editor/usefloweditorscreenstate.ts","./src/components/flow-editor/usefloweditorshellcontroller.test.tsx","./src/components/flow-editor/usefloweditorshellcontroller.ts","./src/components/flow-editor/usefloweditorstudiocontroller.test.tsx","./src/components/flow-editor/usefloweditorstudiocontroller.ts","./src/components/flow-editor/useinfradslapply.ts","./src/components/home/githubcard.tsx","./src/components/home/homedashboard.tsx","./src/components/home/homeflowdialogs.tsx","./src/components/home/homesettingsview.tsx","./src/components/home/homesidebar.tsx","./src/components/home/hometemplatesview.tsx","./src/components/home/sidebarfooter.tsx","./src/components/home/welcomemodalstate.ts","./src/components/icons/assetsicon.tsx","./src/components/icons/openflowlogo.tsx","./src/components/infra-sync/infrasyncpanel.test.tsx","./src/components/infra-sync/infrasyncpanel.tsx","./src/components/journey/journeyscorecontrol.test.tsx","./src/components/journey/journeyscorecontrol.tsx","./src/components/properties/bulknodeproperties.test.tsx","./src/components/properties/bulknodeproperties.tsx","./src/components/properties/bulknodepropertiesfamilysections.tsx","./src/components/properties/bulknodepropertiessections.tsx","./src/components/properties/bulknodepropertiesutilitysections.tsx","./src/components/properties/colorpicker.test.tsx","./src/components/properties/colorpicker.tsx","./src/components/properties/customcolorpopover.tsx","./src/components/properties/diagramnodepropertiesrouter.test.ts","./src/components/properties/diagramnodepropertiesrouter.tsx","./src/components/properties/edgeproperties.tsx","./src/components/properties/iconpicker.tsx","./src/components/properties/icontilepickerprimitives.tsx","./src/components/properties/imageupload.tsx","./src/components/properties/inspectorprimitives.tsx","./src/components/properties/nodeactionbuttons.tsx","./src/components/properties/nodecontentsection.tsx","./src/components/properties/nodeimagesettingssection.tsx","./src/components/properties/nodeproperties.test.tsx","./src/components/properties/nodeproperties.tsx","./src/components/properties/nodewireframevariantsection.tsx","./src/components/properties/propertysliderrow.tsx","./src/components/properties/segmentedchoice.tsx","./src/components/properties/shapeselector.tsx","./src/components/properties/swatchpicker.tsx","./src/components/properties/bulknodepropertiesmodel.test.ts","./src/components/properties/bulknodepropertiesmodel.ts","./src/components/properties/colorpickerutils.ts","./src/components/properties/propertyinputbehavior.test.ts","./src/components/properties/propertyinputbehavior.ts","./src/components/properties/sectionactionbuilder.ts","./src/components/properties/shared.ts","./src/components/properties/wireframevariants.ts","./src/components/properties/edge/architectureedgesemanticssection.tsx","./src/components/properties/edge/edgecolorsection.test.tsx","./src/components/properties/edge/edgecolorsection.tsx","./src/components/properties/edge/edgeconditionsection.tsx","./src/components/properties/edge/edgelabelsection.tsx","./src/components/properties/edge/edgerelationsection.test.tsx","./src/components/properties/edge/edgerelationsection.tsx","./src/components/properties/edge/edgeroutesection.test.tsx","./src/components/properties/edge/edgeroutesection.tsx","./src/components/properties/edge/edgestylesection.tsx","./src/components/properties/edge/sequencemessagesection.tsx","./src/components/properties/edge/architecturesemantics.test.ts","./src/components/properties/edge/architecturesemantics.ts","./src/components/properties/edge/edgecolorutils.ts","./src/components/properties/edge/edgelabelmodel.test.ts","./src/components/properties/edge/edgelabelmodel.ts","./src/components/properties/edge/errelationoptions.ts","./src/components/properties/families/architecturenodeproperties.test.tsx","./src/components/properties/families/architecturenodeproperties.tsx","./src/components/properties/families/architecturenodesection.tsx","./src/components/properties/families/classdiagramnodeproperties.tsx","./src/components/properties/families/classmemberlisteditor.tsx","./src/components/properties/families/classnodesection.tsx","./src/components/properties/families/erdiagramnodeproperties.tsx","./src/components/properties/families/entityfieldlisteditor.tsx","./src/components/properties/families/entitynodesection.tsx","./src/components/properties/families/journeynodeproperties.tsx","./src/components/properties/families/journeynodesection.tsx","./src/components/properties/families/mindmapnodeproperties.test.tsx","./src/components/properties/families/mindmapnodeproperties.tsx","./src/components/properties/families/sequencenodeproperties.tsx","./src/components/properties/families/sequencenodesection.tsx","./src/components/properties/families/specializednodecolorsections.test.tsx","./src/components/properties/families/structuredtextlisteditor.tsx","./src/components/properties/families/architectureoptions.ts","./src/components/studio-code-panel/usestudiocodepanelcontroller.test.tsx","./src/components/studio-code-panel/usestudiocodepanelcontroller.ts","./src/components/templates/templatepresentation.tsx","./src/components/toolbar/toolbaraddmenu.test.tsx","./src/components/toolbar/toolbaraddmenu.tsx","./src/components/toolbar/toolbaraddmenupanel.tsx","./src/components/toolbar/toolbarhistorycontrols.tsx","./src/components/toolbar/toolbarmodecontrols.tsx","./src/components/toolbar/toolbarbuttonstyles.ts","./src/components/top-nav/savestatusindicator.tsx","./src/components/top-nav/topnavactions.tsx","./src/components/top-nav/topnavbrand.tsx","./src/components/top-nav/topnavmenu.test.tsx","./src/components/top-nav/topnavmenu.tsx","./src/components/top-nav/topnavmenupanel.tsx","./src/components/top-nav/usetopnavstate.ts","./src/components/ui/button.tsx","./src/components/ui/collapsiblesection.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/searchfield.tsx","./src/components/ui/segmentedtabs.tsx","./src/components/ui/select.test.tsx","./src/components/ui/select.tsx","./src/components/ui/sidebaritem.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toastcontext.tsx","./src/components/ui/editorfieldstyles.ts","./src/config/aiproviders.test.ts","./src/config/aiproviders.ts","./src/config/rolloutflags.ts","./src/context/architecturelintcontext.tsx","./src/context/cinematicexportcontext.tsx","./src/context/diagramdiffcontext.tsx","./src/context/themecontext.tsx","./src/diagram-types/bootstrap.test.ts","./src/diagram-types/bootstrap.ts","./src/diagram-types/builtinplugins.ts","./src/diagram-types/builtinpropertypanels.ts","./src/diagram-types/registerbuiltinplugins.test.ts","./src/diagram-types/registerbuiltinplugins.ts","./src/diagram-types/registerbuiltinpropertypanels.test.ts","./src/diagram-types/registerbuiltinpropertypanels.ts","./src/diagram-types/architecture/fuzzcorpus.test.ts","./src/diagram-types/architecture/plugin.test.ts","./src/diagram-types/architecture/plugin.ts","./src/diagram-types/classdiagram/fuzzcorpus.test.ts","./src/diagram-types/classdiagram/plugin.test.ts","./src/diagram-types/classdiagram/plugin.ts","./src/diagram-types/core/contracts.ts","./src/diagram-types/core/index.ts","./src/diagram-types/core/propertypanels.test.ts","./src/diagram-types/core/propertypanels.ts","./src/diagram-types/core/registry.ts","./src/diagram-types/erdiagram/fuzzcorpus.test.ts","./src/diagram-types/erdiagram/plugin.test.ts","./src/diagram-types/erdiagram/plugin.ts","./src/diagram-types/flowchart/plugin.ts","./src/diagram-types/journey/fuzzcorpus.test.ts","./src/diagram-types/journey/plugin.test.ts","./src/diagram-types/journey/plugin.ts","./src/diagram-types/mindmap/fuzzcorpus.test.ts","./src/diagram-types/mindmap/plugin.test.ts","./src/diagram-types/mindmap/plugin.ts","./src/diagram-types/sequence/plugin.test.ts","./src/diagram-types/sequence/plugin.ts","./src/diagram-types/statediagram/plugin.test.ts","./src/diagram-types/statediagram/plugin.ts","./src/docs/docsroutes.ts","./src/docs/publicdocscatalog.js","./src/hooks/edgeconnectinteractions.test.ts","./src/hooks/edgeconnectinteractions.ts","./src/hooks/flowexportviewport.test.ts","./src/hooks/flowexportviewport.ts","./src/hooks/mindmaptopicactionrequest.ts","./src/hooks/nodelabeleditrequest.test.ts","./src/hooks/nodelabeleditrequest.ts","./src/hooks/nodequickcreaterequest.test.ts","./src/hooks/nodequickcreaterequest.ts","./src/hooks/snapshotpolicy.test.ts","./src/hooks/snapshotpolicy.ts","./src/hooks/useaigeneration.ts","./src/hooks/useanalyticspreference.ts","./src/hooks/useanimatededgeperformancewarning.test.ts","./src/hooks/useanimatededgeperformancewarning.ts","./src/hooks/useassetcatalog.ts","./src/hooks/usecinematicexport.ts","./src/hooks/useclipboardoperations.ts","./src/hooks/usedesignsystem.ts","./src/hooks/useedgeinteractions.ts","./src/hooks/useedgeoperations.ts","./src/hooks/usefloweditoractions.test.ts","./src/hooks/usefloweditoractions.ts","./src/hooks/usefloweditorcallbacks.ts","./src/hooks/usefloweditorcollaboration.test.ts","./src/hooks/usefloweditorcollaboration.ts","./src/hooks/usefloweditoruistate.ts","./src/hooks/useflowexport.ts","./src/hooks/useflowhistory.test.ts","./src/hooks/useflowhistory.ts","./src/hooks/useflowoperations.ts","./src/hooks/usegithubstars.ts","./src/hooks/useinfrasync.ts","./src/hooks/useinlinenodetextedit.test.ts","./src/hooks/useinlinenodetextedit.ts","./src/hooks/usekeyboardshortcuts.test.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/uselayoutoperations.ts","./src/hooks/usemarkdowneditor.ts","./src/hooks/usemenukeyboardnavigation.ts","./src/hooks/usemodifierkeys.test.ts","./src/hooks/usemodifierkeys.ts","./src/hooks/usenodeoperations.ts","./src/hooks/useplayback.ts","./src/hooks/useprovidershapepreview.ts","./src/hooks/userecentimports.ts","./src/hooks/useshiftheld.ts","./src/hooks/usesnapshots.ts","./src/hooks/usestaticexport.ts","./src/hooks/usestoragepressureguard.ts","./src/hooks/usestructuredlisteditor.ts","./src/hooks/usestyleclipboard.test.ts","./src/hooks/usestyleclipboard.ts","./src/hooks/ai-generation/chathistorystorage.test.ts","./src/hooks/ai-generation/chathistorystorage.ts","./src/hooks/ai-generation/codetoarchitecture.test.ts","./src/hooks/ai-generation/codetoarchitecture.ts","./src/hooks/ai-generation/codebaseanalyzer.test.ts","./src/hooks/ai-generation/codebaseanalyzer.ts","./src/hooks/ai-generation/codebaseparser.test.ts","./src/hooks/ai-generation/codebaseparser.ts","./src/hooks/ai-generation/codebasetonativediagram.test.ts","./src/hooks/ai-generation/codebasetonativediagram.ts","./src/hooks/ai-generation/graphcomposer.test.ts","./src/hooks/ai-generation/graphcomposer.ts","./src/hooks/ai-generation/nodeactionprompts.test.ts","./src/hooks/ai-generation/nodeactionprompts.ts","./src/hooks/ai-generation/openapiparser.test.ts","./src/hooks/ai-generation/openapiparser.ts","./src/hooks/ai-generation/openapitosequence.ts","./src/hooks/ai-generation/positionpreservingapply.test.ts","./src/hooks/ai-generation/positionpreservingapply.ts","./src/hooks/ai-generation/readiness.test.ts","./src/hooks/ai-generation/readiness.ts","./src/hooks/ai-generation/requestlifecycle.test.ts","./src/hooks/ai-generation/requestlifecycle.ts","./src/hooks/ai-generation/sqlparser.test.ts","./src/hooks/ai-generation/sqlparser.ts","./src/hooks/ai-generation/sqltoerd.ts","./src/hooks/ai-generation/streamingparser.ts","./src/hooks/ai-generation/streamingstore.ts","./src/hooks/ai-generation/terraformtocloud.ts","./src/hooks/edge-operations/utils.test.ts","./src/hooks/edge-operations/utils.ts","./src/hooks/flow-editor-actions/exporthandlers.test.ts","./src/hooks/flow-editor-actions/exporthandlers.ts","./src/hooks/flow-editor-actions/helpers.ts","./src/hooks/flow-editor-actions/layouthandlers.test.ts","./src/hooks/flow-editor-actions/layouthandlers.ts","./src/hooks/flow-export/diagramdocumenttransfer.test.ts","./src/hooks/flow-export/diagramdocumenttransfer.ts","./src/hooks/flow-export/exportcapture.test.ts","./src/hooks/flow-export/exportcapture.ts","./src/hooks/flow-operations/useflowcoreactions.ts","./src/hooks/node-operations/createconnectedsibling.ts","./src/hooks/node-operations/dragstopreconcilepolicy.test.ts","./src/hooks/node-operations/dragstopreconcilepolicy.ts","./src/hooks/node-operations/nodefactories.ts","./src/hooks/node-operations/routingduringdrag.test.ts","./src/hooks/node-operations/routingduringdrag.ts","./src/hooks/node-operations/sectionbounds.ts","./src/hooks/node-operations/sectionhittesting.ts","./src/hooks/node-operations/sectionoperations.ts","./src/hooks/node-operations/usearchitecturenodeoperations.test.ts","./src/hooks/node-operations/usearchitecturenodeoperations.ts","./src/hooks/node-operations/usemindmapnodeoperations.test.ts","./src/hooks/node-operations/usemindmapnodeoperations.ts","./src/hooks/node-operations/usenodedragoperations.test.ts","./src/hooks/node-operations/usenodedragoperations.ts","./src/hooks/node-operations/usenodeoperationadders.ts","./src/hooks/node-operations/utils.test.ts","./src/hooks/node-operations/utils.ts","./src/i18n/config.test.ts","./src/i18n/config.ts","./src/i18n/strictmodelocalecoverage.test.ts","./src/i18n/usedlocalecoverage.test.ts","./src/lib/aiiconspipeline.test.ts","./src/lib/architecturetemplatedata.ts","./src/lib/architecturetemplates.test.ts","./src/lib/architecturetemplates.ts","./src/lib/brand.ts","./src/lib/classmembers.ts","./src/lib/colorutils.ts","./src/lib/connectcreationpolicy.test.ts","./src/lib/connectcreationpolicy.ts","./src/lib/date.ts","./src/lib/designtokens.ts","./src/lib/devperformance.ts","./src/lib/entityfields.test.ts","./src/lib/entityfields.ts","./src/lib/ertoclassconversion.test.ts","./src/lib/ertoclassconversion.ts","./src/lib/exportfilename.test.ts","./src/lib/exportfilename.ts","./src/lib/flowminddslparserv2.test.ts","./src/lib/flowminddslparserv2.ts","./src/lib/fuzzymatch.ts","./src/lib/genericshapepolicy.ts","./src/lib/iconmatcher.test.ts","./src/lib/iconmatcher.ts","./src/lib/iconresolver.test.ts","./src/lib/iconresolver.ts","./src/lib/id.ts","./src/lib/index.ts","./src/lib/legacybranding.ts","./src/lib/logger.ts","./src/lib/mermaidenrichmentpipeline.test.ts","./src/lib/mermaidparser.ts","./src/lib/mermaidparserhelpers.ts","./src/lib/mermaidparsermodel.ts","./src/lib/mindmaplayout.test.ts","./src/lib/mindmaplayout.ts","./src/lib/mindmaplayoutengine.ts","./src/lib/mindmaptree.ts","./src/lib/nodebulkediting.test.ts","./src/lib/nodebulkediting.ts","./src/lib/nodeenricher.test.ts","./src/lib/nodeenricher.ts","./src/lib/nodehandles.ts","./src/lib/nodeiconstate.test.ts","./src/lib/nodeiconstate.ts","./src/lib/nodeparent.ts","./src/lib/nodestyledata.test.ts","./src/lib/nodestyledata.ts","./src/lib/openflowdslparser.ts","./src/lib/openflowdslparserv2.ts","./src/lib/reactflowcompat.ts","./src/lib/reconnectedge.test.ts","./src/lib/reconnectedge.ts","./src/lib/relationsemantics.test.ts","./src/lib/relationsemantics.ts","./src/lib/releasestaleelkroutes.test.ts","./src/lib/releasestaleelkroutes.ts","./src/lib/result.ts","./src/lib/semanticclassifier.test.ts","./src/lib/semanticclassifier.ts","./src/lib/storagepressure.test.ts","./src/lib/storagepressure.ts","./src/lib/types.ts","./src/lib/xsssafety.test.tsx","./src/services/aligndistribute.ts","./src/services/aiservice.test.ts","./src/services/aiservice.ts","./src/services/aiserviceschemas.ts","./src/services/animatedexport.test.ts","./src/services/animatedexport.ts","./src/services/architectureroundtrip.test.ts","./src/services/assetcatalog.test.ts","./src/services/assetcatalog.ts","./src/services/assetpresentation.ts","./src/services/canonicalserialization.test.ts","./src/services/canonicalserialization.ts","./src/services/composediagramfordisplay.test.ts","./src/services/composediagramfordisplay.ts","./src/services/diagramdocument.test.ts","./src/services/diagramdocument.ts","./src/services/diagramdocumentschemas.ts","./src/services/domainlibrary.test.ts","./src/services/domainlibrary.ts","./src/services/elklayout.test.ts","./src/services/elklayout.ts","./src/services/exportservice.test.ts","./src/services/exportservice.ts","./src/services/figmaexportservice.ts","./src/services/flowchartroundtrip.test.ts","./src/services/geminiservice.ts","./src/services/geminisysteminstruction.ts","./src/services/gifencoder.test.ts","./src/services/gifencoder.ts","./src/services/githubfetcher.test.ts","./src/services/githubfetcher.ts","./src/services/iconassetcatalog.ts","./src/services/importfidelity.test.ts","./src/services/importfidelity.ts","./src/services/importlayoutmetadata.ts","./src/services/mermaidparser.test.ts","./src/services/openflowdslexporter.test.ts","./src/services/openflowdslexporter.ts","./src/services/openflowdslparser.test.ts","./src/services/openflowroundtripgolden.test.ts","./src/services/openflowroundtripgoldenfixtures.ts","./src/services/operationfeedback.ts","./src/services/remainingfamiliesroundtrip.test.ts","./src/services/sequencelayout.test.ts","./src/services/sequencelayout.ts","./src/services/smartedgerouting.test.ts","./src/services/smartedgerouting.ts","./src/services/statediagramroundtrip.test.ts","./src/services/templates.selector.test.ts","./src/services/templates.ts","./src/services/zipextractor.ts","./src/services/ai/contextserializer.test.ts","./src/services/ai/contextserializer.ts","./src/services/analytics/analytics.ts","./src/services/analytics/analyticssettings.test.ts","./src/services/analytics/analyticssettings.ts","./src/services/analytics/surfaceanalyticsclient.ts","./src/services/architecturelint/defaultrules.ts","./src/services/architecturelint/ruleengine.test.ts","./src/services/architecturelint/ruleengine.ts","./src/services/architecturelint/rulelibrary.ts","./src/services/architecturelint/types.ts","./src/services/architecturelint/workspacerules.ts","./src/services/collaboration/bootstrap.test.ts","./src/services/collaboration/bootstrap.ts","./src/services/collaboration/canvasdiff.test.ts","./src/services/collaboration/canvasdiff.ts","./src/services/collaboration/comments.test.ts","./src/services/collaboration/comments.ts","./src/services/collaboration/contracts.test.ts","./src/services/collaboration/contracts.ts","./src/services/collaboration/hookutils.ts","./src/services/collaboration/operationlog.test.ts","./src/services/collaboration/operationlog.ts","./src/services/collaboration/presenceviewmodel.test.ts","./src/services/collaboration/presenceviewmodel.ts","./src/services/collaboration/reducer.test.ts","./src/services/collaboration/reducer.ts","./src/services/collaboration/roomconfig.ts","./src/services/collaboration/roomlink.test.ts","./src/services/collaboration/roomlink.ts","./src/services/collaboration/runtimecontroller.test.ts","./src/services/collaboration/runtimecontroller.ts","./src/services/collaboration/runtimehookutils.test.ts","./src/services/collaboration/runtimehookutils.ts","./src/services/collaboration/schemas.ts","./src/services/collaboration/session.test.ts","./src/services/collaboration/session.ts","./src/services/collaboration/storebridge.test.ts","./src/services/collaboration/storebridge.ts","./src/services/collaboration/transport.test.ts","./src/services/collaboration/transport.ts","./src/services/collaboration/transportfactory.test.ts","./src/services/collaboration/transportfactory.ts","./src/services/collaboration/types.ts","./src/services/collaboration/versioning.test.ts","./src/services/collaboration/versioning.ts","./src/services/collaboration/yjspeertransport.test.ts","./src/services/collaboration/yjspeertransport.ts","./src/services/diagramdiff/diffengine.ts","./src/services/elk-layout/boundaryfanout.ts","./src/services/elk-layout/determinism.ts","./src/services/elk-layout/options.test.ts","./src/services/elk-layout/options.ts","./src/services/elk-layout/textsizing.ts","./src/services/elk-layout/types.ts","./src/services/export/cinematicbuildplan.test.ts","./src/services/export/cinematicbuildplan.ts","./src/services/export/cinematicexport.ts","./src/services/export/cinematicexporttheme.test.ts","./src/services/export/cinematicexporttheme.ts","./src/services/export/cinematicrenderstate.test.ts","./src/services/export/cinematicrenderstate.ts","./src/services/export/formatting.ts","./src/services/export/mermaidbuilder.test.ts","./src/services/export/mermaidbuilder.ts","./src/services/export/mermaidexportquality.test.ts","./src/services/export/pdfdocument.test.ts","./src/services/export/pdfdocument.ts","./src/services/export/plantumlbuilder.ts","./src/services/export/mermaid/architecturemermaid.ts","./src/services/export/mermaid/classdiagrammermaid.ts","./src/services/export/mermaid/erdiagrammermaid.ts","./src/services/export/mermaid/journeymermaid.ts","./src/services/export/mermaid/mindmapmermaid.ts","./src/services/export/mermaid/sequencemermaid.ts","./src/services/export/mermaid/statediagrammermaid.ts","./src/services/figma/edgehelpers.ts","./src/services/figma/iconhelpers.ts","./src/services/figma/nodelayers.ts","./src/services/figma/themehelpers.ts","./src/services/figmaimport/figmaapiclient.ts","./src/services/flowpilot/assetgrounding.test.ts","./src/services/flowpilot/assetgrounding.ts","./src/services/flowpilot/prompting.ts","./src/services/flowpilot/responsepolicy.test.ts","./src/services/flowpilot/responsepolicy.ts","./src/services/flowpilot/skills.ts","./src/services/flowpilot/thread.ts","./src/services/flowpilot/types.ts","./src/services/infrasync/dockercomposeparser.test.ts","./src/services/infrasync/dockercomposeparser.ts","./src/services/infrasync/infratodsl.test.ts","./src/services/infrasync/infratodsl.ts","./src/services/infrasync/kubernetesparser.test.ts","./src/services/infrasync/kubernetesparser.ts","./src/services/infrasync/terraformstateparser.test.ts","./src/services/infrasync/terraformstateparser.ts","./src/services/infrasync/types.ts","./src/services/mermaid/compatfixturecorpus.test.ts","./src/services/mermaid/compatreportharness.test.ts","./src/services/mermaid/detectdiagramtype.test.ts","./src/services/mermaid/detectdiagramtype.ts","./src/services/mermaid/diagnosticformatting.test.ts","./src/services/mermaid/diagnosticformatting.ts","./src/services/mermaid/diagnosticssnapshot.test.ts","./src/services/mermaid/diagnosticssnapshot.ts","./src/services/mermaid/editablepartialcorpus.test.ts","./src/services/mermaid/importcontracts.ts","./src/services/mermaid/importstatepresentation.test.ts","./src/services/mermaid/importstatepresentation.ts","./src/services/mermaid/mermaidlayoutcorpus.test.ts","./src/services/mermaid/officialmermaidvalidation.test.ts","./src/services/mermaid/officialmermaidvalidation.ts","./src/services/mermaid/parsemermaidbytype.test.ts","./src/services/mermaid/parsemermaidbytype.ts","./src/services/mermaid/strictmodediagnosticspresentation.test.ts","./src/services/mermaid/strictmodediagnosticspresentation.ts","./src/services/mermaid/strictmodeguidance.test.ts","./src/services/mermaid/strictmodeguidance.ts","./src/services/mermaid/strictmodeuxregression.test.ts","./src/services/mermaid/supportmatrix.test.ts","./src/services/mermaid/supportmatrix.ts","./src/services/offline/registerappshellserviceworker.test.ts","./src/services/offline/registerappshellserviceworker.ts","./src/services/onboarding/config.ts","./src/services/onboarding/eventschemas.ts","./src/services/onboarding/events.test.ts","./src/services/onboarding/events.ts","./src/services/playback/contracts.test.ts","./src/services/playback/contracts.ts","./src/services/playback/model.test.ts","./src/services/playback/model.ts","./src/services/playback/studio.test.ts","./src/services/playback/studio.ts","./src/services/sequence/layoutconstants.ts","./src/services/sequence/sequencemessage.test.ts","./src/services/sequence/sequencemessage.ts","./src/services/shapelibrary/bootstrap.test.ts","./src/services/shapelibrary/bootstrap.ts","./src/services/shapelibrary/ingestionpipeline.test.ts","./src/services/shapelibrary/ingestionpipeline.ts","./src/services/shapelibrary/manifestvalidation.test.ts","./src/services/shapelibrary/manifestvalidation.ts","./src/services/shapelibrary/providercatalog.test.ts","./src/services/shapelibrary/providercatalog.ts","./src/services/shapelibrary/registry.test.ts","./src/services/shapelibrary/registry.ts","./src/services/shapelibrary/starterpacks.ts","./src/services/shapelibrary/types.ts","./src/services/storage/fallbackstorage.ts","./src/services/storage/flowdocumentmodel.test.ts","./src/services/storage/flowdocumentmodel.ts","./src/services/storage/flowpersiststorage.test.ts","./src/services/storage/flowpersiststorage.ts","./src/services/storage/indexeddbhelpers.ts","./src/services/storage/indexeddbschema.test.ts","./src/services/storage/indexeddbschema.ts","./src/services/storage/indexeddbstatestorage.test.ts","./src/services/storage/indexeddbstatestorage.ts","./src/services/storage/localfirstrepository.ts","./src/services/storage/localfirstruntime.ts","./src/services/storage/persisteddocumentadapters.test.ts","./src/services/storage/persisteddocumentadapters.ts","./src/services/storage/persistencetypes.ts","./src/services/storage/snapshotstorage.test.ts","./src/services/storage/snapshotstorage.ts","./src/services/storage/storageruntime.test.ts","./src/services/storage/storageruntime.ts","./src/services/storage/storageschemas.test.ts","./src/services/storage/storageschemas.ts","./src/services/storage/storagetelemetry.test.ts","./src/services/storage/storagetelemetry.ts","./src/services/storage/storagetelemetrysink.test.ts","./src/services/storage/storagetelemetrysink.ts","./src/services/storage/uilocalstorage.test.ts","./src/services/storage/uilocalstorage.ts","./src/services/templatelibrary/registry.test.ts","./src/services/templatelibrary/registry.ts","./src/services/templatelibrary/startertemplates.assets.test.ts","./src/services/templatelibrary/startertemplates.test.ts","./src/services/templatelibrary/startertemplates.ts","./src/services/templatelibrary/templatefactories.ts","./src/services/templatelibrary/types.ts","./src/store/actionfactory.ts","./src/store/aisettings.test.ts","./src/store/aisettings.ts","./src/store/aisettingspersistence.ts","./src/store/aisettingsschemas.test.ts","./src/store/aisettingsschemas.ts","./src/store/canvashooks.ts","./src/store/createflowstore.test.ts","./src/store/createflowstore.ts","./src/store/createflowstorepersistoptions.ts","./src/store/createflowstorestate.ts","./src/store/defaults.ts","./src/store/designsystemhooks.ts","./src/store/documenthooks.ts","./src/store/documentstatesync.test.ts","./src/store/documentstatesync.ts","./src/store/editorpagehooks.ts","./src/store/historyhooks.ts","./src/store/historystate.ts","./src/store/persistence.test.ts","./src/store/persistence.ts","./src/store/persistenceschemas.ts","./src/store/selectionhooks.ts","./src/store/selectors.test.ts","./src/store/selectors.ts","./src/store/tabhooks.ts","./src/store/types.ts","./src/store/viewhooks.ts","./src/store/workspacedocumentmodel.test.ts","./src/store/workspacedocumentmodel.ts","./src/store/actions/createaiandselectionactions.ts","./src/store/actions/createcanvasactions.ts","./src/store/actions/createdesignsystemactions.ts","./src/store/actions/createhistoryactions.test.ts","./src/store/actions/createhistoryactions.ts","./src/store/actions/createlayeractions.ts","./src/store/actions/createtabactions.ts","./src/store/actions/createviewactions.ts","./src/store/actions/createworkspacedocumentactions.ts","./src/store/actions/synctabnodesedges.ts","./src/store/slices/createcanvaseditorslice.ts","./src/store/slices/createexperienceslice.ts","./src/store/slices/createworkspaceslice.ts"],"version":"5.8.3"} \ No newline at end of file From 49f4519eea1baa141629b487cc3b0c8e3b3f695e Mon Sep 17 00:00:00 2001 From: Varun Date: Sat, 11 Apr 2026 23:24:29 +0530 Subject: [PATCH 9/9] Improve Mermaid import fidelity and release readiness --- package-lock.json | 49 +- package.json | 3 + public/locales/de/translation.json | 8 +- public/locales/en/translation.json | 8 +- public/locales/es/translation.json | 8 +- public/locales/fr/translation.json | 8 +- public/locales/ja/translation.json | 8 +- public/locales/tr/translation.json | 8 +- public/locales/zh/translation.json | 8 +- scripts/check-bundle-budget.mjs | 18 +- scripts/mermaid-compat-fixtures.mjs | 2 +- scripts/mermaid-flowchart-gold-corpus.json | 62 ++ scripts/mermaid-flowchart-gold-corpus.mjs | 9 + src/components/CustomEdge.tsx | 23 +- src/components/CustomNode.tsx | 81 +- .../DesignSystem.integration.test.tsx | 94 ++ src/components/FlowCanvas.tsx | 2 + src/components/FlowEditor.test.tsx | 83 +- src/components/FlowEditor.tsx | 127 ++- src/components/ImportRecoveryDialog.test.tsx | 46 +- src/components/ImportRecoveryDialog.tsx | 39 +- .../MermaidDiagnosticsBanner.test.tsx | 21 + src/components/MermaidDiagnosticsBanner.tsx | 8 +- src/components/MermaidSvgNode.tsx | 51 ++ src/components/SectionNode.tsx | 123 ++- .../SettingsModal/CanvasSettings.tsx | 39 + src/components/StudioCodePanel.tsx | 1 + .../command-bar/applyCodeChanges.test.ts | 97 ++ .../command-bar/applyCodeChanges.ts | 101 ++- src/components/custom-edge/pathUtils.test.ts | 200 +++++ src/components/custom-edge/pathUtils.ts | 123 ++- src/components/custom-edge/pathUtilsTypes.ts | 7 +- .../flow-canvas/flowCanvasTypes.test.ts | 1 + .../flow-canvas/flowCanvasTypes.tsx | 2 + .../flow-canvas/useFlowCanvasPaste.ts | 141 +-- src/components/nodeHelpers.ts | 18 + .../properties/edge/EdgeRouteSection.test.tsx | 20 + .../properties/edge/EdgeRouteSection.tsx | 25 +- .../useStudioCodePanelController.ts | 6 +- src/hooks/flow-export/exportCapture.ts | 7 +- src/hooks/node-operations/nodeFactories.ts | 35 + src/hooks/node-operations/sectionBounds.ts | 34 +- .../node-operations/sectionHitTesting.ts | 2 + .../node-operations/sectionOperations.ts | 10 + src/hooks/node-operations/utils.test.ts | 98 ++ src/hooks/node-operations/utils.ts | 1 + src/hooks/useCinematicExport.ts | 1 + src/hooks/useFlowEditorCallbacks.ts | 33 +- src/i18n/locales/de/translation.json | 8 +- src/i18n/locales/en/translation.json | 8 +- src/i18n/locales/es/translation.json | 8 +- src/i18n/locales/fr/translation.json | 8 +- src/i18n/locales/ja/translation.json | 8 +- src/i18n/locales/tr/translation.json | 8 +- src/i18n/locales/zh/translation.json | 8 +- src/lib/edgeRouteData.ts | 14 + src/lib/mermaidParser.ts | 29 +- src/lib/mermaidParserHelpers.ts | 4 + src/lib/mermaidParserModel.ts | 7 +- src/lib/reconnectEdge.test.ts | 4 + src/lib/reconnectEdge.ts | 11 +- src/lib/releaseStaleElkRoutes.test.ts | 35 +- src/lib/releaseStaleElkRoutes.ts | 16 +- src/lib/types.ts | 29 +- src/services/composeDiagramForDisplay.test.ts | 279 +++++- src/services/composeDiagramForDisplay.ts | 432 ++++++++- src/services/elk-layout/options.test.ts | 8 +- src/services/elk-layout/options.ts | 5 - src/services/elkLayout.ts | 17 +- src/services/export/cinematicExport.ts | 5 + src/services/export/cinematicRenderState.ts | 2 + src/services/importFidelity.test.ts | 81 +- src/services/importFidelity.ts | 151 +++- .../mermaid/diagnosticsSnapshot.test.ts | 94 +- src/services/mermaid/diagnosticsSnapshot.ts | 72 +- .../ensureMermaidMeasurementSupport.test.ts | 37 + .../ensureMermaidMeasurementSupport.ts | 179 ++++ .../mermaid/extractLayoutFromSvg.test.ts | 180 ++++ src/services/mermaid/extractLayoutFromSvg.ts | 841 ++++++++++++++++++ .../flowchartGoldCorpusIntegrity.test.ts | 58 ++ .../flowchartImportFidelityCorpus.test.ts | 361 ++++++++ .../flowchartImportOutcomeCorpus.test.ts | 198 +++++ .../mermaid/importGeometryUtils.test.ts | 12 + src/services/mermaid/importGeometryUtils.ts | 58 ++ src/services/mermaid/importProvenance.test.ts | 96 ++ src/services/mermaid/importProvenance.ts | 144 +++ .../mermaid/importSceneProjection.test.ts | 194 ++++ src/services/mermaid/importSceneProjection.ts | 353 ++++++++ .../mermaid/importStatePresentation.test.ts | 30 +- .../mermaid/importStatePresentation.ts | 37 +- .../mermaidRenderCompatibility.test.ts | 51 ++ .../mermaid/officialFlowchartImport.test.ts | 216 +++++ .../mermaid/officialFlowchartImport.ts | 753 ++++++++++++++++ .../mermaid/officialMermaidValidation.ts | 2 + .../mermaid/parseMermaidByType.test.ts | 14 + .../mermaid/recoveryPresentation.test.ts | 35 + src/services/mermaid/recoveryPresentation.ts | 26 + .../mermaid/rendererFirstImport.test.ts | 81 ++ src/services/mermaid/rendererFirstImport.ts | 300 +++++++ src/services/smartEdgeRouting.test.ts | 65 ++ src/services/smartEdgeRouting.ts | 38 + .../actions/createHistoryActions.test.ts | 1 + src/store/defaults.ts | 1 + src/store/persistenceSchemas.ts | 1 + src/store/selectors.ts | 1 + src/store/types.ts | 7 + tsconfig.tsbuildinfo | 2 +- 107 files changed, 7373 insertions(+), 249 deletions(-) create mode 100644 scripts/mermaid-flowchart-gold-corpus.json create mode 100644 scripts/mermaid-flowchart-gold-corpus.mjs create mode 100644 src/components/MermaidSvgNode.tsx create mode 100644 src/lib/edgeRouteData.ts create mode 100644 src/services/mermaid/ensureMermaidMeasurementSupport.test.ts create mode 100644 src/services/mermaid/ensureMermaidMeasurementSupport.ts create mode 100644 src/services/mermaid/extractLayoutFromSvg.test.ts create mode 100644 src/services/mermaid/extractLayoutFromSvg.ts create mode 100644 src/services/mermaid/flowchartGoldCorpusIntegrity.test.ts create mode 100644 src/services/mermaid/flowchartImportFidelityCorpus.test.ts create mode 100644 src/services/mermaid/flowchartImportOutcomeCorpus.test.ts create mode 100644 src/services/mermaid/importGeometryUtils.test.ts create mode 100644 src/services/mermaid/importGeometryUtils.ts create mode 100644 src/services/mermaid/importProvenance.test.ts create mode 100644 src/services/mermaid/importProvenance.ts create mode 100644 src/services/mermaid/importSceneProjection.test.ts create mode 100644 src/services/mermaid/importSceneProjection.ts create mode 100644 src/services/mermaid/mermaidRenderCompatibility.test.ts create mode 100644 src/services/mermaid/officialFlowchartImport.test.ts create mode 100644 src/services/mermaid/officialFlowchartImport.ts create mode 100644 src/services/mermaid/recoveryPresentation.test.ts create mode 100644 src/services/mermaid/recoveryPresentation.ts create mode 100644 src/services/mermaid/rendererFirstImport.test.ts create mode 100644 src/services/mermaid/rendererFirstImport.ts diff --git a/package-lock.json b/package-lock.json index 2a4dc3a3..cc33099b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,9 @@ ], "dependencies": { "@google/genai": "^1.40.0", + "@mermaid-js/layout-elk": "^0.2.1", "@xyflow/react": "^12.10.1", + "dagre": "^0.8.5", "elkjs": "^0.11.0", "framer-motion": "^12.34.0", "html-to-image": "^1.11.13", @@ -43,6 +45,7 @@ "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/dagre": "^0.7.54", "@types/node": "^22.14.0", "@types/react-syntax-highlighter": "^15.5.13", "@typescript-eslint/eslint-plugin": "^8.55.0", @@ -2241,6 +2244,25 @@ "node": ">= 12" } }, + "node_modules/@mermaid-js/layout-elk": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/layout-elk/-/layout-elk-0.2.1.tgz", + "integrity": "sha512-MX9jwhMyd5zDcFsYcl3duDUkKhjVRUCGEQrdCeNV5hCIR6+3FuDDbRbFmvVbAu15K1+juzsYGG+K8MDvCY1Amg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "elkjs": "^0.9.3" + }, + "peerDependencies": { + "mermaid": "^11.0.2" + } + }, + "node_modules/@mermaid-js/layout-elk/node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "license": "EPL-2.0" + }, "node_modules/@mermaid-js/parser": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz", @@ -4750,6 +4772,13 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/dagre": { + "version": "0.7.54", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz", + "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -8045,6 +8074,16 @@ "node": ">=12" } }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, "node_modules/dagre-d3-es": { "version": "7.0.14", "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", @@ -10186,6 +10225,15 @@ "dev": true, "license": "MIT" }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, "node_modules/gtoken": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", @@ -12500,7 +12548,6 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "dev": true, "license": "MIT" }, "node_modules/lodash-es": { diff --git a/package.json b/package.json index 6f67e7e8..3485cae4 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,9 @@ }, "dependencies": { "@google/genai": "^1.40.0", + "@mermaid-js/layout-elk": "^0.2.1", "@xyflow/react": "^12.10.1", + "dagre": "^0.8.5", "elkjs": "^0.11.0", "framer-motion": "^12.34.0", "html-to-image": "^1.11.13", @@ -79,6 +81,7 @@ "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/dagre": "^0.7.54", "@types/node": "^22.14.0", "@types/react-syntax-highlighter": "^15.5.13", "@typescript-eslint/eslint-plugin": "^8.55.0", diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index d854fedb..ef465ba6 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -856,7 +856,11 @@ "architectureStrictMode": "Architektur-Strict-Modus", "architectureStrictModeDesc": "Mermaid-Import blockieren, wenn Architekturdiagnosen Wiederherstellungs-/Validierungsprobleme enthalten", "miniMap": "Minimap", - "miniMapDesc": "Minimap unten rechts anzeigen" + "miniMapDesc": "Minimap unten rechts anzeigen", + "mermaidImportMode": "Mermaid Import Mode", + "mermaidImportModeRenderer": "Fidelity-first", + "mermaidImportModeEditable": "Editable-first", + "mermaidImportModeDesc": "Controls how Mermaid diagrams are imported to the canvas." }, "ai": { "provider": "Anbieter", @@ -1441,4 +1445,4 @@ "aiModel": { "buttons": {} } -} +} \ No newline at end of file diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index b82a69d9..a1f0e716 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -949,7 +949,11 @@ "routingProfileInfrastructure": "Infrastructure", "routingProfileHint": "Infrastructure mode biases orthogonal routes for service maps.", "edgeBundling": "Bundle Sibling Edges", - "edgeBundlingDesc": "Keep parallel connections on shared lanes" + "edgeBundlingDesc": "Keep parallel connections on shared lanes", + "mermaidImportMode": "Mermaid Import Mode", + "mermaidImportModeRenderer": "Fidelity-first", + "mermaidImportModeEditable": "Editable-first", + "mermaidImportModeDesc": "Controls how Mermaid diagrams are imported to the canvas." }, "analytics": { "enableTitle": "Anonymous Launch Analytics", @@ -1436,4 +1440,4 @@ "sidebar": { "close": "Close sidebar" } -} +} \ No newline at end of file diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json index 16024368..5ccd61a6 100644 --- a/public/locales/es/translation.json +++ b/public/locales/es/translation.json @@ -856,7 +856,11 @@ "architectureStrictMode": "Modo Estricto de Arquitectura", "architectureStrictModeDesc": "Bloquear importación de Mermaid cuando los diagnósticos de arquitectura incluyan problemas de recuperación/validación", "miniMap": "Minimapa", - "miniMapDesc": "Mostrar minimapa en la esquina inferior derecha" + "miniMapDesc": "Mostrar minimapa en la esquina inferior derecha", + "mermaidImportMode": "Mermaid Import Mode", + "mermaidImportModeRenderer": "Fidelity-first", + "mermaidImportModeEditable": "Editable-first", + "mermaidImportModeDesc": "Controls how Mermaid diagrams are imported to the canvas." }, "ai": { "provider": "Proveedor", @@ -1441,4 +1445,4 @@ "aiModel": { "buttons": {} } -} +} \ No newline at end of file diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index 3f6bdcf4..824bb17c 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -856,7 +856,11 @@ "architectureStrictMode": "Mode Strict Architecture", "architectureStrictModeDesc": "Bloquer l'import Mermaid lorsque les diagnostics d'architecture incluent des problèmes de récupération/validation", "miniMap": "Mini carte", - "miniMapDesc": "Afficher la mini carte en bas à droite" + "miniMapDesc": "Afficher la mini carte en bas à droite", + "mermaidImportMode": "Mermaid Import Mode", + "mermaidImportModeRenderer": "Fidelity-first", + "mermaidImportModeEditable": "Editable-first", + "mermaidImportModeDesc": "Controls how Mermaid diagrams are imported to the canvas." }, "ai": { "provider": "Fournisseur", @@ -1441,4 +1445,4 @@ "aiModel": { "buttons": {} } -} +} \ No newline at end of file diff --git a/public/locales/ja/translation.json b/public/locales/ja/translation.json index f3b5597e..16690c1f 100644 --- a/public/locales/ja/translation.json +++ b/public/locales/ja/translation.json @@ -856,7 +856,11 @@ "architectureStrictMode": "アーキテクチャStrictモード", "architectureStrictModeDesc": "アーキテクチャ診断が回復/検証の問題を含む場合、Mermaidインポートをブロック", "miniMap": "ミニマップ", - "miniMapDesc": "右下にミニマップを表示" + "miniMapDesc": "右下にミニマップを表示", + "mermaidImportMode": "Mermaid Import Mode", + "mermaidImportModeRenderer": "Fidelity-first", + "mermaidImportModeEditable": "Editable-first", + "mermaidImportModeDesc": "Controls how Mermaid diagrams are imported to the canvas." }, "ai": { "provider": "プロバイダー", @@ -1441,4 +1445,4 @@ "aiModel": { "buttons": {} } -} +} \ No newline at end of file diff --git a/public/locales/tr/translation.json b/public/locales/tr/translation.json index 26ef6916..34c37abb 100644 --- a/public/locales/tr/translation.json +++ b/public/locales/tr/translation.json @@ -876,7 +876,11 @@ "architectureStrictMode": "Mimari Katı Modu", "architectureStrictModeDesc": "Mimari tanılamalar kurtarma/doğrulama sorunları içerdiğinde Mermaid içe aktarmayı engelle", "miniMap": "Mini Harita", - "miniMapDesc": "Sağ altta mini haritayı göster" + "miniMapDesc": "Sağ altta mini haritayı göster", + "mermaidImportMode": "Mermaid Import Mode", + "mermaidImportModeRenderer": "Fidelity-first", + "mermaidImportModeEditable": "Editable-first", + "mermaidImportModeDesc": "Controls how Mermaid diagrams are imported to the canvas." }, "ai": { "provider": "Sağlayıcı", @@ -1441,4 +1445,4 @@ "aiModel": { "buttons": {} } -} +} \ No newline at end of file diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index bdfb4b64..2a304210 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -856,7 +856,11 @@ "architectureStrictMode": "架构严格模式", "architectureStrictModeDesc": "当架构诊断包含恢复/验证问题时阻止Mermaid导入", "miniMap": "小地图", - "miniMapDesc": "在右下角显示小地图" + "miniMapDesc": "在右下角显示小地图", + "mermaidImportMode": "Mermaid Import Mode", + "mermaidImportModeRenderer": "Fidelity-first", + "mermaidImportModeEditable": "Editable-first", + "mermaidImportModeDesc": "Controls how Mermaid diagrams are imported to the canvas." }, "ai": { "provider": "提供商", @@ -1441,4 +1445,4 @@ "aiModel": { "buttons": {} } -} +} \ No newline at end of file diff --git a/scripts/check-bundle-budget.mjs b/scripts/check-bundle-budget.mjs index 1bd8a6c5..8959f4dd 100644 --- a/scripts/check-bundle-budget.mjs +++ b/scripts/check-bundle-budget.mjs @@ -37,6 +37,20 @@ function readAllChunkSizes() { return sizes; } +function isStaticAssetWrapperChunk(relativePath) { + const filePath = path.join(DIST_DIR, relativePath); + const source = fs.readFileSync(filePath, 'utf8').trim(); + + // Vite emits tiny JS modules for SVG/data-URL assets when we use import.meta.glob with + // `?url` or when small SVGs are inlined. These modules are static string exports, not + // executable application code, so including them in the lazy-JS total creates false + // bundle-budget failures for large icon catalogs. + return /^const\s+\w+=["'`][\s\S]*["'`];export\{\w+ as default\};?$/.test(source) + && !source.includes('import') + && !source.includes('function') + && !source.includes('=>'); +} + function readEntryAssetSizes() { if (!fs.existsSync(INDEX_HTML_PATH)) { throw new Error('Missing dist/index.html. Run "npm run build" before "npm run bundle:check".'); @@ -108,7 +122,9 @@ function main() { // Lazy chunk checks const allChunkSizes = readAllChunkSizes(); const entryJsSet = new Set(jsEntries.map(([file]) => file)); - const lazyChunks = Array.from(allChunkSizes.entries()).filter(([file]) => !entryJsSet.has(file)); + const lazyChunks = Array.from(allChunkSizes.entries()).filter( + ([file]) => !entryJsSet.has(file) && !isStaticAssetWrapperChunk(file) + ); const totalLazyBytes = lazyChunks.reduce((sum, [, size]) => sum + size, 0); const top5 = [...lazyChunks].sort(([, a], [, b]) => b - a).slice(0, 5); diff --git a/scripts/mermaid-compat-fixtures.mjs b/scripts/mermaid-compat-fixtures.mjs index d26241e9..814f022a 100644 --- a/scripts/mermaid-compat-fixtures.mjs +++ b/scripts/mermaid-compat-fixtures.mjs @@ -40,7 +40,7 @@ const FIXTURE_METADATA = { bucket: 'editable_full', expectedImportState: 'editable_full', structuralAssertions: { minNodes: 3, minEdges: 2 }, - layoutAssertions: { maxBoundingWidth: 360, maxBoundingHeight: 280, requireUniquePositions: true }, + layoutAssertions: { maxBoundingWidth: 420, maxBoundingHeight: 320, requireUniquePositions: true }, }, 'state-invalid-direction': { bucket: 'editable_partial', diff --git a/scripts/mermaid-flowchart-gold-corpus.json b/scripts/mermaid-flowchart-gold-corpus.json new file mode 100644 index 00000000..dd548c7d --- /dev/null +++ b/scripts/mermaid-flowchart-gold-corpus.json @@ -0,0 +1,62 @@ +[ + { + "id": "gold-flowchart-auth-decision", + "fixtureName": "flowchart-auth-decision", + "priority": "p0", + "failureModes": ["edge_route_drift", "rank_order_drift"], + "userImpact": "Decision branches become hard to scan when route ownership or branching direction drifts from Mermaid.", + "successCriteria": [ + "Decision node remains visually above both outcomes.", + "Yes and No branches stay clearly separated left-to-right.", + "Imported result stays in a renderer-backed Mermaid outcome when possible." + ] + }, + { + "id": "gold-flowchart-nested-subgraphs", + "fixtureName": "flowchart-nested-subgraphs", + "priority": "p0", + "failureModes": ["container_geometry_drift", "parent_child_containment_drift"], + "userImpact": "Nested subgraphs look unreliable when parent boxes, child offsets, or containment structure drift.", + "successCriteria": [ + "Outer and inner containers keep stable nesting relationships.", + "Child nodes stay inside their intended subgraph.", + "Container geometry remains visibly intentional rather than auto-fit noise." + ] + }, + { + "id": "gold-flowchart-subgraph-explicit-id", + "fixtureName": "flowchart-subgraph-explicit-id", + "priority": "p1", + "failureModes": ["container_geometry_drift", "identifier_reconciliation_drift"], + "userImpact": "Explicit Mermaid section ids must map cleanly into editable sections or users lose trust in imported structure.", + "successCriteria": [ + "Explicit subgraph id stays stable through import.", + "Section title and bounds remain readable.", + "Nodes remain attached to the correct imported section." + ] + }, + { + "id": "gold-flowchart-modern-dotted-ids", + "fixtureName": "flowchart-modern-annotation-dotted-ids", + "priority": "p1", + "failureModes": ["identifier_reconciliation_drift", "node_sizing_readability_drift"], + "userImpact": "Modern Mermaid ids with dots are common in infrastructure diagrams and break trust fast when labels or identity drift.", + "successCriteria": [ + "Dotted ids survive reconciliation intact.", + "Imported node labels remain readable without overlap.", + "Result does not silently degrade identity semantics." + ] + }, + { + "id": "gold-flowchart-basic", + "fixtureName": "flowchart-basic", + "priority": "p2", + "failureModes": ["node_sizing_readability_drift", "rank_order_drift"], + "userImpact": "Even the simplest flowchart must remain visually obvious or all more complex imports feel risky.", + "successCriteria": [ + "Top-to-bottom order remains obvious at a glance.", + "Node spacing stays compact and readable.", + "Baseline case remains exact Mermaid layout." + ] + } +] diff --git a/scripts/mermaid-flowchart-gold-corpus.mjs b/scripts/mermaid-flowchart-gold-corpus.mjs new file mode 100644 index 00000000..6da8c3f8 --- /dev/null +++ b/scripts/mermaid-flowchart-gold-corpus.mjs @@ -0,0 +1,9 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const corpusPath = path.join(__dirname, 'mermaid-flowchart-gold-corpus.json'); + +export const MERMAID_FLOWCHART_GOLD_CORPUS = JSON.parse(fs.readFileSync(corpusPath, 'utf8')); diff --git a/src/components/CustomEdge.tsx b/src/components/CustomEdge.tsx index b2744ec7..0e4a3779 100644 --- a/src/components/CustomEdge.tsx +++ b/src/components/CustomEdge.tsx @@ -4,10 +4,13 @@ import { ROLLOUT_FLAGS } from '@/config/rolloutFlags'; import type { LegacyEdgeProps } from '@/lib/reactflowCompat'; import type { EdgeData } from '@/lib/types'; import type { FlowEdge } from '@/lib/types'; +import type { NodeData } from '@/lib/types'; import { useCinematicExportState } from '@/context/CinematicExportContext'; import { CustomEdgeWrapper } from './custom-edge/CustomEdgeWrapper'; import { buildEdgePath } from './custom-edge/pathUtils'; import { shouldUseOrthogonalRelationRouting } from './custom-edge/relationRoutingSemantics'; +import { readMermaidImportedEdgeMetadata } from '@/services/mermaid/importProvenance'; +import { readMermaidImportedNodeMetadataFromData } from '@/services/mermaid/importProvenance'; function createEdgeRenderer(variant: 'bezier' | 'smoothstep' | 'step' | 'straight') { return function RenderEdge(props: LegacyEdgeProps): React.ReactElement { @@ -16,9 +19,14 @@ function createEdgeRenderer(variant: 'bezier' | 'smoothstep' | 'step' | 'straigh const allEdges = getEdges() as FlowEdge[]; const allNodes = getNodes(); const currentEdge = allEdges.find((edge) => edge.id === props.id) as FlowEdge | undefined; + const importedEdgeMetadata = currentEdge ? readMermaidImportedEdgeMetadata(currentEdge) : null; const relationSemanticsV1Enabled = ROLLOUT_FLAGS.relationSemanticsV1; const sourceNode = allNodes.find((node) => node.id === props.source); const targetNode = allNodes.find((node) => node.id === props.target); + const sourceIsImportedMermaidContainer = + readMermaidImportedNodeMetadataFromData(sourceNode?.data as NodeData | undefined)?.role === 'container'; + const targetIsImportedMermaidContainer = + readMermaidImportedNodeMetadataFromData(targetNode?.data as NodeData | undefined)?.role === 'container'; const forceOrthogonal = shouldUseOrthogonalRelationRouting( relationSemanticsV1Enabled, props.data, @@ -44,9 +52,22 @@ function createEdgeRenderer(variant: 'bezier' | 'smoothstep' | 'step' | 'straigh variant, { forceOrthogonal, + mermaidPreservedEndpoints: + importedEdgeMetadata?.hasFixedRoute === false, + mermaidSourceContainer: sourceIsImportedMermaidContainer, + mermaidTargetContainer: targetIsImportedMermaidContainer, elkPoints: props.data?.elkPoints as { x: number; y: number }[] | undefined, + importRoutePoints: props.data?.importRoutePoints as + | { x: number; y: number }[] + | undefined, + importRoutePath: props.data?.importRoutePath as string | undefined, mindmapBranchKind: props.data?.mindmapBranchKind as 'root' | 'branch' | undefined, - routingMode: props.data?.routingMode as 'auto' | 'elk' | 'manual' | undefined, + routingMode: props.data?.routingMode as + | 'auto' + | 'elk' + | 'manual' + | 'import-fixed' + | undefined, waypoints: props.data?.waypoints as { x: number; y: number }[] | undefined, waypoint: props.data?.waypoint as { x: number; y: number } | undefined, } diff --git a/src/components/CustomNode.tsx b/src/components/CustomNode.tsx index 36451fca..53e9aa51 100644 --- a/src/components/CustomNode.tsx +++ b/src/components/CustomNode.tsx @@ -17,6 +17,7 @@ import { NodeShapeSVG } from './NodeShapeSVG'; import { DiffBadge, LintViolationBadge } from './NodeBadges'; import { IconAssetNodeBody } from './IconAssetNodeBody'; import { CustomNodeContent } from './CustomNodeContent'; +import { readMermaidImportedNodeMetadataFromData } from '@/services/mermaid/importProvenance'; import { type NodeShape, COMPLEX_SHAPES, @@ -24,17 +25,52 @@ import { NEEDS_SQUARE_ASPECT, COMPLEX_SHAPE_PADDING, getNodeDefaults, + getNumericNodeDimension, getMinNodeSize, toCssSize, getNodeBorderRadius, fontSizeClassFor, } from './nodeHelpers'; +function getMermaidImportedFontSize(nodeHeightPx: number | undefined): number { + if (typeof nodeHeightPx !== 'number') { + return 15; + } + + if (nodeHeightPx <= 56) { + return 14; + } + + if (nodeHeightPx >= 96) { + return 16; + } + + return 15; +} + +function getMermaidImportedContentPadding(nodeHeightPx: number | undefined): string { + if (typeof nodeHeightPx !== 'number') { + return '0.6rem 0.75rem'; + } + + if (nodeHeightPx <= 40) { + return '0.4rem 0.6rem'; + } + + if (nodeHeightPx <= 60) { + return '0.5rem 0.7rem'; + } + + return '0.65rem 0.9rem'; +} + function CustomNode(props: LegacyNodeProps): React.ReactElement { const { id, data, type, selected } = props; const explicitNodeStyle = (props as { style?: React.CSSProperties }).style; const explicitWidth = data.width ?? explicitNodeStyle?.width; const explicitHeight = data.height ?? explicitNodeStyle?.height; + const explicitWidthPx = getNumericNodeDimension(explicitWidth); + const explicitHeightPx = getNumericNodeDimension(explicitHeight); const measuredHeight = (props as { height?: number }).height; const shiftHeld = useShiftHeld(Boolean(selected)); const resolvedAssetIconUrl = useProviderShapePreview( @@ -73,8 +109,10 @@ function CustomNode(props: LegacyNodeProps): React.ReactElement { const hasIcon = Boolean(iconName) || Boolean(data.customIconUrl); const hasLabel = Boolean(data.label?.trim()); const hasSubLabel = Boolean(data.subLabel); + const mermaidImportedNodeMetadata = readMermaidImportedNodeMetadataFromData(data); + const isMermaidImportedLeaf = mermaidImportedNodeMetadata?.role === 'leaf'; const isComplexShape = COMPLEX_SHAPES.includes(activeShape); - const { minWidth, minHeight } = getMinNodeSize(activeShape); + const { minWidth: baseMinWidth, minHeight: baseMinHeight } = getMinNodeSize(activeShape); const contentMinHeight = !isComplexShape ? hasIcon && hasSubLabel ? 128 @@ -83,11 +121,18 @@ function CustomNode(props: LegacyNodeProps): React.ReactElement { : hasSubLabel ? 96 : 84 - : minHeight; - const effectiveMinHeight = Math.max(minHeight, contentMinHeight); - const nodeHeightPx = typeof measuredHeight === 'number' ? measuredHeight : undefined; + : baseMinHeight; + const minWidth = isMermaidImportedLeaf ? explicitWidthPx ?? baseMinWidth : baseMinWidth; + const effectiveMinHeight = isMermaidImportedLeaf + ? explicitHeightPx ?? baseMinHeight + : Math.max(baseMinHeight, contentMinHeight); + const nodeHeightPx = typeof measuredHeight === 'number' ? measuredHeight : explicitHeightPx; const isCompactNode = typeof nodeHeightPx === 'number' && nodeHeightPx < effectiveMinHeight + 8; - const contentPadding = isCompactNode ? '0.5rem' : designSystem.components.node.padding; + const contentPadding = isMermaidImportedLeaf + ? getMermaidImportedContentPadding(nodeHeightPx) + : isCompactNode + ? '0.5rem' + : designSystem.components.node.padding; const labelEdit = useInlineNodeTextEdit(id, 'label', data.label || '', { multiline: true }); const subLabelEdit = useInlineNodeTextEdit(id, 'subLabel', data.subLabel || ''); const connectionHandleClass = @@ -99,11 +144,13 @@ function CustomNode(props: LegacyNodeProps): React.ReactElement { data.assetPresentation === 'icon' && (Boolean(resolvedAssetIconUrl) || Boolean(activeIconKey) || Boolean(data.archIconPackId)); - const labelDisplayValue = hasLabel ? ( - - ) : showEmptyLabelPrompt ? ( - {emptyLabelPrompt} - ) : null; + const labelDisplayValue = hasLabel + ? isMermaidImportedLeaf + ? {data.label} + : + : showEmptyLabelPrompt + ? {emptyLabelPrompt} + : null; const needsSquareAspect = NEEDS_SQUARE_ASPECT.has(activeShape); const selectionRing = @@ -151,13 +198,23 @@ function CustomNode(props: LegacyNodeProps): React.ReactElement { ); } + const importedFontFamilyStyle = + isMermaidImportedLeaf && !data.fontFamily + ? { fontFamily: designSystem.typography.fontFamily } + : {}; + const importedFontSizeStyle = + !data.fontSize && isMermaidImportedLeaf + ? { fontSize: `${getMermaidImportedFontSize(nodeHeightPx)}px` } + : {}; const textProps = { ...fontSizeStyle, + ...importedFontSizeStyle, ...labelFontFamilyStyle, + ...importedFontFamilyStyle, color: visualStyle.text, - fontWeight: data.fontWeight || '600', + fontWeight: data.fontWeight || (isMermaidImportedLeaf ? '500' : '600'), fontStyle: data.fontStyle || 'normal', - lineHeight: 1.2, + lineHeight: isMermaidImportedLeaf ? 1.1 : 1.2, }; const subTextProps = { ...subLabelFontSizeStyle, diff --git a/src/components/DesignSystem.integration.test.tsx b/src/components/DesignSystem.integration.test.tsx index 8e7bcbaf..3d218a7f 100644 --- a/src/components/DesignSystem.integration.test.tsx +++ b/src/components/DesignSystem.integration.test.tsx @@ -4,8 +4,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Position } from '@/lib/reactflowCompat'; import type { DesignSystem } from '@/lib/types'; import { DEFAULT_DESIGN_SYSTEM, useFlowStore } from '@/store'; +import { attachMermaidImportedNodeMetadata } from '@/services/mermaid/importProvenance'; import CustomNode from './CustomNode'; import { CustomSmoothStepEdge } from './CustomEdge'; +import SectionNode from './SectionNode'; import { DEFAULT_EDGE_OPTIONS } from '@/constants'; vi.mock('react-i18next', () => ({ @@ -55,6 +57,7 @@ vi.mock('@/lib/reactflowCompat', async (importOriginal) => { setEdges: vi.fn(), screenToFlowPosition: ({ x, y }: { x: number; y: number }) => ({ x, y }), }), + useNodes: () => [], useViewport: () => ({ zoom: 1 }), }; }); @@ -243,4 +246,95 @@ describe('Design System integration', () => { expect(diagnosticsNode?.style.width).toBe('100%'); expect(diagnosticsNode?.style.height).toBe(''); }); + + it('honors imported Mermaid node geometry instead of generic canvas minimums', () => { + const importedNodeData = attachMermaidImportedNodeMetadata( + { + id: 'decision-1', + type: 'decision', + position: { x: 0, y: 0 }, + data: { label: 'Approved?' }, + } as const, + { + role: 'leaf', + source: 'official-flowchart', + fidelity: 'renderer-backed', + } + ).data; + + const { container } = render( + + ); + + const diagnosticsNode = container.querySelector('[data-transform-diagnostics="1"]') as HTMLElement | null; + expect(diagnosticsNode).not.toBeNull(); + expect(diagnosticsNode?.style.minWidth).toBe('150px'); + expect(diagnosticsNode?.style.minHeight).toBe('70px'); + expect(diagnosticsNode?.style.width).toBe('150px'); + expect(diagnosticsNode?.style.height).toBe('70px'); + expect(diagnosticsNode?.getAttribute('data-transform-compact')).toBe('1'); + const importedLabelStyle = screen.getByText('Approved?').parentElement?.getAttribute('style') ?? ''; + expect(importedLabelStyle).toContain('font-family:'); + // Imported nodes now use the design system font for visual consistency + expect(importedLabelStyle).not.toContain('Trebuchet MS'); + expect(importedLabelStyle).toContain('line-height: 1.1;'); + }); + + it('honors imported Mermaid section geometry instead of generic section minimums', () => { + const importedSectionData = attachMermaidImportedNodeMetadata( + { + id: 'payments', + type: 'section', + position: { x: 0, y: 0 }, + data: { label: 'Payments' }, + } as const, + { + role: 'container', + source: 'official-flowchart', + fidelity: 'renderer-backed', + } + ).data; + + const { container } = render( + + ); + + const sectionFrame = Array.from(container.querySelectorAll('div')).find( + (element) => element instanceof HTMLElement + && element.style.minWidth === '260px' + && element.style.minHeight === '180px' + ) as HTMLElement | undefined; + + expect(sectionFrame).toBeTruthy(); + expect(sectionFrame?.getAttribute('data-section-render-variant')).toBe('mermaid-import'); + expect(screen.getByText('Imported')).toBeTruthy(); + expect(screen.queryByText(/items?$/)).toBeNull(); + expect(screen.getByText('Payments').parentElement?.getAttribute('style')).toContain('top: 8px;'); + }); }); diff --git a/src/components/FlowCanvas.tsx b/src/components/FlowCanvas.tsx index 535828cf..a46fae43 100644 --- a/src/components/FlowCanvas.tsx +++ b/src/components/FlowCanvas.tsx @@ -58,6 +58,7 @@ export const FlowCanvas: React.FC = ({ largeGraphSafetyMode, largeGraphSafetyProfile, architectureStrictMode, + mermaidImportMode, } = useCanvasViewSettings(); const { layers } = useFlowStore( useShallow((state) => ({ @@ -247,6 +248,7 @@ export const FlowCanvas: React.FC = ({ const { handleCanvasPaste } = useFlowCanvasPaste({ architectureStrictMode, + mermaidImportMode, activeTabId, fitView, updateTab, diff --git a/src/components/FlowEditor.test.tsx b/src/components/FlowEditor.test.tsx index 819d683f..f5587253 100644 --- a/src/components/FlowEditor.test.tsx +++ b/src/components/FlowEditor.test.tsx @@ -8,6 +8,11 @@ const openStudioCode = vi.fn(); const importRecoveryDialogMock = vi.fn(); const useMermaidDiagnosticsMock = vi.fn(); const useFlowEditorScreenModelMock = vi.fn(); +const setNodesMock = vi.fn(); +const setEdgesMock = vi.fn(); +const updateTabMock = vi.fn(); +const setMermaidDiagnosticsMock = vi.fn(); +const clearMermaidDiagnosticsMock = vi.fn(); vi.mock('./FlowCanvas', () => ({ FlowCanvas: () =>
FlowCanvas
, @@ -46,6 +51,23 @@ vi.mock('@/components/ImportRecoveryDialog', () => ({ vi.mock('@/store/selectionHooks', () => ({ useMermaidDiagnostics: () => useMermaidDiagnosticsMock(), + useMermaidDiagnosticsActions: () => ({ + setMermaidDiagnostics: setMermaidDiagnosticsMock, + clearMermaidDiagnostics: clearMermaidDiagnosticsMock, + }), +})); + +vi.mock('@/store/canvasHooks', () => ({ + useCanvasActions: () => ({ + setNodes: setNodesMock, + setEdges: setEdgesMock, + }), +})); + +vi.mock('@/store/tabHooks', () => ({ + useTabActions: () => ({ + updateTab: updateTabMock, + }), })); vi.mock('./flow-editor/useFlowEditorScreenModel', () => ({ @@ -65,8 +87,26 @@ function createMermaidDiagnostics() { }; } +function createMermaidLayoutWarningDiagnostics() { + return { + source: 'code', + diagramType: 'flowchart', + importState: 'editable_full', + layoutMode: 'mermaid_preserved_partial', + layoutFallbackReason: 'matched 1/2 official flowchart edge routes', + statusLabel: 'Ready to apply', + statusDetail: '2 nodes, 1 edges · Partial Mermaid layout preserved', + originalSource: 'flowchart LR\nA-->B', + diagnostics: [], + updatedAt: 1, + }; +} + function createFlowEditorScreenModel( - importRecoveryState: { fileName: string; report: Pick } | null = null + importRecoveryState: { + fileName: string; + report: Pick; + } | null = null ) { return { nodes: [], @@ -110,6 +150,11 @@ describe('FlowEditor', () => { beforeEach(() => { openStudioCode.mockReset(); importRecoveryDialogMock.mockReset(); + setNodesMock.mockReset(); + setEdgesMock.mockReset(); + updateTabMock.mockReset(); + setMermaidDiagnosticsMock.mockReset(); + clearMermaidDiagnosticsMock.mockReset(); useMermaidDiagnosticsMock.mockReturnValue(createMermaidDiagnostics()); useFlowEditorScreenModelMock.mockReturnValue(createFlowEditorScreenModel()); }); @@ -146,4 +191,40 @@ describe('FlowEditor', () => { props.onAction?.(); expect(openStudioCode).toHaveBeenCalledWith('mermaid'); }); + + it('passes Mermaid recovery action into the import recovery dialog when parsing is editable_full but report layout degraded', () => { + useMermaidDiagnosticsMock.mockReturnValue(null); + useFlowEditorScreenModelMock.mockReturnValue( + createFlowEditorScreenModel({ + fileName: 'layout-warning.mmd', + report: { + source: 'mermaid', + importState: 'editable_full', + layoutMode: 'mermaid_preserved_partial', + originalSource: 'flowchart LR\nA-->B', + }, + }) + ); + + render(); + + expect(importRecoveryDialogMock).toHaveBeenCalled(); + const props = importRecoveryDialogMock.mock.calls[0][0] as { + actionLabel?: string; + onAction?: () => void; + }; + expect(props.actionLabel).toBe('Open Mermaid code'); + + props.onAction?.(); + expect(openStudioCode).toHaveBeenCalledWith('mermaid'); + }); + + it('still offers Mermaid code recovery when parsing is editable_full but layout fidelity degraded', () => { + useMermaidDiagnosticsMock.mockReturnValue(createMermaidLayoutWarningDiagnostics()); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Open Mermaid code' })); + expect(openStudioCode).toHaveBeenCalledWith('mermaid'); + }); }); diff --git a/src/components/FlowEditor.tsx b/src/components/FlowEditor.tsx index 4a9860b5..5335b13a 100644 --- a/src/components/FlowEditor.tsx +++ b/src/components/FlowEditor.tsx @@ -10,8 +10,17 @@ import { DiagramDiffProvider } from '@/context/DiagramDiffContext'; import { ShareEmbedModal } from '@/components/ShareEmbedModal'; import { ImportRecoveryDialog } from '@/components/ImportRecoveryDialog'; import { MermaidDiagnosticsBanner } from '@/components/MermaidDiagnosticsBanner'; +import { canRecoverMermaidSource as canRecoverMermaidSourceFromState } from '@/services/mermaid/recoveryPresentation'; import { resolveCinematicExportTheme } from '@/services/export/cinematicExportTheme'; import { useMermaidDiagnostics } from '@/store/selectionHooks'; +import { useCanvasActions } from '@/store/canvasHooks'; +import { useTabActions } from '@/store/tabHooks'; +import { parseMermaidByType } from '@/services/mermaid/parseMermaidByType'; +import { importMermaidToCanvas } from '@/services/mermaid/rendererFirstImport'; +import { resolveLayoutDirection } from '@/components/flow-canvas/pasteHelpers'; +import { buildMermaidDiagnosticsSnapshot } from '@/services/mermaid/diagnosticsSnapshot'; +import { normalizeParseDiagnostics } from '@/services/mermaid/diagnosticFormatting'; +import { useMermaidDiagnosticsActions } from '@/store/selectionHooks'; interface FlowEditorProps { onGoHome: () => void; @@ -20,6 +29,9 @@ interface FlowEditorProps { export function FlowEditor({ onGoHome }: FlowEditorProps) { const cinematicExportState = useCinematicExportState(); const mermaidDiagnostics = useMermaidDiagnostics(); + const { setNodes, setEdges } = useCanvasActions(); + const { updateTab } = useTabActions(); + const { setMermaidDiagnostics, clearMermaidDiagnostics } = useMermaidDiagnosticsActions(); const { nodes, edges, @@ -49,14 +61,89 @@ export function FlowEditor({ onGoHome }: FlowEditorProps) { const mermaidRecoverySource = importRecoveryState?.report.source === 'mermaid' ? (importRecoveryState.report.originalSource ?? mermaidDiagnostics?.originalSource) : mermaidDiagnostics?.originalSource; - const canRecoverMermaidSource = Boolean( - mermaidRecoverySource - && ( - importRecoveryState?.report.source === 'mermaid' - ? importRecoveryState.report.importState !== 'editable_full' - : mermaidDiagnostics?.importState !== 'editable_full' - ) - ); + const canRecoverMermaidSource = importRecoveryState?.report.source === 'mermaid' + ? canRecoverMermaidSourceFromState({ + originalSource: mermaidRecoverySource, + importState: importRecoveryState.report.importState, + layoutMode: importRecoveryState.report.layoutMode, + }) + : canRecoverMermaidSourceFromState({ + originalSource: mermaidRecoverySource, + importState: mermaidDiagnostics?.importState, + layoutMode: mermaidDiagnostics?.layoutMode, + }); + + const handleConvertMermaidToEditable = React.useCallback(async () => { + if (!mermaidRecoverySource) { + return; + } + + const parsed = parseMermaidByType(mermaidRecoverySource, { + architectureStrictMode: viewSettings.architectureStrictMode, + }); + const diagnostics = normalizeParseDiagnostics(parsed.diagnostics); + + if (parsed.error) { + setMermaidDiagnostics( + buildMermaidDiagnosticsSnapshot({ + source: 'import', + diagramType: parsed.diagramType, + importState: parsed.importState, + originalSource: mermaidRecoverySource, + diagnostics, + error: parsed.error, + }) + ); + return; + } + + const editableImport = await importMermaidToCanvas({ + parsed, + source: mermaidRecoverySource, + importMode: 'native_editable', + layout: { + direction: resolveLayoutDirection(parsed), + spacing: 'normal', + contentDensity: 'balanced', + }, + }); + + recordHistory(); + setNodes(editableImport.nodes); + setEdges(editableImport.edges); + if (parsed.diagramType) { + updateTab(activePageId, { diagramType: parsed.diagramType }); + } + + if (diagnostics.length > 0 || editableImport.visualMode !== 'editable_exact') { + setMermaidDiagnostics( + buildMermaidDiagnosticsSnapshot({ + source: 'import', + diagramType: parsed.diagramType, + importState: parsed.importState, + originalSource: mermaidRecoverySource, + diagnostics, + nodeCount: editableImport.nodes.length, + edgeCount: editableImport.edges.length, + layoutMode: editableImport.layoutMode, + visualMode: editableImport.visualMode, + layoutFallbackReason: editableImport.layoutFallbackReason, + }) + ); + } else { + clearMermaidDiagnostics(); + } + }, [ + activePageId, + clearMermaidDiagnostics, + mermaidRecoverySource, + recordHistory, + setEdges, + setMermaidDiagnostics, + setNodes, + updateTab, + viewSettings.architectureStrictMode, + ]); return ( flowEditorController.openStudioCode('mermaid') : undefined} + actionLabel={ + mermaidDiagnostics.visualMode === 'renderer_exact' && canRecoverMermaidSource + ? 'Convert to editable diagram' + : canRecoverMermaidSource + ? 'Open Mermaid code' + : undefined + } + onAction={ + mermaidDiagnostics.visualMode === 'renderer_exact' && canRecoverMermaidSource + ? handleConvertMermaidToEditable + : canRecoverMermaidSource + ? () => flowEditorController.openStudioCode('mermaid') + : undefined + } /> @@ -130,12 +229,16 @@ export function FlowEditor({ onGoHome }: FlowEditorProps) { onClose={dismissImportRecovery} actionLabel={ importRecoveryState.report.source === 'mermaid' && canRecoverMermaidSource - ? 'Open Mermaid code' + ? mermaidDiagnostics?.visualMode === 'renderer_exact' + ? 'Convert to editable diagram' + : 'Open Mermaid code' : undefined } onAction={ importRecoveryState.report.source === 'mermaid' && canRecoverMermaidSource - ? () => flowEditorController.openStudioCode('mermaid') + ? mermaidDiagnostics?.visualMode === 'renderer_exact' + ? handleConvertMermaidToEditable + : () => flowEditorController.openStudioCode('mermaid') : undefined } /> diff --git a/src/components/ImportRecoveryDialog.test.tsx b/src/components/ImportRecoveryDialog.test.tsx index fb0a0dca..acf4a72d 100644 --- a/src/components/ImportRecoveryDialog.test.tsx +++ b/src/components/ImportRecoveryDialog.test.tsx @@ -40,6 +40,8 @@ function createMermaidReport(): ImportFidelityReport { id: 'import-2', source: 'mermaid', importState: 'unsupported_family', + layoutMode: 'elk_fallback', + layoutFallbackReason: 'Mermaid SVG extraction unavailable', originalSource: 'gitGraph\ncommit id: "a1"', timestamp: '2026-03-30T00:00:00.000Z', status: 'failed', @@ -60,6 +62,33 @@ function createMermaidReport(): ImportFidelityReport { }; } +function createMermaidLayoutWarningReport(): ImportFidelityReport { + return { + id: 'import-3', + source: 'mermaid', + importState: 'editable_full', + layoutMode: 'mermaid_preserved_partial', + layoutFallbackReason: 'matched 1/2 official flowchart edge routes', + originalSource: 'flowchart LR\nA-->B', + timestamp: '2026-03-30T00:00:00.000Z', + status: 'success_with_warnings', + nodeCount: 2, + edgeCount: 1, + elapsedMs: 11, + issues: [ + { + code: 'MERMAID_LAYOUT_PRESERVED', + severity: 'warning', + message: 'Partial Mermaid layout preserved: matched 1/2 official flowchart edge routes', + }, + ], + summary: { + warningCount: 1, + errorCount: 0, + }, + }; +} + describe('ImportRecoveryDialog', () => { it('renders import issues and invokes retry/close actions', () => { const onRetry = vi.fn(); @@ -97,7 +126,8 @@ describe('ImportRecoveryDialog', () => { ); expect(screen.getByText('Unsupported Mermaid family')).toBeTruthy(); - expect(screen.getByText(/not editable yet/i)).toBeTruthy(); + expect(screen.getByText('ELK fallback')).toBeTruthy(); + expect(screen.getByText(/ELK fallback was used/i)).toBeTruthy(); expect(screen.getByText(/Original Mermaid source is preserved/i)).toBeTruthy(); }); @@ -118,4 +148,18 @@ describe('ImportRecoveryDialog', () => { fireEvent.click(screen.getByRole('button', { name: 'Open Mermaid code' })); expect(onAction).toHaveBeenCalledTimes(1); }); + + it('shows warning-grade Mermaid status when layout degraded despite editable_full parsing', () => { + render( + + ); + + expect(screen.getByText('Ready with warnings')).toBeTruthy(); + expect(screen.getByText('Preserved partial Mermaid layout')).toBeTruthy(); + }); }); diff --git a/src/components/ImportRecoveryDialog.tsx b/src/components/ImportRecoveryDialog.tsx index 30d435ac..a2caab95 100644 --- a/src/components/ImportRecoveryDialog.tsx +++ b/src/components/ImportRecoveryDialog.tsx @@ -5,7 +5,7 @@ import { getImportRecoveryGuidance, type ImportFidelityReport, } from '@/services/importFidelity'; -import { getMermaidImportStateLabel } from '@/services/mermaid/importStatePresentation'; +import { getMermaidStatusLabel } from '@/services/mermaid/importStatePresentation'; import { MODAL_PANEL_CLASS, SECTION_CARD_CLASS, SECTION_SURFACE_CLASS, STATUS_SURFACE_CLASS } from '@/lib/designTokens'; import { Button } from './ui/Button'; @@ -26,6 +26,27 @@ function formatSourceLabel(source: ImportFidelityReport['source']): string { return source.toUpperCase(); } +function formatLayoutLabel(report: ImportFidelityReport): string | null { + if (report.source !== 'mermaid' || !report.layoutMode) { + return null; + } + + if (report.layoutMode === 'mermaid_exact') { + return 'Exact Mermaid layout'; + } + if (report.layoutMode === 'mermaid_preserved_partial') { + return 'Preserved partial Mermaid layout'; + } + if (report.layoutMode === 'mermaid_partial') { + return 'Partial Mermaid extraction with ELK'; + } + if (report.layoutMode === 'elk_fallback') { + return 'ELK fallback'; + } + + return null; +} + export function ImportRecoveryDialog({ fileName, report, @@ -38,7 +59,13 @@ export function ImportRecoveryDialog({ const visibleIssues = report.issues.slice(0, 3); const remainingIssueCount = Math.max(0, report.issues.length - visibleIssues.length); const mermaidStateLabel = - report.source === 'mermaid' ? getMermaidImportStateLabel(report.importState) : null; + report.source === 'mermaid' + ? getMermaidStatusLabel({ + importState: report.importState, + layoutMode: report.layoutMode, + }) + : null; + const layoutLabel = formatLayoutLabel(report); const recoveryGuidance = getImportRecoveryGuidance(report); useEffect(() => { @@ -89,7 +116,7 @@ export function ImportRecoveryDialog({
-
+
Source
{formatSourceLabel(report.source)}
@@ -100,6 +127,12 @@ export function ImportRecoveryDialog({
{mermaidStateLabel}
) : null} + {layoutLabel ? ( +
+
Layout
+
{layoutLabel}
+
+ ) : null}
Errors
{report.summary.errorCount}
diff --git a/src/components/MermaidDiagnosticsBanner.test.tsx b/src/components/MermaidDiagnosticsBanner.test.tsx index 004a981e..14358542 100644 --- a/src/components/MermaidDiagnosticsBanner.test.tsx +++ b/src/components/MermaidDiagnosticsBanner.test.tsx @@ -47,4 +47,25 @@ describe('MermaidDiagnosticsBanner', () => { fireEvent.click(screen.getByRole('button', { name: 'Open Mermaid code' })); expect(onAction).toHaveBeenCalledTimes(1); }); + + it('keeps the Mermaid recovery hint visible when layout fidelity degrades despite editable_full parsing', () => { + render( + B', + diagnostics: [], + updatedAt: 1, + }} + /> + ); + + expect(screen.getByText(/Original Mermaid source is preserved/i)).toBeTruthy(); + }); }); diff --git a/src/components/MermaidDiagnosticsBanner.tsx b/src/components/MermaidDiagnosticsBanner.tsx index bc4ba9b5..8119f225 100644 --- a/src/components/MermaidDiagnosticsBanner.tsx +++ b/src/components/MermaidDiagnosticsBanner.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { AlertCircle } from 'lucide-react'; import type { MermaidDiagnosticsSnapshot } from '@/store/types'; +import { canRecoverMermaidSource } from '@/services/mermaid/recoveryPresentation'; function getMermaidDiagnosticsBannerClass(hasError: boolean): string { if (hasError) { @@ -28,6 +29,11 @@ export function MermaidDiagnosticsBanner({ || snapshot.statusDetail || snapshot.diagnostics[0]?.message || 'Mermaid diagnostics are available.'; + const showRecoveryHint = canRecoverMermaidSource({ + originalSource: snapshot.originalSource, + importState: snapshot.importState, + layoutMode: snapshot.layoutMode, + }); return (
{snapshot.statusLabel ?? 'Mermaid diagnostics'}
{message}
- {snapshot.originalSource && snapshot.importState !== 'editable_full' ? ( + {showRecoveryHint ? (
Original Mermaid source is preserved. Open Mermaid code to continue editing safely.
diff --git a/src/components/MermaidSvgNode.tsx b/src/components/MermaidSvgNode.tsx new file mode 100644 index 00000000..2f7c0922 --- /dev/null +++ b/src/components/MermaidSvgNode.tsx @@ -0,0 +1,51 @@ +import React, { memo, useMemo } from 'react'; +import type { LegacyNodeProps } from '@/lib/reactflowCompat'; +import type { NodeData } from '@/lib/types'; +import { NodeChrome } from './NodeChrome'; + +function sanitizeMermaidSvgMarkup(svgMarkup: string | undefined): string | null { + if (typeof svgMarkup !== 'string' || svgMarkup.trim().length === 0) { + return null; + } + + // Mermaid owns the markup, but we still strip script tags as a hard safety floor. + return svgMarkup.replace(/[\s\S]*?<\/script>/gi, '').trim(); +} + +function MermaidSvgNode({ id, data, selected }: LegacyNodeProps): React.ReactElement { + const sanitizedSvg = useMemo(() => sanitizeMermaidSvgMarkup(data.mermaidSvg), [data.mermaidSvg]); + + return ( + +
+ {sanitizedSvg ? ( +
+ ) : ( +
+ Mermaid render unavailable +
+ )} +
+ + ); +} + +export default memo(MermaidSvgNode); diff --git a/src/components/SectionNode.tsx b/src/components/SectionNode.tsx index ef109855..7c41a3b3 100644 --- a/src/components/SectionNode.tsx +++ b/src/components/SectionNode.tsx @@ -4,13 +4,70 @@ import type { NodeData } from '@/lib/types'; import { getNodeParentId } from '@/lib/nodeParent'; import { Group, Lock, EyeOff } from 'lucide-react'; import { NamedIcon } from './IconMap'; +import { getNumericNodeDimension } from './nodeHelpers'; import { resolveSectionVisualStyle } from '../theme'; import { useInlineNodeTextEdit } from '@/hooks/useInlineNodeTextEdit'; import { InlineTextEditSurface } from './InlineTextEditSurface'; import { NodeChrome } from './NodeChrome'; import { useSelectionState } from '@/store/selectionHooks'; +import { readMermaidImportedNodeMetadataFromData } from '@/services/mermaid/importProvenance'; -function SectionNode({ id, data, selected }: LegacyNodeProps): React.ReactElement { +type SectionRenderVariant = 'default' | 'mermaid-import'; + +interface SectionRenderConfig { + variant: SectionRenderVariant; + bodyBorderRadius: string; + bodyInset: number; + titleTop: number; + titleLeft: number; + titlePadding: string; + titleBackgroundColor: string; + titleMaxWidth: string; + showLeadingIcon: boolean; + showImportedBadge: boolean; + showChildCount: boolean; +} + +function getSectionRenderConfig( + isImportedMermaidContainer: boolean, + borderColor: string +): SectionRenderConfig { + if (isImportedMermaidContainer) { + return { + variant: 'mermaid-import', + bodyBorderRadius: '12px', + bodyInset: 0, + titleTop: 8, + titleLeft: 10, + titlePadding: '0.15rem 0.45rem', + titleBackgroundColor: 'rgba(255,255,255,0.9)', + titleMaxWidth: 'calc(100% - 72px)', + showLeadingIcon: false, + showImportedBadge: true, + showChildCount: false, + }; + } + + return { + variant: 'default', + bodyBorderRadius: '16px', + bodyInset: 0, + titleTop: -36, + titleLeft: 0, + titlePadding: '0.375rem 0.625rem', + titleBackgroundColor: `${borderColor}22`, + titleMaxWidth: 'calc(100% - 8px)', + showLeadingIcon: true, + showImportedBadge: true, + showChildCount: true, + }; +} + +function SectionNode(props: LegacyNodeProps): React.ReactElement { + const { id, data, selected } = props; + const explicitNodeStyle = (props as { style?: React.CSSProperties }).style; + const explicitWidth = getNumericNodeDimension(explicitNodeStyle?.width); + const explicitHeight = getNumericNodeDimension(explicitNodeStyle?.height); const allNodes = useNodes(); const { hoveredSectionId } = useSelectionState(); const theme = resolveSectionVisualStyle(data.color, data.colorMode, data.customColor, 'blue'); @@ -24,39 +81,57 @@ function SectionNode({ id, data, selected }: LegacyNodeProps): React.R const isDropTarget = hoveredSectionId === id; const isLocked = data.sectionLocked === true; const isHidden = data.sectionHidden === true; + const isImportedMermaidContainer = + readMermaidImportedNodeMetadataFromData(data)?.role === 'container'; + const minWidth = isImportedMermaidContainer ? explicitWidth ?? 350 : 350; + const minHeight = isImportedMermaidContainer ? explicitHeight ?? 250 : 250; const borderColor = isDropTarget ? theme.title : theme.border; const bgColor = isDropTarget ? `color-mix(in srgb, ${theme.bg} 85%, white 15%)` : theme.bg; + const renderConfig = getSectionRenderConfig(isImportedMermaidContainer, borderColor); return (
- {/* Floating title — sits ABOVE the section border, FigJam-style */}
- {iconName ? ( - - ) : ( - - )} + {renderConfig.showLeadingIcon ? ( + iconName ? ( + + ) : ( + + ) + ) : null} ): React.R onDraftChange={labelEdit.setDraft} onCommit={labelEdit.commit} onKeyDown={labelEdit.handleKeyDown} - className="font-semibold text-[13px] leading-tight tracking-tight whitespace-nowrap" + className={`font-semibold leading-tight tracking-tight whitespace-nowrap ${ + isImportedMermaidContainer ? 'text-[12px]' : 'text-[13px]' + }`} style={{ color: theme.title }} inputClassName="font-semibold" isSelected={Boolean(selected)} @@ -94,14 +171,26 @@ function SectionNode({ id, data, selected }: LegacyNodeProps): React.R {isHidden && }
)} + {renderConfig.showImportedBadge && isImportedMermaidContainer && ( + + Imported + + )}
- {/* Section body — clean border, no internal header bar */}
): React.R }} /> - {/* Edge drag-handle strips for resize */}
- {/* Child count — subtle bottom-right badge */} - {childCount > 0 && ( + {renderConfig.showChildCount && childCount > 0 && ( setViewSettings({ architectureStrictMode: checked })} /> +
+ +
+ {[ + { + mode: 'renderer_first', + label: t('settingsModal.canvas.mermaidImportModeRenderer', 'Fidelity-first'), + }, + { + mode: 'native_editable', + label: t('settingsModal.canvas.mermaidImportModeEditable', 'Editable-first'), + }, + ].map((option) => ( + + ))} +
+

+ {t( + 'settingsModal.canvas.mermaidImportModeDesc', + 'Fidelity-first keeps Mermaid’s rendered geometry. Editable-first converts directly to native canvas nodes.' + )} +

+
✨ Mermaid → Icons
Paste Mermaid · 1,100+ icons
auto-assigned · beautiful
✨ Mermaid → Icons
Paste Mermaid · 1,600+ icons
auto-assigned · beautiful
🤖 AI Generation
9 providers · BYOK
Direct-to-canvas output
`{}` Diagram as Code
Bidirectional live sync
Git-friendly DSL
🧩 Asset Libraries
Developer · AWS · Azure
GCP · CNCF · Icons