Repository: jupyrdf/ipyforcegraph (language: python)

Issue:
Link Directionality
# Elevator Pitch
Add visual representation for the directionality of links.

# Motivation
* In complex digraphs, understanding the direction the edges go can be important.
* The `force-graph` libraries offer static (arrows) and dynamic (particles) representations for this.
* This representation can also be used to convey properties of the link (e.g., number of particles could represent data flows)

# Design Ideas
* Keep the python interface for the 2D and 3D the same.
* Control the aesthetic representation and location of the arrows and particles
  * For the arrows allow changing `linkDirectionalArrowRelPos`, `linkDirectionalArrowColor`, `linkDirectionalArrowLength` from python side
  * For the particles allow changing `linkDirectionalParticleSpeed`, `linkDirectionalParticleWidth`, `linkDirectionalParticleColor`, maybe we need a separate behavior for doing particle emission (i.e., `emitParticle`)
* Would be great if we could drive these based on properties of the links


Discussion / hints:

Fix patch:
diff --git a/.binder/environment.yml b/.binder/environment.yml
index 0ff1959b..f69dcf97 100644
--- a/.binder/environment.yml
+++ b/.binder/environment.yml
@@ -23,6 +23,7 @@ dependencies:
   - robotframework-seleniumlibrary
   - robotframework-tidy
   - rope
+  - stringcase
   ### docs/environment.yml ###
   - ansi2html
   - bqplot >=0.12.20
diff --git a/.github/locks/linux-64_3.11_environment.conda.lock b/.github/locks/linux-64_3.11_environment.conda.lock
index 200475c6..c7ac8137 100644
--- a/.github/locks/linux-64_3.11_environment.conda.lock
+++ b/.github/locks/linux-64_3.11_environment.conda.lock
@@ -23,6 +23,7 @@
 #   - robotframework-seleniumlibrary
 #   - robotframework-tidy
 #   - rope
+#   - stringcase
 #   ### docs/environment.yml ###
 #   - ansi2html
 #   - bqplot >=0.12.20
@@ -211,6 +212,7 @@ https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.0.0-pyhd8
 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-1.0.1-py_0.tar.bz2#67cd9d9c0382d37479b4d306c369a2d4
 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-1.0.3-py_0.tar.bz2#d01180388e6d1838c3e1ad029590aa7a
 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-1.1.5-pyhd8ed1ab_2.tar.bz2#9ff55a0901cf952f05c654394de76bf7
+https://conda.anaconda.org/conda-forge/noarch/stringcase-1.2.0-py_0.tar.bz2#26a9caf3173939377bac7152379daac0
 https://conda.anaconda.org/conda-forge/noarch/tabulate-0.9.0-pyhd8ed1ab_1.tar.bz2#4759805cce2d914c38472f70bf4d8bcb
 https://conda.anaconda.org/conda-forge/noarch/tokenize-rt-5.0.0-pyhd8ed1ab_0.tar.bz2#c8bea20ee7803c4df5821fae0ee388d6
 https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_0.tar.bz2#f832c45a477c78bebd107098db465095
@@ -307,7 +309,7 @@ https://conda.anaconda.org/conda-forge/noarch/jupyter_client-7.4.8-pyhd8ed1ab_0.
 https://conda.anaconda.org/conda-forge/linux-64/keyring-23.13.1-py311h38be061_0.conda#0dc0127b1daefefa5e2caa49dde5c230
 https://conda.anaconda.org/conda-forge/noarch/nbformat-5.7.1-pyhd8ed1ab_0.conda#facb42913140f1a399a5ebb65b30915e
 https://conda.anaconda.org/conda-forge/noarch/robotframework-tidy-3.3.2-pyhd8ed1ab_0.tar.bz2#354a5c374999c2688332c073e2450951
-https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.13-pyhd8ed1ab_0.conda#3078ef2359efd6ecadbc7e085c5e0592
+https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.14-pyhd8ed1ab_0.conda#01f33ad2e0aaf6b5ba4add50dad5ad29
 https://conda.anaconda.org/conda-forge/noarch/black-jupyter-22.10.0-hd8ed1ab_2.tar.bz2#80112c21a14035fe1d786f082ce27978
 https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.20.1-pyh210e3f2_0.conda#cfc405049e1e3c5111fe588bacddb36f
 https://conda.anaconda.org/conda-forge/noarch/nbclient-0.5.13-pyhd8ed1ab_0.tar.bz2#3edde88a191701cf052216c4ba353a83
diff --git a/.github/locks/linux-64_3.7_environment.conda.lock b/.github/locks/linux-64_3.7_environment.conda.lock
index e33876f9..9d33fdac 100644
--- a/.github/locks/linux-64_3.7_environment.conda.lock
+++ b/.github/locks/linux-64_3.7_environment.conda.lock
@@ -23,6 +23,7 @@
 #   - robotframework-seleniumlibrary
 #   - robotframework-tidy
 #   - rope
+#   - stringcase
 #   ### docs/environment.yml ###
 #   - ansi2html
 #   - bqplot >=0.12.20
@@ -119,7 +120,7 @@ https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.39-h753d276_0.conda#e
 https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.40.0-h753d276_0.tar.bz2#2e5f9a37d487e1019fd4d8113adb2f9f
 https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.10.0-hf14f497_3.tar.bz2#d85acad4b47dff4e3def14a769a97906
 https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.13-h7f98852_1004.tar.bz2#b3653fdc58d03face9724f602218a904
-https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.10.3-h7463322_0.tar.bz2#3b933ea47ef8f330c4c068af25fcd6a8
+https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.10.3-hca2bb57_1.conda#e67867487d8f19c9ac2b4ee7871e69de
 https://conda.anaconda.org/conda-forge/linux-64/pandoc-2.19.2-h32600fe_1.tar.bz2#35a82883468c85ac8bf41f083c1933cf
 https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.40-hc3806b6_0.tar.bz2#69e2c796349cd9b273890bee0febfe1b
 https://conda.anaconda.org/conda-forge/linux-64/perl-5.32.1-2_h7f98852_perl5.tar.bz2#09ba115862623f00962e9809ea248f1a
@@ -228,13 +229,14 @@ https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.0.0-pyhd8
 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-1.0.1-py_0.tar.bz2#67cd9d9c0382d37479b4d306c369a2d4
 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-1.0.3-py_0.tar.bz2#d01180388e6d1838c3e1ad029590aa7a
 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-1.1.5-pyhd8ed1ab_2.tar.bz2#9ff55a0901cf952f05c654394de76bf7
+https://conda.anaconda.org/conda-forge/noarch/stringcase-1.2.0-py_0.tar.bz2#26a9caf3173939377bac7152379daac0
 https://conda.anaconda.org/conda-forge/noarch/tabulate-0.9.0-pyhd8ed1ab_1.tar.bz2#4759805cce2d914c38472f70bf4d8bcb
 https://conda.anaconda.org/conda-forge/noarch/tokenize-rt-5.0.0-pyhd8ed1ab_0.tar.bz2#c8bea20ee7803c4df5821fae0ee388d6
 https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_0.tar.bz2#f832c45a477c78bebd107098db465095
 https://conda.anaconda.org/conda-forge/noarch/tomli-2.0.1-pyhd8ed1ab_0.tar.bz2#5844808ffab9ebdb694585b50ba02a96
 https://conda.anaconda.org/conda-forge/noarch/tomli-w-1.0.0-pyhd8ed1ab_0.tar.bz2#73506d1ab4202481841c68c169b7ef6c
 https://conda.anaconda.org/conda-forge/linux-64/tornado-6.2-py37h540881e_0.tar.bz2#2f0863ba6b29d1d2872b064dd697a492
-https://conda.anaconda.org/conda-forge/noarch/traitlets-5.8.0-pyhd8ed1ab_0.conda#75fa7ac3992a41e6a06102594bbf67d0
+https://conda.anaconda.org/conda-forge/noarch/traitlets-5.8.1-pyhd8ed1ab_0.conda#ef5179bcc6a9acc23b12ac92936e05ab
 https://conda.anaconda.org/conda-forge/linux-64/typed-ast-1.5.4-py37h540881e_0.tar.bz2#f9d104fbe4d74756013127a6d18a888a
 https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.4.0-pyha770c72_0.tar.bz2#2d93b130d148d7fc77e583677792fc6a
 https://conda.anaconda.org/conda-forge/linux-64/ujson-5.5.0-py37hd23a5d3_0.tar.bz2#14c44e39a57e09715ac974522aa70d53
@@ -325,7 +327,7 @@ https://conda.anaconda.org/conda-forge/noarch/nbclient-0.5.13-pyhd8ed1ab_0.tar.b
 https://conda.anaconda.org/conda-forge/noarch/networkx-2.7-pyhd8ed1ab_0.tar.bz2#9d430f94458637bbb2f5e78526324412
 https://conda.anaconda.org/conda-forge/linux-64/pytest-7.2.0-py37h89c1867_0.tar.bz2#c623307fdde70b00731a06dc62341a6c
 https://conda.anaconda.org/conda-forge/noarch/robotframework-tidy-3.3.2-pyhd8ed1ab_0.tar.bz2#354a5c374999c2688332c073e2450951
-https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.13-pyhd8ed1ab_0.conda#3078ef2359efd6ecadbc7e085c5e0592
+https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.14-pyhd8ed1ab_0.conda#01f33ad2e0aaf6b5ba4add50dad5ad29
 https://conda.anaconda.org/conda-forge/noarch/black-jupyter-22.10.0-hd8ed1ab_2.tar.bz2#80112c21a14035fe1d786f082ce27978
 https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.16.2-pyh210e3f2_0.tar.bz2#6b0f40821b784cac8a33d0c5eb7602c0
 https://conda.anaconda.org/conda-forge/noarch/jupyter-cache-0.5.0-pyhd8ed1ab_0.tar.bz2#293ede7de6b604f3586efbfa615ee2e1
diff --git a/.github/locks/osx-64_3.11_environment.conda.lock b/.github/locks/osx-64_3.11_environment.conda.lock
index 9f7792ee..ee5b71c5 100644
--- a/.github/locks/osx-64_3.11_environment.conda.lock
+++ b/.github/locks/osx-64_3.11_environment.conda.lock
@@ -23,6 +23,7 @@
 #   - robotframework-seleniumlibrary
 #   - robotframework-tidy
 #   - rope
+#   - stringcase
 #   ### docs/environment.yml ###
 #   - ansi2html
 #   - bqplot >=0.12.20
@@ -188,6 +189,7 @@ https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.0.0-pyhd8
 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-1.0.1-py_0.tar.bz2#67cd9d9c0382d37479b4d306c369a2d4
 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-1.0.3-py_0.tar.bz2#d01180388e6d1838c3e1ad029590aa7a
 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-1.1.5-pyhd8ed1ab_2.tar.bz2#9ff55a0901cf952f05c654394de76bf7
+https://conda.anaconda.org/conda-forge/noarch/stringcase-1.2.0-py_0.tar.bz2#26a9caf3173939377bac7152379daac0
 https://conda.anaconda.org/conda-forge/noarch/tabulate-0.9.0-pyhd8ed1ab_1.tar.bz2#4759805cce2d914c38472f70bf4d8bcb
 https://conda.anaconda.org/conda-forge/noarch/tokenize-rt-5.0.0-pyhd8ed1ab_0.tar.bz2#c8bea20ee7803c4df5821fae0ee388d6
 https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_0.tar.bz2#f832c45a477c78bebd107098db465095
@@ -288,7 +290,7 @@ https://conda.anaconda.org/conda-forge/noarch/nbformat-5.7.1-pyhd8ed1ab_0.conda#
 https://conda.anaconda.org/conda-forge/osx-64/numcodecs-0.11.0-py311h814d153_1.conda#21f2ae35161c19b8c4ad0791d12ef2a3
 https://conda.anaconda.org/conda-forge/osx-64/pandas-1.5.2-py311hd84f3f5_2.conda#2952f7263a51d4286f1ff168887a5715
 https://conda.anaconda.org/conda-forge/noarch/robotframework-tidy-3.3.2-pyhd8ed1ab_0.tar.bz2#354a5c374999c2688332c073e2450951
-https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.13-pyhd8ed1ab_0.conda#3078ef2359efd6ecadbc7e085c5e0592
+https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.14-pyhd8ed1ab_0.conda#01f33ad2e0aaf6b5ba4add50dad5ad29
 https://conda.anaconda.org/conda-forge/noarch/black-jupyter-22.10.0-hd8ed1ab_2.tar.bz2#80112c21a14035fe1d786f082ce27978
 https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.20.1-pyh736e0ef_0.conda#4a1acae5e2868bb5ee67359ac69ad759
 https://conda.anaconda.org/conda-forge/noarch/nbclient-0.5.13-pyhd8ed1ab_0.tar.bz2#3edde88a191701cf052216c4ba353a83
diff --git a/.github/locks/osx-64_3.7_environment.conda.lock b/.github/locks/osx-64_3.7_environment.conda.lock
index 145aedde..5556222c 100644
--- a/.github/locks/osx-64_3.7_environment.conda.lock
+++ b/.github/locks/osx-64_3.7_environment.conda.lock
@@ -23,6 +23,7 @@
 #   - robotframework-seleniumlibrary
 #   - robotframework-tidy
 #   - rope
+#   - stringcase
 #   ### docs/environment.yml ###
 #   - ansi2html
 #   - bqplot >=0.12.20
@@ -116,7 +117,7 @@ https://conda.anaconda.org/conda-forge/osx-64/libgfortran-5.0.0-11_3_0_h97931a8_
 https://conda.anaconda.org/conda-forge/osx-64/libnghttp2-1.51.0-he2ab024_0.conda#910e7f012beecf5d845a993feb9259a9
 https://conda.anaconda.org/conda-forge/osx-64/libssh2-1.10.0-h47af595_3.tar.bz2#5a28624eeb7812b585b9e2d75f846ba2
 https://conda.anaconda.org/conda-forge/osx-64/libtiff-4.4.0-h6268bbc_5.conda#551fc78ba147767ea5905d0746e2fbb5
-https://conda.anaconda.org/conda-forge/osx-64/libxml2-2.10.3-hb9e07b5_0.tar.bz2#13ba8bf8f44cdac2e5401dac20a36040
+https://conda.anaconda.org/conda-forge/osx-64/libxml2-2.10.3-hb9e07b5_1.conda#eb3935321cd42650512d09af480fee22
 https://conda.anaconda.org/conda-forge/osx-64/nodejs-18.12.1-hd0c9b3c_0.tar.bz2#e5ab86f2155244c022f8db0165bce009
 https://conda.anaconda.org/conda-forge/osx-64/sqlite-3.40.0-h9ae0607_0.tar.bz2#b66b0b11f1b901f3c2bce9406bedfd40
 https://conda.anaconda.org/conda-forge/osx-64/brotli-1.0.9-hb7f2c08_8.tar.bz2#55f612fe4a9b5f6ac76348b6de94aaeb
@@ -208,13 +209,14 @@ https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.0.0-pyhd8
 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-1.0.1-py_0.tar.bz2#67cd9d9c0382d37479b4d306c369a2d4
 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-1.0.3-py_0.tar.bz2#d01180388e6d1838c3e1ad029590aa7a
 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-1.1.5-pyhd8ed1ab_2.tar.bz2#9ff55a0901cf952f05c654394de76bf7
+https://conda.anaconda.org/conda-forge/noarch/stringcase-1.2.0-py_0.tar.bz2#26a9caf3173939377bac7152379daac0
 https://conda.anaconda.org/conda-forge/noarch/tabulate-0.9.0-pyhd8ed1ab_1.tar.bz2#4759805cce2d914c38472f70bf4d8bcb
 https://conda.anaconda.org/conda-forge/noarch/tokenize-rt-5.0.0-pyhd8ed1ab_0.tar.bz2#c8bea20ee7803c4df5821fae0ee388d6
 https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_0.tar.bz2#f832c45a477c78bebd107098db465095
 https://conda.anaconda.org/conda-forge/noarch/tomli-2.0.1-pyhd8ed1ab_0.tar.bz2#5844808ffab9ebdb694585b50ba02a96
 https://conda.anaconda.org/conda-forge/noarch/tomli-w-1.0.0-pyhd8ed1ab_0.tar.bz2#73506d1ab4202481841c68c169b7ef6c
 https://conda.anaconda.org/conda-forge/osx-64/tornado-6.2-py37h994c40b_0.tar.bz2#c3332b56d4d2fdb61c6b1319fce87dcc
-https://conda.anaconda.org/conda-forge/noarch/traitlets-5.8.0-pyhd8ed1ab_0.conda#75fa7ac3992a41e6a06102594bbf67d0
+https://conda.anaconda.org/conda-forge/noarch/traitlets-5.8.1-pyhd8ed1ab_0.conda#ef5179bcc6a9acc23b12ac92936e05ab
 https://conda.anaconda.org/conda-forge/osx-64/typed-ast-1.5.4-py37h994c40b_0.tar.bz2#c79fcf509142a82adcbaff8b93c063fd
 https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.4.0-pyha770c72_0.tar.bz2#2d93b130d148d7fc77e583677792fc6a
 https://conda.anaconda.org/conda-forge/osx-64/ujson-5.5.0-py37hac51a3e_0.tar.bz2#64104fa8acc31f320b7f4d3b8434c1c6
@@ -307,7 +309,7 @@ https://conda.anaconda.org/conda-forge/noarch/nbclient-0.5.13-pyhd8ed1ab_0.tar.b
 https://conda.anaconda.org/conda-forge/noarch/networkx-2.7-pyhd8ed1ab_0.tar.bz2#9d430f94458637bbb2f5e78526324412
 https://conda.anaconda.org/conda-forge/osx-64/pytest-7.2.0-py37hf985489_0.tar.bz2#e26d95efe84901a52eb7ccfd1bbf3712
 https://conda.anaconda.org/conda-forge/noarch/robotframework-tidy-3.3.2-pyhd8ed1ab_0.tar.bz2#354a5c374999c2688332c073e2450951
-https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.13-pyhd8ed1ab_0.conda#3078ef2359efd6ecadbc7e085c5e0592
+https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.14-pyhd8ed1ab_0.conda#01f33ad2e0aaf6b5ba4add50dad5ad29
 https://conda.anaconda.org/conda-forge/noarch/black-jupyter-22.10.0-hd8ed1ab_2.tar.bz2#80112c21a14035fe1d786f082ce27978
 https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.16.2-pyh736e0ef_0.tar.bz2#35bc6846dabf976973cf960cd5fe2148
 https://conda.anaconda.org/conda-forge/noarch/jupyter-cache-0.5.0-pyhd8ed1ab_0.tar.bz2#293ede7de6b604f3586efbfa615ee2e1
diff --git a/.github/locks/win-64_3.11_environment.conda.lock b/.github/locks/win-64_3.11_environment.conda.lock
index 2bc8d93c..591e5e04 100644
--- a/.github/locks/win-64_3.11_environment.conda.lock
+++ b/.github/locks/win-64_3.11_environment.conda.lock
@@ -23,6 +23,7 @@
 #   - robotframework-seleniumlibrary
 #   - robotframework-tidy
 #   - rope
+#   - stringcase
 #   ### docs/environment.yml ###
 #   - ansi2html
 #   - bqplot >=0.12.20
@@ -175,6 +176,7 @@ https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.0.0-pyhd8
 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-1.0.1-py_0.tar.bz2#67cd9d9c0382d37479b4d306c369a2d4
 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-1.0.3-py_0.tar.bz2#d01180388e6d1838c3e1ad029590aa7a
 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-1.1.5-pyhd8ed1ab_2.tar.bz2#9ff55a0901cf952f05c654394de76bf7
+https://conda.anaconda.org/conda-forge/noarch/stringcase-1.2.0-py_0.tar.bz2#26a9caf3173939377bac7152379daac0
 https://conda.anaconda.org/conda-forge/noarch/tabulate-0.9.0-pyhd8ed1ab_1.tar.bz2#4759805cce2d914c38472f70bf4d8bcb
 https://conda.anaconda.org/conda-forge/noarch/tokenize-rt-5.0.0-pyhd8ed1ab_0.tar.bz2#c8bea20ee7803c4df5821fae0ee388d6
 https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_0.tar.bz2#f832c45a477c78bebd107098db465095
@@ -275,7 +277,7 @@ https://conda.anaconda.org/conda-forge/noarch/nbformat-5.7.1-pyhd8ed1ab_0.conda#
 https://conda.anaconda.org/conda-forge/win-64/numcodecs-0.11.0-py311h12c1d0e_1.conda#9b66ec550742f950ba5fdc1fe650ce26
 https://conda.anaconda.org/conda-forge/win-64/pandas-1.5.2-py311hf63dbb6_2.conda#6657e2e9ccfc95b5f5cd6279c69c0e80
 https://conda.anaconda.org/conda-forge/noarch/robotframework-tidy-3.3.2-pyhd8ed1ab_0.tar.bz2#354a5c374999c2688332c073e2450951
-https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.13-pyhd8ed1ab_0.conda#3078ef2359efd6ecadbc7e085c5e0592
+https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.14-pyhd8ed1ab_0.conda#01f33ad2e0aaf6b5ba4add50dad5ad29
 https://conda.anaconda.org/conda-forge/noarch/black-jupyter-22.10.0-hd8ed1ab_2.tar.bz2#80112c21a14035fe1d786f082ce27978
 https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.20.1-pyh025b116_0.conda#513c1ede964e753f2573cbab165f0208
 https://conda.anaconda.org/conda-forge/noarch/nbclient-0.5.13-pyhd8ed1ab_0.tar.bz2#3edde88a191701cf052216c4ba353a83
diff --git a/.github/locks/win-64_3.7_environment.conda.lock b/.github/locks/win-64_3.7_environment.conda.lock
index 85cd6603..a7dbf1d6 100644
--- a/.github/locks/win-64_3.7_environment.conda.lock
+++ b/.github/locks/win-64_3.7_environment.conda.lock
@@ -23,6 +23,7 @@
 #   - robotframework-seleniumlibrary
 #   - robotframework-tidy
 #   - rope
+#   - stringcase
 #   ### docs/environment.yml ###
 #   - ansi2html
 #   - bqplot >=0.12.20
@@ -101,7 +102,7 @@ https://conda.anaconda.org/conda-forge/win-64/libbrotlidec-1.0.9-hcfcfb64_8.tar.
 https://conda.anaconda.org/conda-forge/win-64/libbrotlienc-1.0.9-hcfcfb64_8.tar.bz2#88e62627120c20289bf8982b15e0a6a1
 https://conda.anaconda.org/conda-forge/win-64/libflang-5.0.0-h6538335_20180525.tar.bz2#9f473a344e18668e99a93f7e21a54b69
 https://conda.anaconda.org/conda-forge/win-64/libpng-1.6.39-h19919ed_0.conda#ab6febdb2dbd9c00803609079db4de71
-https://conda.anaconda.org/conda-forge/win-64/libxml2-2.10.3-hc3477c8_0.tar.bz2#6dcf17bf780618ddb559d992ea3b6961
+https://conda.anaconda.org/conda-forge/win-64/libxml2-2.10.3-hc3477c8_1.conda#ebeb5e4cb351f83724a50dda9190b609
 https://conda.anaconda.org/conda-forge/win-64/m2w64-gcc-libs-5.3.0-7.tar.bz2#fe759119b8b3bfa720b8762c6fdc35de
 https://conda.anaconda.org/conda-forge/win-64/sqlite-3.40.0-hcfcfb64_0.tar.bz2#c71d4be22d8402e7e17386aeb18b930e
 https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.4-h0e60522_1.tar.bz2#e1aff0583dda5fb917eb3d2c1025aa80
@@ -198,13 +199,14 @@ https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.0.0-pyhd8
 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-1.0.1-py_0.tar.bz2#67cd9d9c0382d37479b4d306c369a2d4
 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-1.0.3-py_0.tar.bz2#d01180388e6d1838c3e1ad029590aa7a
 https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-1.1.5-pyhd8ed1ab_2.tar.bz2#9ff55a0901cf952f05c654394de76bf7
+https://conda.anaconda.org/conda-forge/noarch/stringcase-1.2.0-py_0.tar.bz2#26a9caf3173939377bac7152379daac0
 https://conda.anaconda.org/conda-forge/noarch/tabulate-0.9.0-pyhd8ed1ab_1.tar.bz2#4759805cce2d914c38472f70bf4d8bcb
 https://conda.anaconda.org/conda-forge/noarch/tokenize-rt-5.0.0-pyhd8ed1ab_0.tar.bz2#c8bea20ee7803c4df5821fae0ee388d6
 https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_0.tar.bz2#f832c45a477c78bebd107098db465095
 https://conda.anaconda.org/conda-forge/noarch/tomli-2.0.1-pyhd8ed1ab_0.tar.bz2#5844808ffab9ebdb694585b50ba02a96
 https://conda.anaconda.org/conda-forge/noarch/tomli-w-1.0.0-pyhd8ed1ab_0.tar.bz2#73506d1ab4202481841c68c169b7ef6c
 https://conda.anaconda.org/conda-forge/win-64/tornado-6.2-py37hcc03f2d_0.tar.bz2#f641f86e4a663fe93697da6cc51d6598
-https://conda.anaconda.org/conda-forge/noarch/traitlets-5.8.0-pyhd8ed1ab_0.conda#75fa7ac3992a41e6a06102594bbf67d0
+https://conda.anaconda.org/conda-forge/noarch/traitlets-5.8.1-pyhd8ed1ab_0.conda#ef5179bcc6a9acc23b12ac92936e05ab
 https://conda.anaconda.org/conda-forge/win-64/typed-ast-1.5.4-py37hcc03f2d_0.tar.bz2#a83d12574e31ec3a84f180231b129ed6
 https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.4.0-pyha770c72_0.tar.bz2#2d93b130d148d7fc77e583677792fc6a
 https://conda.anaconda.org/conda-forge/win-64/ujson-5.5.0-py37h7f67f24_0.tar.bz2#98c4c2572ac92aa0fe91bdc1af8468bc
@@ -298,7 +300,7 @@ https://conda.anaconda.org/conda-forge/noarch/nbclient-0.5.13-pyhd8ed1ab_0.tar.b
 https://conda.anaconda.org/conda-forge/noarch/networkx-2.7-pyhd8ed1ab_0.tar.bz2#9d430f94458637bbb2f5e78526324412
 https://conda.anaconda.org/conda-forge/win-64/pytest-7.2.0-py37h03978a9_0.tar.bz2#bc1cd0ec0102d6e13f7d82a70d5faf78
 https://conda.anaconda.org/conda-forge/noarch/robotframework-tidy-3.3.2-pyhd8ed1ab_0.tar.bz2#354a5c374999c2688332c073e2450951
-https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.13-pyhd8ed1ab_0.conda#3078ef2359efd6ecadbc7e085c5e0592
+https://conda.anaconda.org/conda-forge/noarch/urllib3-1.26.14-pyhd8ed1ab_0.conda#01f33ad2e0aaf6b5ba4add50dad5ad29
 https://conda.anaconda.org/conda-forge/noarch/black-jupyter-22.10.0-hd8ed1ab_2.tar.bz2#80112c21a14035fe1d786f082ce27978
 https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.16.2-pyh025b116_0.tar.bz2#5dc2bff03226e2ccc206ed20fc4a37cc
 https://conda.anaconda.org/conda-forge/noarch/jupyter-cache-0.5.0-pyhd8ed1ab_0.tar.bz2#293ede7de6b604f3586efbfa615ee2e1
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7ddfe942..2f6285cc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -200,6 +200,24 @@ jobs:
           name: ipyforcegraph-${{ github.run_number }}-dist
           path: ./dist
 
+      - name: preflight (linux)
+        if: ${{ contains(matrix.os, 'ubuntu') }}
+        shell: bash -l {0}
+        run: doit -n4 preflight:lab || doit preflight:lab || exit 1
+
+      - name: preflight (macos)
+        if: ${{ contains(matrix.os, 'macos') }}
+        shell: bash -l {0}
+        run: doit -n4 preflight:lab || doit preflight:lab || exit 1
+
+      - name: preflight (windows)
+        if: ${{ contains(matrix.os, 'windows') }}
+        shell: cmd /C CALL {0}
+        env:
+          ATEST_PROCESSES: 1
+          WIN_CI: 1
+        run: doit -n4 preflight:lab || doit preflight:lab || exit 1
+
       - name: test (linux)
         if: ${{ contains(matrix.os, 'ubuntu') }}
         shell: bash -l {0}
diff --git a/examples/3D.ipynb b/examples/3D.ipynb
index 86950a4a..d0987396 100644
--- a/examples/3D.ipynb
+++ b/examples/3D.ipynb
@@ -29,14 +29,8 @@
    "outputs": [],
    "source": [
     "with __import__(\"importnb\").Notebook():\n",
-    "    from _index import make_a_simple_example\n",
-    "    from Behaviors import (\n",
-    "        add_link_colors,\n",
-    "        add_link_labels,\n",
-    "        add_node_colors,\n",
-    "        add_node_labels,\n",
-    "        add_node_selection,\n",
-    "    )"
+    "    import Behaviors as B\n",
+    "    from _index import make_a_simple_example"
    ]
   },
   {
@@ -48,14 +42,26 @@
    "source": [
     "from ipyforcegraph.forcegraph import ForceGraph3D\n",
     "\n",
+    "behaviors = [\n",
+    "    B.add_node_selection,\n",
+    "    B.add_node_colors,\n",
+    "    B.add_node_labels,\n",
+    "    B.add_link_colors,\n",
+    "    B.add_link_labels,\n",
+    "    B.add_link_directional_arrow_color,\n",
+    "    B.add_link_directional_arrow_length,\n",
+    "    B.add_link_directional_arrow_rel_pos,\n",
+    "    B.add_link_directional_particle_color,\n",
+    "    B.add_link_directional_particle_speed,\n",
+    "    B.add_link_directional_particle_width,\n",
+    "    B.add_link_directional_particles,\n",
+    "]\n",
+    "\n",
     "\n",
     "def make_a_full_3d_example():\n",
     "    fg, box = make_a_simple_example(GraphClass=ForceGraph3D)\n",
-    "    add_node_selection(fg, box)\n",
-    "    add_node_labels(fg, box)\n",
-    "    add_node_colors(fg, box)\n",
-    "    add_link_colors(fg, box)\n",
-    "    add_link_labels(fg, box)\n",
+    "    for add_behavior in behaviors:\n",
+    "        add_behavior(fg, box)\n",
     "    return fg, box"
    ]
   },
diff --git a/examples/Behaviors.ipynb b/examples/Behaviors.ipynb
index 68da39fe..2079354a 100644
--- a/examples/Behaviors.ipynb
+++ b/examples/Behaviors.ipynb
@@ -33,7 +33,9 @@
     "import random\n",
     "\n",
     "import ipywidgets as W\n",
-    "import traitlets as T"
+    "import traitlets as T\n",
+    "\n",
+    "import ipyforcegraph.behaviors as B"
    ]
   },
   {
@@ -99,20 +101,17 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "from ipyforcegraph.behaviors import NodeSelection\n",
-    "\n",
-    "\n",
     "def add_node_selection(fg=None, box=None):\n",
     "    if fg is None:\n",
     "        fg, box = make_a_simple_example()\n",
-    "    selection = NodeSelection()\n",
-    "    fg.behaviors = [selection]\n",
+    "    selection = B.NodeSelection()\n",
     "    ui_selection = W.TagsInput(\n",
     "        placeholder=\"select some nodes\",\n",
     "        allowed_tags=sorted(fg.source.nodes.id),\n",
     "    )\n",
+    "    box.behaviors = {**box.behaviors, \"node_selection\": selection}\n",
+    "    box.node_ui = {**box.node_ui, \"node_selection\": ui_selection}\n",
     "    T.link((selection, \"selected\"), (ui_selection, \"value\"))\n",
-    "    box.children[1].children += (ui_selection,)\n",
     "    return fg, box"
    ]
   },
@@ -124,8 +123,7 @@
    "outputs": [],
    "source": [
     "if __name__ == \"__main__\":\n",
-    "    add_node_selection(fg, box)\n",
-    "    display(box)"
+    "    add_node_selection(fg, box)"
    ]
   },
   {
@@ -157,6 +155,37 @@
     "be used. Ensure `NodeColors` is in `behaviors` _after_ e.g. `NodeSelection`."
    ]
   },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "c7e5a586-9e26-4436-88ad-9012e1072e37",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def make_a_collapsible_picker(title, children):\n",
+    "    select = W.Dropdown(options=list(children), layout=dict(flex=\"0\"))\n",
+    "    ui = W.HBox([select], layout=dict(flex_wrap=\"wrap\"))\n",
+    "    T.dlink((select, \"value\"), (ui, \"children\"), lambda x: [select, *children[x]])\n",
+    "\n",
+    "    return W.Accordion([ui], titles=[title])"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "ba984087-3008-420f-a2fd-5b1e6fcdf8ae",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def make_random_color_series(fg, trait, column_name):\n",
+    "    df = getattr(fg.source, trait)\n",
+    "    digits = \"01234567abcdef\"\n",
+    "    df[column_name] = [\n",
+    "        \"#\" + \"\".join([random.choice(digits) for j in range(6)]) for i in range(len(df))\n",
+    "    ]\n",
+    "    fg.source.send_state(trait)"
+   ]
+  },
   {
    "cell_type": "code",
    "execution_count": null,
@@ -170,30 +199,33 @@
     "def add_node_colors(fg=None, box=None, column_name=\"color\"):\n",
     "    if fg is None:\n",
     "        fg, box = make_a_simple_example()\n",
+    "\n",
     "    colors = NodeColors(column_name=column_name)\n",
-    "    fg.behaviors = [*fg.behaviors, colors]\n",
+    "\n",
     "    if column_name not in fg.source.nodes:\n",
-    "        fg.source.nodes[column_name] = [\n",
-    "            \"#\" + \"\".join([random.choice(\"01234567abcdef\") for j in range(6)])\n",
-    "            for i in range(len(fg.source.nodes))\n",
-    "        ]\n",
-    "        fg.source.send_state(\"nodes\")\n",
+    "        make_random_color_series(fg, \"nodes\", column_name)\n",
     "    ui_color_column = W.Dropdown(\n",
     "        options=list(fg.source.nodes.columns), value=column_name\n",
     "    )\n",
-    "    ui_color_template = W.Textarea()\n",
+    "    ui_color_template = W.Textarea(layout=dict(max_width=\"100%\"))\n",
     "    ui_color_template_enabled = W.Checkbox(description=\"enabled?\")\n",
-    "    ui_colors = W.Accordion(\n",
-    "        [ui_color_column, W.VBox([ui_color_template_enabled, ui_color_template])],\n",
-    "        titles=[\"node color by column\", \"node color by template\"],\n",
-    "    )\n",
     "    T.link((ui_color_column, \"value\"), (colors, \"column_name\"))\n",
     "    T.dlink(\n",
     "        (ui_color_template, \"value\"),\n",
     "        (colors, \"template\"),\n",
     "        lambda x: x if ui_color_template_enabled.value else \"\",\n",
     "    )\n",
-    "    box.children[1].children += (ui_colors,)\n",
+    "\n",
+    "    ui_colors = make_a_collapsible_picker(\n",
+    "        \"node: color\",\n",
+    "        {\n",
+    "            \"off\": [],\n",
+    "            \"column\": [ui_color_column],\n",
+    "            \"template\": [W.VBox([ui_color_template_enabled, ui_color_template])],\n",
+    "        },\n",
+    "    )\n",
+    "    box.behaviors = {**box.behaviors, \"node_color\": colors}\n",
+    "    box.node_ui = {**box.node_ui, \"color\": ui_colors}\n",
     "    return fg, box"
    ]
   },
@@ -205,8 +237,7 @@
    "outputs": [],
    "source": [
     "if __name__ == \"__main__\":\n",
-    "    add_node_colors(fg, box)\n",
-    "    display(box)"
+    "    add_node_colors(fg, box)"
    ]
   },
   {
@@ -274,27 +305,29 @@
     "from ipyforcegraph.behaviors import NodeLabels\n",
     "\n",
     "\n",
-    "def add_node_labels(fg=None, box=None, column_name=\"id\"):\n",
-    "    if fg is None:\n",
-    "        fg, box = make_a_simple_example()\n",
+    "def add_node_labels(fg, box, column_name=\"id\"):\n",
     "    labels = NodeLabels()\n",
     "    ui_label_column = W.Dropdown(\n",
     "        options=list(fg.source.nodes.columns), value=column_name\n",
     "    )\n",
     "    ui_label_template = W.Textarea()\n",
     "    ui_label_template_enabled = W.Checkbox(description=\"enabled?\")\n",
-    "    ui_labels = W.Accordion(\n",
-    "        [ui_label_column, W.VBox([ui_label_template_enabled, ui_label_template])],\n",
-    "        titles=[\"node label by column\", \"node label by template\"],\n",
-    "    )\n",
     "    T.link((ui_label_column, \"value\"), (labels, \"column_name\"))\n",
     "    T.dlink(\n",
     "        (ui_label_template, \"value\"),\n",
     "        (labels, \"template\"),\n",
     "        lambda x: x if ui_label_template_enabled.value else \"\",\n",
     "    )\n",
-    "    box.children[1].children += (ui_labels,)\n",
-    "    fg.behaviors = [*fg.behaviors, labels]\n",
+    "    ui_labels = make_a_collapsible_picker(\n",
+    "        \"node: label\",\n",
+    "        {\n",
+    "            \"off\": [],\n",
+    "            \"column\": [ui_label_column],\n",
+    "            \"template\": [W.VBox([ui_label_template_enabled, ui_label_template])],\n",
+    "        },\n",
+    "    )\n",
+    "    box.behaviors = {**box.behaviors, \"node_label\": labels}\n",
+    "    box.node_ui = {**box.node_ui, \"label\": ui_labels}\n",
     "    return fg, box"
    ]
   },
@@ -306,8 +339,7 @@
    "outputs": [],
    "source": [
     "if __name__ == \"__main__\":\n",
-    "    add_node_labels(fg, box)\n",
-    "    display(box)"
+    "    add_node_labels(fg, box)"
    ]
   },
   {
@@ -347,6 +379,47 @@
     "## Link Behaviors"
    ]
   },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "a5aa1fe6-9829-4e9f-bee0-1f661936894d",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def make_link_behavior_with_ui(WidgetClass, label, column_name, is_color=False):\n",
+    "    def add_behavior(fg, box, column_name=column_name):\n",
+    "        behavior = WidgetClass(column_name=column_name)\n",
+    "        if is_color:\n",
+    "            if column_name not in fg.source.links:\n",
+    "                make_random_color_series(fg, \"links\", column_name)\n",
+    "        ui_column_name = W.Dropdown(\n",
+    "            options=list(fg.source.links.columns), value=column_name\n",
+    "        )\n",
+    "        ui_template = W.Textarea()\n",
+    "        ui_template_enabled = W.Checkbox(description=\"enabled?\")\n",
+    "        T.link((ui_column_name, \"value\"), (behavior, \"column_name\"))\n",
+    "        T.dlink(\n",
+    "            (ui_template, \"value\"),\n",
+    "            (behavior, \"template\"),\n",
+    "            lambda x: x if ui_template_enabled.value else \"\",\n",
+    "        )\n",
+    "\n",
+    "        ui = make_a_collapsible_picker(\n",
+    "            label,\n",
+    "            {\n",
+    "                \"off\": [],\n",
+    "                \"column\": [ui_column_name],\n",
+    "                \"template\": [W.VBox([ui_template_enabled, ui_template])],\n",
+    "            },\n",
+    "        )\n",
+    "\n",
+    "        box.behaviors = {**box.behaviors, label: behavior}\n",
+    "        box.link_ui = {**box.link_ui, label: ui}\n",
+    "        return fg, box\n",
+    "\n",
+    "    return add_behavior"
+   ]
+  },
   {
    "cell_type": "markdown",
    "id": "5ecec943-13b8-44e8-b207-6c4e498f81e8",
@@ -364,37 +437,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "from ipyforcegraph.behaviors import LinkColors\n",
-    "\n",
-    "\n",
-    "def add_link_colors(fg=None, box=None, column_name=\"color\"):\n",
-    "    if fg is None:\n",
-    "        fg, box = make_a_simple_example()\n",
-    "    colors = LinkColors(column_name=column_name)\n",
-    "    fg.behaviors = [*fg.behaviors, colors]\n",
-    "    if column_name not in fg.source.links:\n",
-    "        fg.source.links[column_name] = [\n",
-    "            \"#\" + \"\".join([random.choice(\"01234567abcdef\") for j in range(6)])\n",
-    "            for i in range(len(fg.source.links))\n",
-    "        ]\n",
-    "        fg.source.send_state(\"links\")\n",
-    "    ui_color_column = W.Dropdown(\n",
-    "        options=list(fg.source.links.columns), value=column_name\n",
-    "    )\n",
-    "    ui_color_template = W.Textarea()\n",
-    "    ui_color_template_enabled = W.Checkbox(description=\"enabled?\")\n",
-    "    ui_colors = W.Accordion(\n",
-    "        [ui_color_column, W.VBox([ui_color_template_enabled, ui_color_template])],\n",
-    "        titles=[\"link color by column\", \"link color by template\"],\n",
-    "    )\n",
-    "    T.link((ui_color_column, \"value\"), (colors, \"column_name\"))\n",
-    "    T.dlink(\n",
-    "        (ui_color_template, \"value\"),\n",
-    "        (colors, \"template\"),\n",
-    "        lambda x: x if ui_color_template_enabled.value else \"\",\n",
-    "    )\n",
-    "    box.children[1].children += (ui_colors,)\n",
-    "    return fg, box"
+    "add_link_colors = make_link_behavior_with_ui(B.LinkColors, \"link: color\", \"color\", True)"
    ]
   },
   {
@@ -405,8 +448,7 @@
    "outputs": [],
    "source": [
     "if __name__ == \"__main__\":\n",
-    "    add_link_colors(fg, box)\n",
-    "    display(box)"
+    "    add_link_colors(fg, box)"
    ]
   },
   {
@@ -422,47 +464,178 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "id": "a2d3d6b6-3ccf-422d-bbe8-e02f30482070",
+   "id": "8ed938f8-eb48-4b28-802b-cdd43e4191b6",
    "metadata": {},
    "outputs": [],
    "source": [
-    "from ipyforcegraph.behaviors import LinkLabels\n",
+    "add_link_labels = make_link_behavior_with_ui(B.LinkLabels, \"link: label\", \"value\")\n",
     "\n",
+    "if __name__ == \"__main__\":\n",
+    "    add_link_labels(fg, box)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "b0f76652-37e2-4547-a265-3dd5c4630b58",
+   "metadata": {},
+   "source": [
+    "### `LinkDirectionalArrowColor`"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "2435faa8-d542-46b5-b115-11156f9b6c46",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "add_link_directional_arrow_color = make_link_behavior_with_ui(\n",
+    "    B.LinkDirectionalArrowColor, \"link: directional arrow color\", \"color\", is_color=True\n",
+    ")\n",
     "\n",
-    "def add_link_labels(fg=None, box=None, column_name=\"value\"):\n",
-    "    if fg is None:\n",
-    "        fg, box = make_a_simple_example()\n",
-    "    labels = LinkLabels()\n",
-    "    ui_label_column = W.Dropdown(\n",
-    "        options=list(fg.source.links.columns), value=column_name\n",
-    "    )\n",
-    "    ui_label_template = W.Textarea()\n",
-    "    ui_label_template_enabled = W.Checkbox(description=\"enabled?\")\n",
-    "    ui_labels = W.Accordion(\n",
-    "        [ui_label_column, W.VBox([ui_label_template_enabled, ui_label_template])],\n",
-    "        titles=[\"link label by column\", \"link label by template\"],\n",
-    "    )\n",
-    "    T.link((ui_label_column, \"value\"), (labels, \"column_name\"))\n",
-    "    T.dlink(\n",
-    "        (ui_label_template, \"value\"),\n",
-    "        (labels, \"template\"),\n",
-    "        lambda x: x if ui_label_template_enabled.value else \"\",\n",
-    "    )\n",
-    "    box.children[1].children += (ui_labels,)\n",
-    "    fg.behaviors = [*fg.behaviors, labels]\n",
-    "    return fg, box"
+    "if __name__ == \"__main__\":\n",
+    "    add_link_directional_arrow_color(fg, box)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "3a13a031-1968-4459-8bf8-90c8e897dedf",
+   "metadata": {},
+   "source": [
+    "### `LinkDirectionalArrowLength`"
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
-   "id": "fc2187f9-0d88-4e9d-9a90-5e9681c5b1e5",
+   "id": "6e50a322-cbf6-4c9b-a59c-ec18d6285bb6",
    "metadata": {},
    "outputs": [],
    "source": [
+    "add_link_directional_arrow_length = make_link_behavior_with_ui(\n",
+    "    B.LinkDirectionalArrowLength, \"link: directional arrow length\", \"value\"\n",
+    ")\n",
+    "\n",
     "if __name__ == \"__main__\":\n",
-    "    add_link_labels(fg, box)\n",
-    "    display(box)"
+    "    add_link_directional_arrow_length(fg, box)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "61a5f29e-d2a0-40a9-bcfe-d721b2278ae4",
+   "metadata": {},
+   "source": [
+    "### `LinkDirectionalArrowRelPos`"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b0cc963f-dd99-40cd-9355-7bcb8a52033e",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "add_link_directional_arrow_rel_pos = make_link_behavior_with_ui(\n",
+    "    B.LinkDirectionalArrowRelPos, \"link: directional arrow relative position\", \"value\"\n",
+    ")\n",
+    "\n",
+    "if __name__ == \"__main__\":\n",
+    "    add_link_directional_arrow_rel_pos(fg, box)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "36668d7f-9912-4ddc-9471-4174b866b419",
+   "metadata": {},
+   "source": [
+    "### `LinkDirectionalParticleColor`"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "2839dfdc-80f8-481c-8571-9c66a457b850",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "add_link_directional_particle_color = make_link_behavior_with_ui(\n",
+    "    B.LinkDirectionalParticleColor,\n",
+    "    \"link: directional particle color\",\n",
+    "    \"color\",\n",
+    "    is_color=True,\n",
+    ")\n",
+    "\n",
+    "if __name__ == \"__main__\":\n",
+    "    add_link_directional_particle_color(fg, box)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "5b47d1e6-88fd-4e94-bff2-fcd2b0f3a52a",
+   "metadata": {},
+   "source": [
+    "### `LinkDirectionalParticleSpeed`"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "fb5713e5-47db-4b76-9f17-728f436d4084",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "add_link_directional_particle_speed = make_link_behavior_with_ui(\n",
+    "    B.LinkDirectionalParticleSpeed, \"link: directional particle speed\", \"value\"\n",
+    ")\n",
+    "\n",
+    "if __name__ == \"__main__\":\n",
+    "    add_link_directional_particle_speed(fg, box)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "69a92aa8-2537-4a0d-a4a5-7108e6ce7e8d",
+   "metadata": {},
+   "source": [
+    "### `LinkDirectionalParticleWidth`"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "23d53c2f-8ede-4e5b-a504-6fb25ccc3266",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "add_link_directional_particle_width = make_link_behavior_with_ui(\n",
+    "    B.LinkDirectionalParticleWidth, \"link: directional particle width\", \"value\"\n",
+    ")\n",
+    "\n",
+    "if __name__ == \"__main__\":\n",
+    "    add_link_directional_particle_width(fg, box)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "d4a5c279-ee46-4f6d-911e-de779fc162ff",
+   "metadata": {},
+   "source": [
+    "### `LinkDirectionalParticles`"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "f1e35a6e-3f23-47ef-b8ca-196c7c23feab",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "add_link_directional_particles = make_link_behavior_with_ui(\n",
+    "    B.LinkDirectionalParticles, \"link: directional particles\", \"value\"\n",
+    ")\n",
+    "\n",
+    "if __name__ == \"__main__\":\n",
+    "    add_link_directional_particles(fg, box)"
    ]
   }
  ],
diff --git a/examples/_index.ipynb b/examples/_index.ipynb
index f36ea2a8..e4793405 100644
--- a/examples/_index.ipynb
+++ b/examples/_index.ipynb
@@ -33,6 +33,7 @@
     "from pathlib import Path\n",
     "\n",
     "import ipywidgets as W\n",
+    "import traitlets as T\n",
     "\n",
     "from ipyforcegraph.forcegraph import DataFrameSource, ForceGraph"
    ]
@@ -57,14 +58,26 @@
     "        data = json.loads(Path(dataset).read_text())\n",
     "        source = DataFrameSource(**data)\n",
     "    fg = GraphClass(source=source, layout=dict(min_height=\"500px\"))\n",
-    "    box = W.HBox(\n",
-    "        [\n",
-    "            fg,\n",
-    "            W.VBox([], layout=dict(width=\"300px\")),\n",
-    "            W.HTML(\"\"\"<style>.widget-box:empty{display:none;}</style>\"\"\"),\n",
-    "        ],\n",
-    "        layout=dict(height=\"100%\"),\n",
+    "    style = W.HTML(\n",
+    "        \"\"\"<style>\n",
+    "        .jp-fg-demo{\n",
+    "            --jp-widgets-container-padding: 0.25em;\n",
+    "            --jp-widgets-inline-width: auto;\n",
+    "        } \n",
+    "        .widget-box:empty{display:none;}\n",
+    "    </style>\"\"\"\n",
     "    )\n",
+    "    node_ui = W.VBox()\n",
+    "    link_ui = W.VBox()\n",
+    "    ui = W.VBox([node_ui, link_ui], layout=dict(width=\"500px\", overflow_y=\"scroll\"))\n",
+    "    box = W.HBox([style, fg, ui], layout=dict(height=\"100%\", max_height=\"100vh\"))\n",
+    "    box.add_class(\"jp-fg-demo\")\n",
+    "    box.add_traits(\n",
+    "        behaviors=T.Dict(), node_behaviors=T.Dict(), node_ui=T.Dict(), link_ui=T.Dict()\n",
+    "    )\n",
+    "    T.dlink((box, \"behaviors\"), (fg, \"behaviors\"), lambda d: tuple(d.values()))\n",
+    "    T.dlink((box, \"node_ui\"), (node_ui, \"children\"), lambda d: tuple(d.values()))\n",
+    "    T.dlink((box, \"link_ui\"), (link_ui, \"children\"), lambda d: tuple(d.values()))\n",
     "    return fg, box"
    ]
   },
diff --git a/examples/future/generators.ipynb b/examples/future/generators.ipynb
new file mode 100644
index 00000000..f8167280
--- /dev/null
+++ b/examples/future/generators.ipynb
@@ -0,0 +1,371 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "125cb78c-b1ba-47da-85b4-85a7d1b6dbb6",
+   "metadata": {},
+   "source": [
+    "# Generating new behaviors"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "112d0d1c-e01c-4a5d-b7c1-8ceb559b2456",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from jinja2 import Template\n",
+    "from pathlib import Path\n",
+    "import stringcase\n",
+    "import pandas as pd"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "9bb452fa-9e68-4136-bb46-9f69d59d9f0b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "HERE = Path.cwd()\n",
+    "ROOT = HERE.parent.parent\n",
+    "BEHAVIORS_PY = ROOT  / \"src/ipyforcegraph/behaviors.py\"\n",
+    "BEHAVIORS_TS = ROOT / \"js/widgets/behaviors\""
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "ffb9017c-e92a-4391-937c-a6b1620f138e",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "assert BEHAVIORS_TS.exists()\n",
+    "assert BEHAVIORS_PY.exists()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "aedee298-defc-4265-be33-ef359aee832e",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "py_cls_template = Template(\"\"\"\n",
+    "@W.register\n",
+    "class {{ py_class }}(Behavior):\n",
+    "    _model_name: str = T.Unicode(\"{{ js_class }}\").tag(sync=True)\n",
+    "\n",
+    "    column_name: str = T.Unicode(\n",
+    "        None,\n",
+    "        allow_none=True,\n",
+    "        help=\"name of the source column to use for {{ readable }}.\",\n",
+    "    ).tag(sync=True)\n",
+    "\n",
+    "    template: Optional[str] = T.Unicode(\n",
+    "        None,\n",
+    "        allow_none=True,\n",
+    "        help=\"a nunjucks template to use to calculate {{ readable }}\",\n",
+    "    ).tag(sync=True)\n",
+    "\"\"\".strip())"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "824a153f-ad78-44e6-bfae-2e5adda9fc9f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "js_class_filename = Template(\"\"\"{{ kebab }}.ts\"\"\") \n",
+    "js_class_template = Template(\"\"\"\n",
+    "/*\n",
+    " * Copyright (c) 2023 ipyforcegraph contributors.\n",
+    " * Distributed under the terms of the Modified BSD License.\n",
+    " */\n",
+    "import { IBehave, ILinkBehaveOptions } from '../../tokens';\n",
+    "\n",
+    "import { LinkColumnOrTemplateModel } from './base';\n",
+    "\n",
+    "export class {{ js_class }} extends LinkColumnOrTemplateModel implements IBehave {\n",
+    "  static model_name = '{{ js_class }}';\n",
+    "\n",
+    "  defaults() {\n",
+    "    return { ...super.defaults(), _model_name: {{ js_class }}.model_name };\n",
+    "  }\n",
+    "\n",
+    "  {{ api_method }}(options: ILinkBehaveOptions): string | null {\n",
+    "    return super.getLinkAttr(options);\n",
+    "  }\n",
+    "}\n",
+    "\"\"\".strip())"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "2ae50338-2865-4484-ad10-de7e10adb3b3",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "token_ts_template = Template(\"\"\"\n",
+    "  {{ api_method }}?(options: ILinkBehaveOptions): string | null;\n",
+    "\"\"\".strip())"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "555a779d-5cef-4a89-b931-8d4bacd18a10",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "two_d_ts_post_update_template = Template(\"\"\"\n",
+    "graph.{{ raw_attr }}(this.wrapFunction(this.{{ api_method }}));\n",
+    "\"\"\".strip())"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "fd4e72f7-79c6-43bc-a051-40ccb79242e1",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "two_d_ts_method_template = Template(\"\"\"\n",
+    "protected {{ api_method }} = (link: LinkObject): string => {\n",
+    "  return this.getComposedLinkAttr(link, '{{ api_method }}', '');\n",
+    "};\n",
+    "\"\"\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "ca6f092f-7fd6-4d69-8980-c08507fc4c95",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "index_ts_template = Template(\"\"\"\n",
+    "export * from './{{ kebab }}';\n",
+    "\"\"\".strip())"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "ba0dc927-3a9f-465c-865a-3f40230f5d9f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "nb_template = Template(\"\"\"\n",
+    "\n",
+    "### `{{ py_class }}`\n",
+    "\n",
+    "add_{{ snake }} = make_link_behavior_with_ui(B.{{ py_class }}, \"{{ readable }}\", \"{{ column_name }}\"{{ is_color }})\n",
+    "\n",
+    "if __name__ == \"__main__\":\n",
+    "    add_{{ snake }}(fg, box)\n",
+    "    display(box)\n",
+    "\n",
+    "\"\"\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "13997f9f-2985-43a7-b362-ffa77070e334",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "link_attrs = sorted(set(\"\"\"\n",
+    "linkDirectionalArrowLength\n",
+    "linkDirectionalArrowLength\n",
+    "linkDirectionalArrowColor\n",
+    "linkDirectionalArrowColor\n",
+    "linkDirectionalArrowRelPos\n",
+    "linkDirectionalArrowRelPos\n",
+    "linkDirectionalParticles\n",
+    "linkDirectionalParticles\n",
+    "linkDirectionalParticleSpeed\n",
+    "linkDirectionalParticleSpeed\n",
+    "linkDirectionalParticleWidth\n",
+    "linkDirectionalParticleWidth\n",
+    "linkDirectionalParticleColor\n",
+    "linkDirectionalParticleColor\n",
+    "\"\"\".strip().splitlines()))\n",
+    "link_attrs"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "0bf7c46a-0fce-481a-9d99-a8123adf2c0b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def template_one(raw_attr):\n",
+    "    context = dict(\n",
+    "        raw_attr=raw_attr,\n",
+    "        py_class=f\"\"\"{raw_attr[0].upper()}{raw_attr[1:]}\"\"\",\n",
+    "        readable=stringcase.sentencecase(raw_attr).lower().replace(\" rel \", \" relative \").replace(\" pos\", \" position\"),\n",
+    "        kebab=stringcase.spinalcase(raw_attr).lower(),\n",
+    "        snake=stringcase.snakecase(raw_attr).lower(),\n",
+    "        is_color=\", is_color=True\" if \"Color\" in raw_attr else \"\",\n",
+    "        column_name=\"color\" if \"Color\" in raw_attr else \"value\",\n",
+    "\n",
+    "    )\n",
+    "    context[\"js_class\"] = f\"\"\"{context[\"py_class\"]}Model\"\"\"\n",
+    "    context[\"api_method\"] = f\"\"\"get{context[\"py_class\"]}\"\"\"\n",
+    "\n",
+    "    result = {    \n",
+    "        **context,\n",
+    "        \"js_class_filename\": js_class_filename.render(**context),\n",
+    "        \"js_class\": js_class_template.render(**context),\n",
+    "        \"py_cls_frag\": py_cls_template.render(**context),\n",
+    "        \"tokens_ts\": token_ts_template.render(**context),\n",
+    "        \"two_d_ts_post_update\": two_d_ts_post_update_template.render(**context),\n",
+    "        \"two_d_ts_method\": two_d_ts_method_template.render(**context),\n",
+    "        \"index_ts\": index_ts_template.render(**context),\n",
+    "        \"nb\": nb_template.render(**context),\n",
+    "    }\n",
+    "    \n",
+    "    return result"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "ac46bf5b-b850-416f-aeb2-39856075289d",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "df = pd.DataFrame([template_one(raw_attr) for raw_attr in link_attrs])\n",
+    "df.T"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "9928f42a-b4d9-4b76-92bd-5d0a8be6d3bf",
+   "metadata": {},
+   "source": [
+    "write files"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "fdff54db-37a8-4107-8ab8-79bd86a20eab",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# df.apply(lambda x: (BEHAVIORS_TS / x.js_class_filename).write_text(x.js_class), axis=1)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "9b1ef94c-3f89-4bbb-a9f3-9738857a1b0b",
+   "metadata": {},
+   "source": [
+    "import files"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "f3534d43-2108-49bf-9056-aa182c101e89",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(\"// index.ts\\n\")\n",
+    "print(\"\".join(list(df.index_ts)))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "5f0cbc1b-ac37-4b2e-b170-dd051979ac84",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(\"// tokens.ts\\n\")\n",
+    "print(\"\\n\".join(list(df.tokens_ts)))\n",
+    "\n",
+    "print(\"' | '\".join(list(df.api_method)))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "77e82558-fd5a-4fc4-8034-cf420a5baf23",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(\"// 2d.ts\\n\")\n",
+    "print(\"\\n\".join(list(df.two_d_ts_method)))\n",
+    "\n",
+    "print(\"\\n\".join(list(df.two_d_ts_post_update)))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "269d0325-61cd-49d5-bc78-c11b827fe058",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(\"# behaviors.py\\n\")\n",
+    "print(\"\\n\\n\".join(list(df.py_cls_frag)))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "f6a1b031-2214-4d8d-8f05-7c0c76f9205f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(\"# Behaviors.ipynb\\n\")\n",
+    "print(\"\\n\\n\".join(list(df.nb)))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "3184d3a4-0655-4981-812e-f5c220bd091b",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "8956b688-8b58-4f51-9292-5d95bd6bf7c3",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.11.0"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/js/tokens.ts b/js/tokens.ts
index 37d245da..9c821b63 100644
--- a/js/tokens.ts
+++ b/js/tokens.ts
@@ -45,8 +45,17 @@ export const WIDGET_DEFAULTS = {
 
 export interface IBehave {
   updateRequested: ISignal<IBehave, void>;
+  // link
   getLinkColor?(options: ILinkBehaveOptions): string | null;
   getLinkLabel?(options: ILinkBehaveOptions): string | null;
+  getLinkDirectionalArrowColor?(options: ILinkBehaveOptions): string | null;
+  getLinkDirectionalArrowLength?(options: ILinkBehaveOptions): string | null;
+  getLinkDirectionalArrowRelPos?(options: ILinkBehaveOptions): string | null;
+  getLinkDirectionalParticleColor?(options: ILinkBehaveOptions): string | null;
+  getLinkDirectionalParticleSpeed?(options: ILinkBehaveOptions): string | null;
+  getLinkDirectionalParticleWidth?(options: ILinkBehaveOptions): string | null;
+  getLinkDirectionalParticles?(options: ILinkBehaveOptions): string | null;
+  // node
   getNodeColor?(options: INodeBehaveOptions): string | null;
   getNodeLabel?(options: INodeBehaveOptions): string | null;
   // evented
@@ -54,7 +63,16 @@ export interface IBehave {
 }
 
 export type TNodeBehaveMethod = 'getNodeLabel' | 'getNodeColor';
-export type TLinkBehaveMethod = 'getLinkLabel' | 'getLinkColor';
+export type TLinkBehaveMethod =
+  | 'getLinkLabel'
+  | 'getLinkColor'
+  | 'getLinkDirectionalArrowColor'
+  | 'getLinkDirectionalArrowLength'
+  | 'getLinkDirectionalArrowRelPos'
+  | 'getLinkDirectionalParticleColor'
+  | 'getLinkDirectionalParticleSpeed'
+  | 'getLinkDirectionalParticleWidth'
+  | 'getLinkDirectionalParticles';
 
 export interface IBehaveOptions {
   view: IHasGraph;
diff --git a/js/widgets/behaviors/index.ts b/js/widgets/behaviors/index.ts
index 350884d4..7a474328 100644
--- a/js/widgets/behaviors/index.ts
+++ b/js/widgets/behaviors/index.ts
@@ -2,8 +2,16 @@
  * Copyright (c) 2023 ipyforcegraph contributors.
  * Distributed under the terms of the Modified BSD License.
  */
+
 export * from './base';
 export * from './link-color';
+export * from './link-directional-arrow-color';
+export * from './link-directional-arrow-length';
+export * from './link-directional-arrow-rel-pos';
+export * from './link-directional-particle-color';
+export * from './link-directional-particle-speed';
+export * from './link-directional-particle-width';
+export * from './link-directional-particles';
 export * from './link-label';
 export * from './node-color';
 export * from './node-label';
diff --git a/js/widgets/behaviors/link-color.ts b/js/widgets/behaviors/link-color.ts
index 07b251f7..db0605fc 100644
--- a/js/widgets/behaviors/link-color.ts
+++ b/js/widgets/behaviors/link-color.ts
@@ -7,7 +7,7 @@ import { IBehave, ILinkBehaveOptions } from '../../tokens';
 import { LinkColumnOrTemplateModel } from './base';
 
 export class LinkColorModel extends LinkColumnOrTemplateModel implements IBehave {
-  static model_name = 'NodeColorModel';
+  static model_name = 'LinkColorModel';
 
   defaults() {
     return { ...super.defaults(), _model_name: LinkColorModel.model_name };
diff --git a/js/widgets/behaviors/link-directional-arrow-color.ts b/js/widgets/behaviors/link-directional-arrow-color.ts
new file mode 100644
index 00000000..44cbf5fe
--- /dev/null
+++ b/js/widgets/behaviors/link-directional-arrow-color.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import { IBehave, ILinkBehaveOptions } from '../../tokens';
+
+import { LinkColumnOrTemplateModel } from './base';
+
+export class LinkDirectionalArrowColorModel
+  extends LinkColumnOrTemplateModel
+  implements IBehave
+{
+  static model_name = 'LinkDirectionalArrowColorModel';
+
+  defaults() {
+    return {
+      ...super.defaults(),
+      _model_name: LinkDirectionalArrowColorModel.model_name,
+    };
+  }
+
+  getLinkDirectionalArrowColor(options: ILinkBehaveOptions): string | null {
+    return super.getLinkAttr(options);
+  }
+}
diff --git a/js/widgets/behaviors/link-directional-arrow-length.ts b/js/widgets/behaviors/link-directional-arrow-length.ts
new file mode 100644
index 00000000..e1d177a9
--- /dev/null
+++ b/js/widgets/behaviors/link-directional-arrow-length.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import { IBehave, ILinkBehaveOptions } from '../../tokens';
+
+import { LinkColumnOrTemplateModel } from './base';
+
+export class LinkDirectionalArrowLengthModel
+  extends LinkColumnOrTemplateModel
+  implements IBehave
+{
+  static model_name = 'LinkDirectionalArrowLengthModel';
+
+  defaults() {
+    return {
+      ...super.defaults(),
+      _model_name: LinkDirectionalArrowLengthModel.model_name,
+    };
+  }
+
+  getLinkDirectionalArrowLength(options: ILinkBehaveOptions): string | null {
+    return super.getLinkAttr(options);
+  }
+}
diff --git a/js/widgets/behaviors/link-directional-arrow-rel-pos.ts b/js/widgets/behaviors/link-directional-arrow-rel-pos.ts
new file mode 100644
index 00000000..009aed49
--- /dev/null
+++ b/js/widgets/behaviors/link-directional-arrow-rel-pos.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import { IBehave, ILinkBehaveOptions } from '../../tokens';
+
+import { LinkColumnOrTemplateModel } from './base';
+
+export class LinkDirectionalArrowRelPosModel
+  extends LinkColumnOrTemplateModel
+  implements IBehave
+{
+  static model_name = 'LinkDirectionalArrowRelPosModel';
+
+  defaults() {
+    return {
+      ...super.defaults(),
+      _model_name: LinkDirectionalArrowRelPosModel.model_name,
+    };
+  }
+
+  getLinkDirectionalArrowRelPos(options: ILinkBehaveOptions): string | null {
+    return super.getLinkAttr(options);
+  }
+}
diff --git a/js/widgets/behaviors/link-directional-particle-color.ts b/js/widgets/behaviors/link-directional-particle-color.ts
new file mode 100644
index 00000000..4060afe0
--- /dev/null
+++ b/js/widgets/behaviors/link-directional-particle-color.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import { IBehave, ILinkBehaveOptions } from '../../tokens';
+
+import { LinkColumnOrTemplateModel } from './base';
+
+export class LinkDirectionalParticleColorModel
+  extends LinkColumnOrTemplateModel
+  implements IBehave
+{
+  static model_name = 'LinkDirectionalParticleColorModel';
+
+  defaults() {
+    return {
+      ...super.defaults(),
+      _model_name: LinkDirectionalParticleColorModel.model_name,
+    };
+  }
+
+  getLinkDirectionalParticleColor(options: ILinkBehaveOptions): string | null {
+    return super.getLinkAttr(options);
+  }
+}
diff --git a/js/widgets/behaviors/link-directional-particle-speed.ts b/js/widgets/behaviors/link-directional-particle-speed.ts
new file mode 100644
index 00000000..33dceaf9
--- /dev/null
+++ b/js/widgets/behaviors/link-directional-particle-speed.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import { IBehave, ILinkBehaveOptions } from '../../tokens';
+
+import { LinkColumnOrTemplateModel } from './base';
+
+export class LinkDirectionalParticleSpeedModel
+  extends LinkColumnOrTemplateModel
+  implements IBehave
+{
+  static model_name = 'LinkDirectionalParticleSpeedModel';
+
+  defaults() {
+    return {
+      ...super.defaults(),
+      _model_name: LinkDirectionalParticleSpeedModel.model_name,
+    };
+  }
+
+  getLinkDirectionalParticleSpeed(options: ILinkBehaveOptions): string | null {
+    return super.getLinkAttr(options);
+  }
+}
diff --git a/js/widgets/behaviors/link-directional-particle-width.ts b/js/widgets/behaviors/link-directional-particle-width.ts
new file mode 100644
index 00000000..d185824f
--- /dev/null
+++ b/js/widgets/behaviors/link-directional-particle-width.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import { IBehave, ILinkBehaveOptions } from '../../tokens';
+
+import { LinkColumnOrTemplateModel } from './base';
+
+export class LinkDirectionalParticleWidthModel
+  extends LinkColumnOrTemplateModel
+  implements IBehave
+{
+  static model_name = 'LinkDirectionalParticleWidthModel';
+
+  defaults() {
+    return {
+      ...super.defaults(),
+      _model_name: LinkDirectionalParticleWidthModel.model_name,
+    };
+  }
+
+  getLinkDirectionalParticleWidth(options: ILinkBehaveOptions): string | null {
+    return super.getLinkAttr(options);
+  }
+}
diff --git a/js/widgets/behaviors/link-directional-particles.ts b/js/widgets/behaviors/link-directional-particles.ts
new file mode 100644
index 00000000..9b5f2736
--- /dev/null
+++ b/js/widgets/behaviors/link-directional-particles.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2023 ipyforcegraph contributors.
+ * Distributed under the terms of the Modified BSD License.
+ */
+import { IBehave, ILinkBehaveOptions } from '../../tokens';
+
+import { LinkColumnOrTemplateModel } from './base';
+
+export class LinkDirectionalParticlesModel
+  extends LinkColumnOrTemplateModel
+  implements IBehave
+{
+  static model_name = 'LinkDirectionalParticlesModel';
+
+  defaults() {
+    return {
+      ...super.defaults(),
+      _model_name: LinkDirectionalParticlesModel.model_name,
+    };
+  }
+
+  getLinkDirectionalParticles(options: ILinkBehaveOptions): string | null {
+    return super.getLinkAttr(options);
+  }
+}
diff --git a/js/widgets/behaviors/link-label.ts b/js/widgets/behaviors/link-label.ts
index 6f349875..e373ec1f 100644
--- a/js/widgets/behaviors/link-label.ts
+++ b/js/widgets/behaviors/link-label.ts
@@ -2,21 +2,17 @@
  * Copyright (c) 2023 ipyforcegraph contributors.
  * Distributed under the terms of the Modified BSD License.
  */
-import { IBehave, IHasGraph, ILinkBehaveOptions } from '../../tokens';
+import { IBehave, ILinkBehaveOptions } from '../../tokens';
 
 import { LinkColumnOrTemplateModel } from './base';
 
 export class LinkLabelModel extends LinkColumnOrTemplateModel implements IBehave {
-  static model_name = 'NodeLabelModel';
+  static model_name = 'LinkLabelModel';
 
   defaults() {
     return { ...super.defaults(), _model_name: LinkLabelModel.model_name };
   }
 
-  columnName(hasGraph: IHasGraph): string | null {
-    return super.getColumnName(hasGraph);
-  }
-
   getLinkLabel(options: ILinkBehaveOptions): string | null {
     return super.getLinkAttr(options);
   }
diff --git a/js/widgets/display/2d.ts b/js/widgets/display/2d.ts
index 954c43d6..ff429597 100644
--- a/js/widgets/display/2d.ts
+++ b/js/widgets/display/2d.ts
@@ -218,11 +218,37 @@ export class ForceGraphView<T = ForceGraphGenericInstance<ForceGraphInstance>>
     await this.rendered;
     const graph = this.graph as ForceGraphInstance;
     if (graph) {
+      // link
       graph.linkColor(this.wrapFunction(this.getLinkColor));
       graph.linkLabel(this.wrapFunction(this.getLinkLabel));
 
+      graph.linkDirectionalArrowColor(
+        this.wrapFunction(this.getLinkDirectionalArrowColor)
+      );
+      graph.linkDirectionalArrowLength(
+        this.wrapFunction(this.getLinkDirectionalArrowLength)
+      );
+      graph.linkDirectionalArrowRelPos(
+        this.wrapFunction(this.getLinkDirectionalArrowRelPos)
+      );
+      graph.linkDirectionalParticleColor(
+        this.wrapFunction(this.getLinkDirectionalParticleColor)
+      );
+      graph.linkDirectionalParticleSpeed(
+        this.wrapFunction(this.getLinkDirectionalParticleSpeed)
+      );
+      graph.linkDirectionalParticleWidth(
+        this.wrapFunction(this.getLinkDirectionalParticleWidth)
+      );
+      graph.linkDirectionalParticles(
+        this.wrapFunction(this.getLinkDirectionalParticles)
+      );
+
+      // node
       graph.nodeColor(this.wrapFunction(this.getNodeColor));
       graph.nodeLabel(this.wrapFunction(this.getNodeLabel));
+
+      // evented
       graph.onNodeClick(this.wrapFunction(this.onNodeClick));
     } else {
       console.warn(`${EMOJI} no graph to postUpdate`);
@@ -248,6 +274,53 @@ export class ForceGraphView<T = ForceGraphGenericInstance<ForceGraphInstance>>
     return this.getComposedLinkAttr(link, 'getLinkLabel', '');
   };
 
+  protected getLinkDirectionalArrowColor = (link: LinkObject): string => {
+    return this.getComposedLinkAttr(link, 'getLinkDirectionalArrowColor', '');
+  };
+
+  protected getLinkDirectionalArrowLength = (link: LinkObject): string => {
+    return this.castToNumber(
+      this.getComposedLinkAttr(link, 'getLinkDirectionalArrowLength', '')
+    );
+  };
+
+  protected getLinkDirectionalArrowRelPos = (link: LinkObject): string => {
+    return this.castToNumber(
+      this.getComposedLinkAttr(link, 'getLinkDirectionalArrowRelPos', '')
+    );
+  };
+
+  protected getLinkDirectionalParticleColor = (link: LinkObject): string => {
+    return this.getComposedLinkAttr(link, 'getLinkDirectionalParticleColor', '');
+  };
+
+  protected getLinkDirectionalParticleSpeed = (link: LinkObject): string => {
+    return this.castToNumber(
+      this.getComposedLinkAttr(link, 'getLinkDirectionalParticleSpeed', '')
+    );
+  };
+
+  protected getLinkDirectionalParticleWidth = (link: LinkObject): string => {
+    return this.castToNumber(
+      this.getComposedLinkAttr(link, 'getLinkDirectionalParticleWidth', '')
+    );
+  };
+
+  protected getLinkDirectionalParticles = (link: LinkObject): string => {
+    return this.castToNumber(
+      this.getComposedLinkAttr(link, 'getLinkDirectionalParticles', '')
+    );
+  };
+
+  protected castToNumber(value: string | number | null): any {
+    if (value == null) {
+      return value as any;
+    } else if (typeof value == 'string') {
+      return parseFloat(value);
+    }
+    return value;
+  }
+
   getComposedLinkAttr(
     link: LinkObject,
     methodName: TLinkBehaveMethod,
diff --git a/scripts/project.py b/scripts/project.py
index f2ec6a03..68204224 100644
--- a/scripts/project.py
+++ b/scripts/project.py
@@ -229,7 +229,7 @@ def _get_boolish(name, default="false"):
 
 HTMLCOV = BUILD / "htmlcov"
 HTMLCOV_INDEX = HTMLCOV / "index.html"
-PYTEST_COV_THRESHOLD = 10
+PYTEST_COV_THRESHOLD = 87
 PYTEST_HTML = BUILD / "pytest.html"
 PYTEST_XUNIT = BUILD / "pytest.xunit.xml"
 
diff --git a/src/ipyforcegraph/_base.py b/src/ipyforcegraph/_base.py
index 245da2fd..48f6895e 100644
--- a/src/ipyforcegraph/_base.py
+++ b/src/ipyforcegraph/_base.py
@@ -9,6 +9,7 @@
 
 
 class ForceBase(W.Widget):
+    _model_name: str = T.Unicode("ForceBaseModel").tag(sync=True)
     _model_module: str = T.Unicode(EXTENSION_NAME).tag(sync=True)
     _model_module_version: str = T.Unicode(EXTENSION_SPEC_VERSION).tag(sync=True)
     _view_module: str = T.Unicode(EXTENSION_NAME).tag(sync=True)
@@ -16,4 +17,4 @@ class ForceBase(W.Widget):
 
 
 class Behavior(ForceBase):
-    pass
+    _model_name: str = T.Unicode("BehaviorModel").tag(sync=True)
diff --git a/src/ipyforcegraph/behaviors.py b/src/ipyforcegraph/behaviors.py
index 7fbf3b71..49a67270 100644
--- a/src/ipyforcegraph/behaviors.py
+++ b/src/ipyforcegraph/behaviors.py
@@ -102,3 +102,122 @@ class LinkLabels(Behavior):
         allow_none=True,
         help="a nunjucks template to use to calculate link labels",
     ).tag(sync=True)
+
+
+@W.register
+class LinkDirectionalArrowColor(Behavior):
+    _model_name: str = T.Unicode("LinkDirectionalArrowColorModel").tag(sync=True)
+
+    column_name: str = T.Unicode(
+        None,
+        allow_none=True,
+        help="name of the source column to use for link directional arrow color.",
+    ).tag(sync=True)
+
+    template: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate link directional arrow color",
+    ).tag(sync=True)
+
+
+@W.register
+class LinkDirectionalArrowLength(Behavior):
+    _model_name: str = T.Unicode("LinkDirectionalArrowLengthModel").tag(sync=True)
+
+    column_name: str = T.Unicode(
+        None,
+        allow_none=True,
+        help="name of the source column to use for link directional arrow length.",
+    ).tag(sync=True)
+
+    template: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate link directional arrow length",
+    ).tag(sync=True)
+
+
+@W.register
+class LinkDirectionalArrowRelPos(Behavior):
+    _model_name: str = T.Unicode("LinkDirectionalArrowRelPosModel").tag(sync=True)
+
+    column_name: str = T.Unicode(
+        None,
+        allow_none=True,
+        help="name of the source column to use for link directional arrow relative position.",
+    ).tag(sync=True)
+
+    template: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate link directional arrow relative position",
+    ).tag(sync=True)
+
+
+@W.register
+class LinkDirectionalParticleColor(Behavior):
+    _model_name: str = T.Unicode("LinkDirectionalParticleColorModel").tag(sync=True)
+
+    column_name: str = T.Unicode(
+        None,
+        allow_none=True,
+        help="name of the source column to use for link directional particle color.",
+    ).tag(sync=True)
+
+    template: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate link directional particle color",
+    ).tag(sync=True)
+
+
+@W.register
+class LinkDirectionalParticleSpeed(Behavior):
+    _model_name: str = T.Unicode("LinkDirectionalParticleSpeedModel").tag(sync=True)
+
+    column_name: str = T.Unicode(
+        None,
+        allow_none=True,
+        help="name of the source column to use for link directional particle speed.",
+    ).tag(sync=True)
+
+    template: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate link directional particle speed",
+    ).tag(sync=True)
+
+
+@W.register
+class LinkDirectionalParticleWidth(Behavior):
+    _model_name: str = T.Unicode("LinkDirectionalParticleWidthModel").tag(sync=True)
+
+    column_name: str = T.Unicode(
+        None,
+        allow_none=True,
+        help="name of the source column to use for link directional particle width.",
+    ).tag(sync=True)
+
+    template: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate link directional particle width",
+    ).tag(sync=True)
+
+
+@W.register
+class LinkDirectionalParticles(Behavior):
+    _model_name: str = T.Unicode("LinkDirectionalParticlesModel").tag(sync=True)
+
+    column_name: str = T.Unicode(
+        None,
+        allow_none=True,
+        help="name of the source column to use for link directional particles.",
+    ).tag(sync=True)
+
+    template: Optional[str] = T.Unicode(
+        None,
+        allow_none=True,
+        help="a nunjucks template to use to calculate link directional particles",
+    ).tag(sync=True)
diff --git a/src/ipyforcegraph/constants.py b/src/ipyforcegraph/constants.py
index 51133cf9..91266abd 100644
--- a/src/ipyforcegraph/constants.py
+++ b/src/ipyforcegraph/constants.py
@@ -3,9 +3,9 @@
 # Copyright (c) 2023 ipyforcegraph contributors.
 # Distributed under the terms of the Modified BSD License.
 
-try:
+try:  # pragma: no cover
     from importlib.metadata import version
-except:
+except:  # pragma: no cover
     from importlib_metadata import version
 
 NAME = "ipyforcegraph"


Test patch:
diff --git a/atest/Notebooks/01_Index.robot b/atest/Notebooks/01_Index.robot
new file mode 100644
index 00000000..32844f15
--- /dev/null
+++ b/atest/Notebooks/01_Index.robot
@@ -0,0 +1,17 @@
+*** Settings ***
+Resource            ../_resources/keywords/Browser.robot
+Resource            ../_resources/keywords/Lab.robot
+Resource            ../_resources/keywords/IPyForceGraph.robot
+Library             Collections
+
+Test Teardown       Clean up after IPyForceGraph Example
+
+
+*** Variables ***
+${SCREENS}      ${SCREENS ROOT}${/}index
+
+
+*** Test Cases ***
+Index
+    [Tags]    data:miserables    lib:force-graph
+    Example Should Restart-and-Run-All    ${INDEX}
diff --git a/atest/Notebooks/02_Behaviors.robot b/atest/Notebooks/02_Behaviors.robot
new file mode 100644
index 00000000..007217ce
--- /dev/null
+++ b/atest/Notebooks/02_Behaviors.robot
@@ -0,0 +1,17 @@
+*** Settings ***
+Resource            ../_resources/keywords/Browser.robot
+Resource            ../_resources/keywords/Lab.robot
+Resource            ../_resources/keywords/IPyForceGraph.robot
+Library             Collections
+
+Test Teardown       Clean up after IPyForceGraph Example
+
+
+*** Variables ***
+${SCREENS}      ${SCREENS ROOT}${/}behaviors
+
+
+*** Test Cases ***
+Behaviors
+    [Tags]    data:miserables    lib:force-graph
+    Example Should Restart-and-Run-All    ${BEHAVIORS}
diff --git a/atest/Notebooks/03_3D.robot b/atest/Notebooks/03_3D.robot
new file mode 100644
index 00000000..f1a3e576
--- /dev/null
+++ b/atest/Notebooks/03_3D.robot
@@ -0,0 +1,17 @@
+*** Settings ***
+Resource            ../_resources/keywords/Browser.robot
+Resource            ../_resources/keywords/Lab.robot
+Resource            ../_resources/keywords/IPyForceGraph.robot
+Library             Collections
+
+Test Teardown       Clean up after IPyForceGraph Example
+
+
+*** Variables ***
+${SCREENS}      ${SCREENS ROOT}${/}3d
+
+
+*** Test Cases ***
+3D
+    [Tags]    data:miserables    lib:3d-force-graph
+    Example Should Restart-and-Run-All    ${THREEDEE}
diff --git a/atest/Notebooks/Examples.robot b/atest/Notebooks/Examples.robot
deleted file mode 100644
index 37abfea2..00000000
--- a/atest/Notebooks/Examples.robot
+++ /dev/null
@@ -1,10 +0,0 @@
-*** Settings ***
-Resource            ../_resources/keywords/Browser.robot
-Resource            ../_resources/keywords/Lab.robot
-Library             Collections
-
-Test Teardown       Clean up after IPyForcegraph Example
-
-
-*** Variables ***
-${SCREENS}      ${SCREENS ROOT}${/}notebook-examples
diff --git a/atest/_resources/fixtures/jupyter_notebook_config.json b/atest/_resources/fixtures/jupyter_config.json
similarity index 100%
rename from atest/_resources/fixtures/jupyter_notebook_config.json
rename to atest/_resources/fixtures/jupyter_config.json
diff --git a/atest/_resources/keywords/IPyForceGraph.robot b/atest/_resources/keywords/IPyForceGraph.robot
new file mode 100644
index 00000000..e9c588ff
--- /dev/null
+++ b/atest/_resources/keywords/IPyForceGraph.robot
@@ -0,0 +1,72 @@
+*** Settings ***
+Library     Collections
+Library     XML    WITH NAME    XML
+Library     OperatingSystem
+Resource    ../variables/IPyForceGraph.robot
+
+
+*** Keywords ***
+Get All IPyForceGraph Example File Names
+    ${file names} =    List Files in Directory    ${IPYFORCEGRAPH_EXAMPLES}
+    RETURN    ${file names}
+
+Get All IPyForceGraph Example Data Names
+    ${file names} =    List Files in Directory    ${IPYFORCEGRAPH_EXAMPLES}${/}datasets
+    RETURN    ${file names}
+
+Get All IPyForceGraph Example Paths
+    ${file names} =    Get All IPyForceGraph Example File Names
+    ${paths} =    Create List
+    FOR    ${file}    IN    @{file names}
+        Append To List    ${paths}    ${IPYFORCEGRAPH_EXAMPLES}${/}${file}
+    END
+    RETURN    ${paths}
+
+Get All IPyForceGraph Data Paths
+    ${file names} =    Get All IPyForceGraph Example Data Names
+    ${paths} =    Create List
+    FOR    ${file}    IN    @{file names}
+        Append To List    ${paths}    ${IPYFORCEGRAPH_EXAMPLES}${/}datasets${/}${file}
+    END
+    RETURN    ${paths}
+
+Open IPyForceGraph Notebook
+    [Arguments]    ${notebook}    ${path}=${IPYFORCEGRAPH_EXAMPLES}
+    Set Tags    notebook:${notebook}
+    ${full path} =    Normalize Path    ${path}${/}${notebook}.ipynb
+    File Should Exist    ${full path}
+    ${files} =    Get All IPyForceGraph Example Paths
+    Copy Support Files    ${files}
+    ${files} =    Get All IPyForceGraph Data Paths
+    Copy Support Files    ${files}    ${/}datasets${/}
+    Open File    ${full path}    ${MENU NOTEBOOK}
+    Wait Until Page Contains Element    ${JLAB XP KERNEL IDLE}    timeout=30s
+    Lab Command    Clear All Outputs
+    Ensure Sidebar Is Closed
+    Capture Page Screenshot    01-loaded.png
+
+Copy Support Files
+    [Arguments]    ${paths}    ${target}=${/}
+    FOR    ${path}    IN    @{paths}
+        ${parent}    ${name} =    Split Path    ${path}
+        Copy File    ${path}    ${OUTPUT DIR}${/}home${target}${name}
+    END
+
+Example Should Restart-and-Run-All
+    [Arguments]    ${example}
+    Set Screenshot Directory    ${SCREENS}${/}${example.lower()}
+    Open IPyForceGraph Notebook    ${example}
+    # nothing should be on the page, yet
+    Restart and Run All
+    Wait Until Page Contains Element    ${IPYFORCEGRAPH GRAPH}    timeout=60s
+    Sleep    5s
+    Capture All Code Cells
+    Page Should Not Contain Standard Errors
+    Capture Page Screenshot    10-ran-all-without-stderr.png
+
+Clean up after IPyForceGraph Example
+    Capture Page Screenshot    99-fin.png
+    ${files} =    Get All IPyForceGraph Example File Names
+    ${data} =    Get All IPyForceGraph Example Data Names
+    Clean up after Working With Files    @{files}
+    Clean up after Working With Files    @{data}
diff --git a/atest/_resources/keywords/Lab.robot b/atest/_resources/keywords/Lab.robot
index 7cccf64e..89a93ac1 100644
--- a/atest/_resources/keywords/Lab.robot
+++ b/atest/_resources/keywords/Lab.robot
@@ -176,7 +176,8 @@ Close JupyterLab
     Close All Browsers
 
 Open Command Palette
-    Press Keys    id:main    ${ACCEL}+SHIFT+c
+    ${open} =    Get WebElements    css:.lm-mod-current ${CMD PALETTE ICON}
+    IF    not ${open.__len__()}    Click Element    css:${CMD PALETTE ICON}
     Wait Until Element is Visible    ${CMD PALETTE INPUT}
     Wait Until Keyword Succeeds    3x    1s    Click Element    ${CMD PALETTE INPUT}
 
@@ -235,7 +236,7 @@ Ensure All Kernels Are Shut Down
     IF    ${els.__len__()}    Wait Until Page Contains Element    ${accept}
     IF    ${els.__len__()}    Click Element    ${accept}
 
-Page Should Not Contain Contain Standard Errors
+Page Should Not Contain Standard Errors
     [Arguments]    ${prefix}=${EMPTY}    ${exceptions}=${None}
     ${errors} =    Get WebElements    ${JLAB XP STDERR}
     FOR    ${idx}    ${error}    IN ENUMERATE    @{errors}
diff --git a/atest/_resources/keywords/Server.robot b/atest/_resources/keywords/Server.robot
index c12695ce..6b8eca1b 100644
--- a/atest/_resources/keywords/Server.robot
+++ b/atest/_resources/keywords/Server.robot
@@ -56,6 +56,7 @@ Create Notebok Server Config
     [Documentation]    Copies in notebook server config file to disables npm/build checks
     [Arguments]    ${home}
     Copy File    ${FIXTURES}${/}${NBSERVER CONF}    ${home}${/}${NBSERVER CONF}
+    Set Environment Variable    JUPYTER_CONFIG_DIR    ${home}
 
 Initialize User Settings
     [Documentation]    Configure the settings directory, and modify settings that make tests less reproducible
diff --git a/atest/_resources/variables/IPyForceGraph.robot b/atest/_resources/variables/IPyForceGraph.robot
new file mode 100644
index 00000000..85409688
--- /dev/null
+++ b/atest/_resources/variables/IPyForceGraph.robot
@@ -0,0 +1,6 @@
+*** Variables ***
+${INDEX}                    _index
+${BEHAVIORS}                Behaviors
+${THREEDEE}                 3D
+
+${IPYFORCEGRAPH GRAPH}      css:.jp-ForceGraph
diff --git a/atest/_resources/variables/Lab.robot b/atest/_resources/variables/Lab.robot
index f537f73b..a1d524b3 100644
--- a/atest/_resources/variables/Lab.robot
+++ b/atest/_resources/variables/Lab.robot
@@ -1,5 +1,6 @@
 *** Variables ***
 ${SPLASH}                       id:jupyterlab-splash
+${CMD PALETTE ICON}             svg[data-icon\="ui-components:palette"]
 ${CMD PALETTE INPUT}            css:#command-palette .p-CommandPalette-input
 ${CMD PALETTE ITEM ACTIVE}      css:#command-palette .p-CommandPalette-item.p-mod-active
 ${JLAB XP TOP}                  //div[@id='jp-top-panel']
diff --git a/atest/_resources/variables/Server.robot b/atest/_resources/variables/Server.robot
index d44a1058..1d496ad8 100644
--- a/atest/_resources/variables/Server.robot
+++ b/atest/_resources/variables/Server.robot
@@ -2,5 +2,5 @@
 # to help catch hard-coded paths
 ${URL PREFIX}       /@est/
 ${FIXTURES}         ${CURDIR}${/}..${/}fixtures
-${NBSERVER CONF}    jupyter_notebook_config.json
+${NBSERVER CONF}    jupyter_config.json
 ${NEXT LAB}         ${0}
diff --git a/scripts/atest.py b/scripts/atest.py
index 8998af0c..e9aca992 100644
--- a/scripts/atest.py
+++ b/scripts/atest.py
@@ -79,7 +79,6 @@ def atest(attempt, extra_args):
             *["--processes", PROCESSES],
             "--artifactsinsubfolders",
             *["--artifacts", "png,log,svg"],
-            "--testlevelsplit",
             *args,
         ]
 
diff --git a/src/ipyforcegraph/tests/test_meta.py b/src/ipyforcegraph/tests/test_meta.py
index 71699f3a..62965492 100644
--- a/src/ipyforcegraph/tests/test_meta.py
+++ b/src/ipyforcegraph/tests/test_meta.py
@@ -3,12 +3,17 @@
 
 import ipyforcegraph
 
-try:
+try:  # pragma: no cover
     from importlib.metadata import version
-except Exception:
+except Exception:  # pragma: no cover
     from importlib_metadata import version
 
 
 def test_meta():
     assert hasattr(ipyforcegraph, "__version__")
     assert ipyforcegraph.__version__ == version("ipyforcegraph")
+
+
+def test_labext():
+    extensions = ipyforcegraph._jupyter_labextension_paths()
+    assert len(extensions) == 1, "unexpected number of extensions"
diff --git a/src/ipyforcegraph/tests/test_widget_model_meta.py b/src/ipyforcegraph/tests/test_widget_model_meta.py
new file mode 100644
index 00000000..d863fef0
--- /dev/null
+++ b/src/ipyforcegraph/tests/test_widget_model_meta.py
@@ -0,0 +1,41 @@
+# Copyright (c) 2023 ipyforcegraph contributors.
+# Distributed under the terms of the Modified BSD License.
+
+from pprint import pprint
+
+import pytest
+
+from ipyforcegraph import behaviors, forcegraph
+from ipyforcegraph._base import ForceBase
+
+
+@pytest.fixture
+def widget_subclasses():
+    assert behaviors
+    assert forcegraph
+
+    subclasses = set([ForceBase])
+    subclass_count = -1
+
+    while len(subclasses) != subclass_count:
+        subclass_count = len(subclasses)
+        subclasses = set(
+            sum([s.__subclasses__() for s in subclasses], list(subclasses))
+        )
+
+    assert subclasses
+    return subclasses
+
+
+@pytest.fixture
+def widget_subclass_model_names(widget_subclasses):
+    return {
+        s: s._model_name.default_value
+        for s in sorted(widget_subclasses, key=lambda s: s.__name__)
+    }
+
+
+def test_unique_model_names(widget_subclasses, widget_subclass_model_names):
+    pprint(widget_subclass_model_names)
+    unique_names = sorted(set(widget_subclass_model_names.values()))
+    assert len(widget_subclasses) == len(unique_names), widget_subclass_model_names