diff --git a/.bazelignore b/.bazelignore new file mode 100644 index 0000000..4247355 --- /dev/null +++ b/.bazelignore @@ -0,0 +1,9 @@ +.direnv +.pnpm-store +bazel-bin +bazel-out +bazel-testlogs +coverage +dist +dist-types +node_modules diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 625a799..f93ccd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,8 +25,8 @@ jobs: typecheck_command: pnpm run check unit_test_command: pnpm run test build_command: pnpm run build - package_check_command: pnpm run check:package - bazel_targets: "//:pkg //:typecheck //:test" + package_check_command: "pnpm run check:package && pnpm run check:bundle-size" + bazel_targets: "//:pkg //:package_consumer_check //:bundle_size_check //:typecheck //:test" package_dir: ./bazel-bin/pkg npm_access: public dry_run: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bf7aafc..7b859ca 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -25,8 +25,8 @@ jobs: typecheck_command: pnpm run check unit_test_command: pnpm run test build_command: pnpm run build - package_check_command: pnpm run check:package - bazel_targets: "//:pkg //:typecheck //:test" + package_check_command: "pnpm run check:package && pnpm run check:bundle-size" + bazel_targets: "//:pkg //:package_consumer_check //:bundle_size_check //:typecheck //:test" package_dir: ./bazel-bin/pkg npm_access: public dry_run: false diff --git a/BUILD.bazel b/BUILD.bazel index 1d9fc84..030b481 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -14,11 +14,10 @@ Build strategy: - vitest for PBT tests (property-based tests using fast-check) """ -load("@aspect_rules_js//js:defs.bzl", "js_library", "js_run_binary") +load("@aspect_rules_js//js:defs.bzl", "js_binary", "js_library", "js_run_binary") load("@aspect_rules_js//npm:defs.bzl", "npm_package") load("@tummycrypt_tinyvectors_npm//:defs.bzl", "npm_link_all_packages") load("@tummycrypt_tinyvectors_npm//:svelte-check/package_json.bzl", svelte_check_bin = "bin") -load("@tummycrypt_tinyvectors_npm//:typescript/package_json.bzl", typescript_bin = "bin") load("@tummycrypt_tinyvectors_npm//:vite/package_json.bzl", vite_bin = "bin") load("@tummycrypt_tinyvectors_npm//:vitest/package_json.bzl", vitest_bin = "bin") @@ -29,9 +28,37 @@ load("@tummycrypt_tinyvectors_npm//:vitest/package_json.bzl", vitest_bin = "bin" npm_link_all_packages(name = "node_modules") vite_bin.vite_binary(name = "vite") -typescript_bin.tsc_binary(name = "tsc") svelte_check_bin.svelte_check_binary(name = "svelte_check") +js_binary( + name = "build_declarations", + data = [ + ":node_modules", + ":node_modules/typescript", + "scripts/copy-svelte-declarations.mjs", + ], + entry_point = "scripts/build-declarations.mjs", +) + +js_binary( + name = "check_package_consumer", + data = [ + ":node_modules", + ":node_modules/svelte", + ":node_modules/typescript", + ], + entry_point = "scripts/check-package-consumer.mjs", +) + +js_binary( + name = "check_bundle_size", + data = [ + ":node_modules", + ":node_modules/vite", + ], + entry_point = "scripts/check-bundle-size.mjs", +) + # ============================================================================= # JavaScript package build @@ -73,19 +100,18 @@ js_run_binary( js_run_binary( name = "tinyvectors_declarations", - tool = "//:tsc", + tool = "//:build_declarations", srcs = glob([ "src/**/*.ts", + "src/**/*.svelte.d.ts", ]) + [ "package.json", "pnpm-lock.yaml", + "scripts/copy-svelte-declarations.mjs", "tsconfig.json", "tsconfig.declarations.json", ":node_modules", - ], - args = [ - "-p", - "$(execpath tsconfig.declarations.json)", + ":node_modules/typescript", ], out_dirs = ["dist-types"], visibility = ["//visibility:public"], @@ -112,12 +138,44 @@ npm_package( srcs = [ "package.json", "README.md", + "CHANGELOG.md", "LICENSE", "src/themes/vector-colors.css", ":tinyvectors", ], package = "@tummycrypt/tinyvectors", - version = "0.2.5", + version = "0.3.0", + visibility = ["//visibility:public"], +) + +js_run_binary( + name = "package_consumer_check", + tool = "//:check_package_consumer", + srcs = [ + ":pkg", + ":node_modules", + ":node_modules/svelte", + ":node_modules/typescript", + ], + args = [ + "$(rootpath :pkg)", + ], + stdout = "package-consumer-check.log", + visibility = ["//visibility:public"], +) + +js_run_binary( + name = "bundle_size_check", + tool = "//:check_bundle_size", + srcs = [ + ":pkg", + ":node_modules", + ":node_modules/vite", + ], + args = [ + "$(rootpath :pkg)", + ], + stdout = "bundle-size-check.log", visibility = ["//visibility:public"], ) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1b98cb4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## 0.3.0 - 2026-05-01 + +- Restores the pre-Phase-A gel/blob feel while keeping gravity-led motion, ambient movement, and safer smoothing. +- Adds device-motion status, permission, calibration, idle reset, reduced-motion, and Chrome/CDP browser harness coverage. +- Adds pointer and scroll lifecycle cleanup, stale IO reset behavior, pointer velocity coverage, and a conservative local pointer field. +- Hardens the package release surface with explicit exports, Bazel-built package validation, bundle-size checks, and consumer-package checks. +- Keeps the release bundle under the 12 KiB gzip gate while documenting the remaining 11 KiB target pressure. diff --git a/MODULE.bazel b/MODULE.bazel index 181fd29..128fa67 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -2,12 +2,12 @@ MODULE.bazel for standalone Bazel consumption of @tummycrypt/tinyvectors. Usage from external repo: - bazel_dep(name = "tummycrypt_tinyvectors", version = "0.2.5") + bazel_dep(name = "tummycrypt_tinyvectors", version = "0.3.0") """ module( name = "tummycrypt_tinyvectors", - version = "0.2.5", + version = "0.3.0", compatibility_level = 1, ) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index ef2a4eb..00d1c12 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -174,6 +174,160 @@ }, "selectedYankedVersions": {}, "moduleExtensions": { + "@@aspect_rules_js+//npm:extensions.bzl%pnpm": { + "general": { + "bzlTransitiveDigest": "+38sdUshoDaaBOpNBG17zT25s0y5uiGlxqyEtzgmzVs=", + "usagesDigest": "NY1vao0VHjdP6l6u+OOM5scvRTwDos1a0xhs0ZBM2Go=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "pnpm": { + "repoRuleId": "@@aspect_rules_js+//npm/private:npm_import.bzl%npm_import_rule", + "attributes": { + "package": "pnpm", + "version": "9.15.9", + "root_package": "", + "link_workspace": "", + "link_packages": {}, + "integrity": "sha512-aARhQYk8ZvrQHAeSMRKOmvuJ74fiaR1p5NQO7iKJiClf1GghgbrlW1hBjDolO95lpQXsfF+UA+zlzDzTfc8lMQ==", + "url": "", + "commit": "", + "patch_args": [ + "-p0" + ], + "patches": [], + "custom_postinstall": "", + "npm_auth": "", + "npm_auth_basic": "", + "npm_auth_username": "", + "npm_auth_password": "", + "lifecycle_hooks": [], + "extra_build_content": "load(\"@aspect_rules_js//js:defs.bzl\", \"js_binary\")\njs_binary(name = \"pnpm\", data = glob([\"package/**\"]), entry_point = \"package/dist/pnpm.cjs\", visibility = [\"//visibility:public\"])", + "generate_bzl_library_targets": false, + "extract_full_archive": true, + "exclude_package_contents": [] + } + }, + "pnpm__links": { + "repoRuleId": "@@aspect_rules_js+//npm/private:npm_import.bzl%npm_import_links", + "attributes": { + "package": "pnpm", + "version": "9.15.9", + "dev": false, + "root_package": "", + "link_packages": {}, + "deps": {}, + "transitive_closure": {}, + "lifecycle_build_target": false, + "lifecycle_hooks_env": [], + "lifecycle_hooks_execution_requirements": [ + "no-sandbox" + ], + "lifecycle_hooks_use_default_shell_env": false, + "bins": {}, + "package_visibility": [ + "//visibility:public" + ], + "replace_package": "", + "exclude_package_contents": [] + } + } + }, + "recordedRepoMappingEntries": [ + [ + "aspect_bazel_lib+", + "bazel_lib", + "bazel_lib+" + ], + [ + "aspect_bazel_lib+", + "bazel_skylib", + "bazel_skylib+" + ], + [ + "aspect_bazel_lib+", + "bazel_tools", + "bazel_tools" + ], + [ + "aspect_bazel_lib+", + "tar.bzl", + "tar.bzl+" + ], + [ + "aspect_rules_js+", + "aspect_bazel_lib", + "aspect_bazel_lib+" + ], + [ + "aspect_rules_js+", + "aspect_rules_js", + "aspect_rules_js+" + ], + [ + "aspect_rules_js+", + "aspect_tools_telemetry_report", + "aspect_tools_telemetry++telemetry+aspect_tools_telemetry_report" + ], + [ + "aspect_rules_js+", + "bazel_features", + "bazel_features+" + ], + [ + "aspect_rules_js+", + "bazel_lib", + "bazel_lib+" + ], + [ + "aspect_rules_js+", + "bazel_skylib", + "bazel_skylib+" + ], + [ + "aspect_rules_js+", + "bazel_tools", + "bazel_tools" + ], + [ + "bazel_features+", + "bazel_features_globals", + "bazel_features++version_extension+bazel_features_globals" + ], + [ + "bazel_features+", + "bazel_features_version", + "bazel_features++version_extension+bazel_features_version" + ], + [ + "bazel_lib+", + "bazel_skylib", + "bazel_skylib+" + ], + [ + "bazel_lib+", + "bazel_tools", + "bazel_tools" + ], + [ + "tar.bzl+", + "aspect_bazel_lib", + "aspect_bazel_lib+" + ], + [ + "tar.bzl+", + "bazel_skylib", + "bazel_skylib+" + ], + [ + "tar.bzl+", + "tar.bzl", + "tar.bzl+" + ] + ] + } + }, "@@aspect_tools_telemetry+//:extension.bzl%telemetry": { "general": { "bzlTransitiveDigest": "cl5A2O84vDL6Tt+Qga8FCj1DUDGqn+e7ly5rZ+4xvcc=", @@ -206,6 +360,123 @@ ] } }, + "@@pybind11_bazel+//:python_configure.bzl%extension": { + "general": { + "bzlTransitiveDigest": "d4N/SZrl3ONcmzE98rcV0Fsro0iUbjNQFTIiLiGuH+k=", + "usagesDigest": "fycyB39YnXIJkfWCIXLUKJMZzANcuLy9ZE73hRucjFk=", + "recordedFileInputs": { + "@@pybind11_bazel+//MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e" + }, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "local_config_python": { + "repoRuleId": "@@pybind11_bazel+//:python_configure.bzl%python_configure", + "attributes": {} + }, + "pybind11": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "build_file": "@@pybind11_bazel+//:pybind11.BUILD", + "strip_prefix": "pybind11-2.11.1", + "urls": [ + "https://github.com/pybind/pybind11/archive/v2.11.1.zip" + ] + } + } + }, + "recordedRepoMappingEntries": [ + [ + "pybind11_bazel+", + "bazel_tools", + "bazel_tools" + ] + ] + } + }, + "@@rules_fuzzing+//fuzzing/private:extensions.bzl%non_module_dependencies": { + "general": { + "bzlTransitiveDigest": "mGiTB79hRNjmeDTQdzkpCHyzXhErMbufeAmySBt7s5s=", + "usagesDigest": "wy6ISK6UOcBEjj/mvJ/S3WeXoO67X+1llb9yPyFtPgc=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "platforms": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.8/platforms-0.0.8.tar.gz", + "https://github.com/bazelbuild/platforms/releases/download/0.0.8/platforms-0.0.8.tar.gz" + ], + "sha256": "8150406605389ececb6da07cbcb509d5637a3ab9a24bc69b1101531367d89d74" + } + }, + "rules_python": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "sha256": "d70cd72a7a4880f0000a6346253414825c19cdd40a28289bdf67b8e6480edff8", + "strip_prefix": "rules_python-0.28.0", + "url": "https://github.com/bazelbuild/rules_python/releases/download/0.28.0/rules_python-0.28.0.tar.gz" + } + }, + "bazel_skylib": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "sha256": "cd55a062e763b9349921f0f5db8c3933288dc8ba4f76dd9416aac68acee3cb94", + "urls": [ + "https://mirror.bazel.build/github.com/bazelbuild/bazel-skylib/releases/download/1.5.0/bazel-skylib-1.5.0.tar.gz", + "https://github.com/bazelbuild/bazel-skylib/releases/download/1.5.0/bazel-skylib-1.5.0.tar.gz" + ] + } + }, + "com_google_absl": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/abseil/abseil-cpp/archive/refs/tags/20240116.1.zip" + ], + "strip_prefix": "abseil-cpp-20240116.1", + "integrity": "sha256-7capMWOvWyoYbUaHF/b+I2U6XLMaHmky8KugWvfXYuk=" + } + }, + "rules_fuzzing_oss_fuzz": { + "repoRuleId": "@@rules_fuzzing+//fuzzing/private/oss_fuzz:repository.bzl%oss_fuzz_repository", + "attributes": {} + }, + "honggfuzz": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "build_file": "@@rules_fuzzing+//:honggfuzz.BUILD", + "sha256": "6b18ba13bc1f36b7b950c72d80f19ea67fbadc0ac0bb297ec89ad91f2eaa423e", + "url": "https://github.com/google/honggfuzz/archive/2.5.zip", + "strip_prefix": "honggfuzz-2.5" + } + }, + "rules_fuzzing_jazzer": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_jar", + "attributes": { + "sha256": "ee6feb569d88962d59cb59e8a31eb9d007c82683f3ebc64955fd5b96f277eec2", + "url": "https://repo1.maven.org/maven2/com/code-intelligence/jazzer/0.20.1/jazzer-0.20.1.jar" + } + }, + "rules_fuzzing_jazzer_api": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_jar", + "attributes": { + "sha256": "f5a60242bc408f7fa20fccf10d6c5c5ea1fcb3c6f44642fec5af88373ae7aa1b", + "url": "https://repo1.maven.org/maven2/com/code-intelligence/jazzer-api/0.20.1/jazzer-api-0.20.1.jar" + } + } + }, + "recordedRepoMappingEntries": [ + [ + "rules_fuzzing+", + "bazel_tools", + "bazel_tools" + ] + ] + } + }, "@@rules_java+//java:rules_java_deps.bzl%compatibility_proxy": { "general": { "bzlTransitiveDigest": "84xJEZ1jnXXwo8BXMprvBm++rRt4jsTu9liBxz0ivps=", @@ -295,7 +566,7 @@ "@@rules_nodejs+//nodejs:extensions.bzl%node": { "general": { "bzlTransitiveDigest": "4pUxCNc22K4I+6+4Nxu52Hur12tFRfa1JMsN5mdDv60=", - "usagesDigest": "54g5qwvCETxU3t3RV9TXKVf7z0lF330Y4TLGkdrrROE=", + "usagesDigest": "1w4kFiZokOU0Da4EvQBCGLHbkIzIrOE2BPRmgFcvAvU=", "recordedFileInputs": {}, "recordedDirentsInputs": {}, "envVariables": {}, @@ -426,6 +697,2500 @@ "recordedRepoMappingEntries": [] } }, + "@@rules_python+//python/private/pypi:pip.bzl%pip_internal": { + "general": { + "bzlTransitiveDigest": "LoTaMl3T/Mr0pZwTYVMcRc5gv6qNfnE8umRCDYy1m7U=", + "usagesDigest": "OLoIStnzNObNalKEMRq99FqenhPGLFZ5utVLV4sz7OI=", + "recordedFileInputs": { + "@@rules_python+//tools/publish/requirements_darwin.txt": "2994136eab7e57b083c3de76faf46f70fad130bc8e7360a7fed2b288b69e79dc", + "@@rules_python+//tools/publish/requirements_linux.txt": "8175b4c8df50ae2f22d1706961884beeb54e7da27bd2447018314a175981997d", + "@@rules_python+//tools/publish/requirements_windows.txt": "7673adc71dc1a81d3661b90924d7a7c0fc998cd508b3cb4174337cef3f2de556" + }, + "recordedDirentsInputs": {}, + "envVariables": { + "RULES_PYTHON_REPO_DEBUG": null, + "RULES_PYTHON_REPO_DEBUG_VERBOSITY": null + }, + "generatedRepoSpecs": { + "rules_python_publish_deps_311_backports_tarfile_py3_none_any_77e284d7": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "backports.tarfile-1.2.0-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "backports-tarfile==1.2.0", + "sha256": "77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", + "urls": [ + "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_backports_tarfile_sdist_d75e02c2": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "backports_tarfile-1.2.0.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "backports-tarfile==1.2.0", + "sha256": "d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", + "urls": [ + "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_certifi_py3_none_any_922820b5": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "certifi-2024.8.30-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "certifi==2024.8.30", + "sha256": "922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", + "urls": [ + "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_certifi_sdist_bec941d2": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "certifi-2024.8.30.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "certifi==2024.8.30", + "sha256": "bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", + "urls": [ + "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_cffi_cp311_cp311_manylinux_2_17_aarch64_a1ed2dd2": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "filename": "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "cffi==1.17.1", + "sha256": "a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", + "urls": [ + "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" + ] + } + }, + "rules_python_publish_deps_311_cffi_cp311_cp311_manylinux_2_17_ppc64le_46bf4316": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "filename": "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "cffi==1.17.1", + "sha256": "46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", + "urls": [ + "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" + ] + } + }, + "rules_python_publish_deps_311_cffi_cp311_cp311_manylinux_2_17_s390x_a24ed04c": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "filename": "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "cffi==1.17.1", + "sha256": "a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", + "urls": [ + "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl" + ] + } + }, + "rules_python_publish_deps_311_cffi_cp311_cp311_manylinux_2_17_x86_64_610faea7": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "filename": "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "cffi==1.17.1", + "sha256": "610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", + "urls": [ + "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" + ] + } + }, + "rules_python_publish_deps_311_cffi_cp311_cp311_musllinux_1_1_aarch64_a9b15d49": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "filename": "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "cffi==1.17.1", + "sha256": "a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", + "urls": [ + "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl" + ] + } + }, + "rules_python_publish_deps_311_cffi_cp311_cp311_musllinux_1_1_x86_64_fc48c783": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "filename": "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "cffi==1.17.1", + "sha256": "fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", + "urls": [ + "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl" + ] + } + }, + "rules_python_publish_deps_311_cffi_sdist_1c39c601": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "cffi-1.17.1.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "cffi==1.17.1", + "sha256": "1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", + "urls": [ + "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_charset_normalizer_cp311_cp311_macosx_10_9_universal2_0d99dd8f": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "charset-normalizer==3.4.0", + "sha256": "0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", + "urls": [ + "https://files.pythonhosted.org/packages/9c/61/73589dcc7a719582bf56aae309b6103d2762b526bffe189d635a7fcfd998/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl" + ] + } + }, + "rules_python_publish_deps_311_charset_normalizer_cp311_cp311_macosx_10_9_x86_64_c57516e5": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "charset-normalizer==3.4.0", + "sha256": "c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", + "urls": [ + "https://files.pythonhosted.org/packages/77/d5/8c982d58144de49f59571f940e329ad6e8615e1e82ef84584c5eeb5e1d72/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl" + ] + } + }, + "rules_python_publish_deps_311_charset_normalizer_cp311_cp311_macosx_11_0_arm64_6dba5d19": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "charset-normalizer==3.4.0", + "sha256": "6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", + "urls": [ + "https://files.pythonhosted.org/packages/bf/19/411a64f01ee971bed3231111b69eb56f9331a769072de479eae7de52296d/charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl" + ] + } + }, + "rules_python_publish_deps_311_charset_normalizer_cp311_cp311_manylinux_2_17_aarch64_bf4475b8": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "charset-normalizer==3.4.0", + "sha256": "bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", + "urls": [ + "https://files.pythonhosted.org/packages/4c/92/97509850f0d00e9f14a46bc751daabd0ad7765cff29cdfb66c68b6dad57f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" + ] + } + }, + "rules_python_publish_deps_311_charset_normalizer_cp311_cp311_manylinux_2_17_ppc64le_ce031db0": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "charset-normalizer==3.4.0", + "sha256": "ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", + "urls": [ + "https://files.pythonhosted.org/packages/e2/29/d227805bff72ed6d6cb1ce08eec707f7cfbd9868044893617eb331f16295/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" + ] + } + }, + "rules_python_publish_deps_311_charset_normalizer_cp311_cp311_manylinux_2_17_s390x_8ff4e7cd": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "charset-normalizer==3.4.0", + "sha256": "8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", + "urls": [ + "https://files.pythonhosted.org/packages/13/bc/87c2c9f2c144bedfa62f894c3007cd4530ba4b5351acb10dc786428a50f0/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl" + ] + } + }, + "rules_python_publish_deps_311_charset_normalizer_cp311_cp311_manylinux_2_17_x86_64_3710a975": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "charset-normalizer==3.4.0", + "sha256": "3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", + "urls": [ + "https://files.pythonhosted.org/packages/eb/5b/6f10bad0f6461fa272bfbbdf5d0023b5fb9bc6217c92bf068fa5a99820f5/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" + ] + } + }, + "rules_python_publish_deps_311_charset_normalizer_cp311_cp311_musllinux_1_2_aarch64_47334db7": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "charset-normalizer==3.4.0", + "sha256": "47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", + "urls": [ + "https://files.pythonhosted.org/packages/d7/a1/493919799446464ed0299c8eef3c3fad0daf1c3cd48bff9263c731b0d9e2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl" + ] + } + }, + "rules_python_publish_deps_311_charset_normalizer_cp311_cp311_musllinux_1_2_ppc64le_f1a2f519": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "charset-normalizer==3.4.0", + "sha256": "f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", + "urls": [ + "https://files.pythonhosted.org/packages/75/d2/0ab54463d3410709c09266dfb416d032a08f97fd7d60e94b8c6ef54ae14b/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl" + ] + } + }, + "rules_python_publish_deps_311_charset_normalizer_cp311_cp311_musllinux_1_2_s390x_63bc5c4a": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "charset-normalizer==3.4.0", + "sha256": "63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", + "urls": [ + "https://files.pythonhosted.org/packages/8d/c9/27e41d481557be53d51e60750b85aa40eaf52b841946b3cdeff363105737/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl" + ] + } + }, + "rules_python_publish_deps_311_charset_normalizer_cp311_cp311_musllinux_1_2_x86_64_bcb4f8ea": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "charset-normalizer==3.4.0", + "sha256": "bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", + "urls": [ + "https://files.pythonhosted.org/packages/ee/44/4f62042ca8cdc0cabf87c0fc00ae27cd8b53ab68be3605ba6d071f742ad3/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl" + ] + } + }, + "rules_python_publish_deps_311_charset_normalizer_cp311_cp311_win_amd64_cee4373f": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "charset-normalizer==3.4.0", + "sha256": "cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", + "urls": [ + "https://files.pythonhosted.org/packages/0b/6e/b13bd47fa9023b3699e94abf565b5a2f0b0be6e9ddac9812182596ee62e4/charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl" + ] + } + }, + "rules_python_publish_deps_311_charset_normalizer_py3_none_any_fe9f97fe": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "charset_normalizer-3.4.0-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "charset-normalizer==3.4.0", + "sha256": "fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", + "urls": [ + "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_charset_normalizer_sdist_223217c3": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "charset_normalizer-3.4.0.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "charset-normalizer==3.4.0", + "sha256": "223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", + "urls": [ + "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_cryptography_cp39_abi3_manylinux_2_17_aarch64_846da004": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "filename": "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "cryptography==43.0.3", + "sha256": "846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5", + "urls": [ + "https://files.pythonhosted.org/packages/2f/78/55356eb9075d0be6e81b59f45c7b48df87f76a20e73893872170471f3ee8/cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" + ] + } + }, + "rules_python_publish_deps_311_cryptography_cp39_abi3_manylinux_2_17_x86_64_0f996e72": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "filename": "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "cryptography==43.0.3", + "sha256": "0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4", + "urls": [ + "https://files.pythonhosted.org/packages/2a/2c/488776a3dc843f95f86d2f957ca0fc3407d0242b50bede7fad1e339be03f/cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" + ] + } + }, + "rules_python_publish_deps_311_cryptography_cp39_abi3_manylinux_2_28_aarch64_f7b178f1": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "filename": "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "cryptography==43.0.3", + "sha256": "f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7", + "urls": [ + "https://files.pythonhosted.org/packages/7c/04/2345ca92f7a22f601a9c62961741ef7dd0127c39f7310dffa0041c80f16f/cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl" + ] + } + }, + "rules_python_publish_deps_311_cryptography_cp39_abi3_manylinux_2_28_x86_64_c2e6fc39": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "filename": "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "cryptography==43.0.3", + "sha256": "c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405", + "urls": [ + "https://files.pythonhosted.org/packages/ac/25/e715fa0bc24ac2114ed69da33adf451a38abb6f3f24ec207908112e9ba53/cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl" + ] + } + }, + "rules_python_publish_deps_311_cryptography_cp39_abi3_musllinux_1_2_aarch64_e1be4655": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "filename": "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "cryptography==43.0.3", + "sha256": "e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16", + "urls": [ + "https://files.pythonhosted.org/packages/21/ce/b9c9ff56c7164d8e2edfb6c9305045fbc0df4508ccfdb13ee66eb8c95b0e/cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl" + ] + } + }, + "rules_python_publish_deps_311_cryptography_cp39_abi3_musllinux_1_2_x86_64_df6b6c6d": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "filename": "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "cryptography==43.0.3", + "sha256": "df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73", + "urls": [ + "https://files.pythonhosted.org/packages/2a/33/b3682992ab2e9476b9c81fff22f02c8b0a1e6e1d49ee1750a67d85fd7ed2/cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl" + ] + } + }, + "rules_python_publish_deps_311_cryptography_sdist_315b9001": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "cryptography-43.0.3.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "cryptography==43.0.3", + "sha256": "315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805", + "urls": [ + "https://files.pythonhosted.org/packages/0d/05/07b55d1fa21ac18c3a8c79f764e2514e6f6a9698f1be44994f5adf0d29db/cryptography-43.0.3.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_docutils_py3_none_any_dafca5b9": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "docutils-0.21.2-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "docutils==0.21.2", + "sha256": "dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", + "urls": [ + "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_docutils_sdist_3a6b1873": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "docutils-0.21.2.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "docutils==0.21.2", + "sha256": "3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", + "urls": [ + "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_idna_py3_none_any_946d195a": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "idna-3.10-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "idna==3.10", + "sha256": "946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", + "urls": [ + "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_idna_sdist_12f65c9b": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "idna-3.10.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "idna==3.10", + "sha256": "12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", + "urls": [ + "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_importlib_metadata_py3_none_any_45e54197": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "importlib_metadata-8.5.0-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "importlib-metadata==8.5.0", + "sha256": "45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", + "urls": [ + "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_importlib_metadata_sdist_71522656": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "importlib_metadata-8.5.0.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "importlib-metadata==8.5.0", + "sha256": "71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", + "urls": [ + "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_jaraco_classes_py3_none_any_f662826b": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "jaraco.classes-3.4.0-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "jaraco-classes==3.4.0", + "sha256": "f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", + "urls": [ + "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_jaraco_classes_sdist_47a024b5": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "jaraco.classes-3.4.0.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "jaraco-classes==3.4.0", + "sha256": "47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", + "urls": [ + "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_jaraco_context_py3_none_any_f797fc48": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "jaraco.context-6.0.1-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "jaraco-context==6.0.1", + "sha256": "f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", + "urls": [ + "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_jaraco_context_sdist_9bae4ea5": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "jaraco_context-6.0.1.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "jaraco-context==6.0.1", + "sha256": "9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", + "urls": [ + "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_jaraco_functools_py3_none_any_ad159f13": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "jaraco.functools-4.1.0-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "jaraco-functools==4.1.0", + "sha256": "ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649", + "urls": [ + "https://files.pythonhosted.org/packages/9f/4f/24b319316142c44283d7540e76c7b5a6dbd5db623abd86bb7b3491c21018/jaraco.functools-4.1.0-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_jaraco_functools_sdist_70f7e0e2": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "jaraco_functools-4.1.0.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "jaraco-functools==4.1.0", + "sha256": "70f7e0e2ae076498e212562325e805204fc092d7b4c17e0e86c959e249701a9d", + "urls": [ + "https://files.pythonhosted.org/packages/ab/23/9894b3df5d0a6eb44611c36aec777823fc2e07740dabbd0b810e19594013/jaraco_functools-4.1.0.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_jeepney_py3_none_any_c0a454ad": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "filename": "jeepney-0.8.0-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "jeepney==0.8.0", + "sha256": "c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755", + "urls": [ + "https://files.pythonhosted.org/packages/ae/72/2a1e2290f1ab1e06f71f3d0f1646c9e4634e70e1d37491535e19266e8dc9/jeepney-0.8.0-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_jeepney_sdist_5efe48d2": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "jeepney-0.8.0.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "jeepney==0.8.0", + "sha256": "5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806", + "urls": [ + "https://files.pythonhosted.org/packages/d6/f4/154cf374c2daf2020e05c3c6a03c91348d59b23c5366e968feb198306fdf/jeepney-0.8.0.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_keyring_py3_none_any_5426f817": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "keyring-25.4.1-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "keyring==25.4.1", + "sha256": "5426f817cf7f6f007ba5ec722b1bcad95a75b27d780343772ad76b17cb47b0bf", + "urls": [ + "https://files.pythonhosted.org/packages/83/25/e6d59e5f0a0508d0dca8bb98c7f7fd3772fc943ac3f53d5ab18a218d32c0/keyring-25.4.1-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_keyring_sdist_b07ebc55": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "keyring-25.4.1.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "keyring==25.4.1", + "sha256": "b07ebc55f3e8ed86ac81dd31ef14e81ace9dd9c3d4b5d77a6e9a2016d0d71a1b", + "urls": [ + "https://files.pythonhosted.org/packages/a5/1c/2bdbcfd5d59dc6274ffb175bc29aa07ecbfab196830e0cfbde7bd861a2ea/keyring-25.4.1.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_markdown_it_py_py3_none_any_35521684": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "markdown_it_py-3.0.0-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "markdown-it-py==3.0.0", + "sha256": "355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", + "urls": [ + "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_markdown_it_py_sdist_e3f60a94": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "markdown-it-py-3.0.0.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "markdown-it-py==3.0.0", + "sha256": "e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", + "urls": [ + "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_mdurl_py3_none_any_84008a41": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "mdurl-0.1.2-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "mdurl==0.1.2", + "sha256": "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", + "urls": [ + "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_mdurl_sdist_bb413d29": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "mdurl-0.1.2.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "mdurl==0.1.2", + "sha256": "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", + "urls": [ + "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_more_itertools_py3_none_any_037b0d32": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "more_itertools-10.5.0-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "more-itertools==10.5.0", + "sha256": "037b0d3203ce90cca8ab1defbbdac29d5f993fc20131f3664dc8d6acfa872aef", + "urls": [ + "https://files.pythonhosted.org/packages/48/7e/3a64597054a70f7c86eb0a7d4fc315b8c1ab932f64883a297bdffeb5f967/more_itertools-10.5.0-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_more_itertools_sdist_5482bfef": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "more-itertools-10.5.0.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "more-itertools==10.5.0", + "sha256": "5482bfef7849c25dc3c6dd53a6173ae4795da2a41a80faea6700d9f5846c5da6", + "urls": [ + "https://files.pythonhosted.org/packages/51/78/65922308c4248e0eb08ebcbe67c95d48615cc6f27854b6f2e57143e9178f/more-itertools-10.5.0.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_nh3_cp37_abi3_macosx_10_12_x86_64_14c5a72e": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "nh3-0.2.18-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "nh3==0.2.18", + "sha256": "14c5a72e9fe82aea5fe3072116ad4661af5cf8e8ff8fc5ad3450f123e4925e86", + "urls": [ + "https://files.pythonhosted.org/packages/b3/89/1daff5d9ba5a95a157c092c7c5f39b8dd2b1ddb4559966f808d31cfb67e0/nh3-0.2.18-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl" + ] + } + }, + "rules_python_publish_deps_311_nh3_cp37_abi3_macosx_10_12_x86_64_7b7c2a3c": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "nh3-0.2.18-cp37-abi3-macosx_10_12_x86_64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "nh3==0.2.18", + "sha256": "7b7c2a3c9eb1a827d42539aa64091640bd275b81e097cd1d8d82ef91ffa2e811", + "urls": [ + "https://files.pythonhosted.org/packages/2c/b6/42fc3c69cabf86b6b81e4c051a9b6e249c5ba9f8155590222c2622961f58/nh3-0.2.18-cp37-abi3-macosx_10_12_x86_64.whl" + ] + } + }, + "rules_python_publish_deps_311_nh3_cp37_abi3_manylinux_2_17_aarch64_42c64511": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "nh3-0.2.18-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "nh3==0.2.18", + "sha256": "42c64511469005058cd17cc1537578eac40ae9f7200bedcfd1fc1a05f4f8c200", + "urls": [ + "https://files.pythonhosted.org/packages/45/b9/833f385403abaf0023c6547389ec7a7acf141ddd9d1f21573723a6eab39a/nh3-0.2.18-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl" + ] + } + }, + "rules_python_publish_deps_311_nh3_cp37_abi3_manylinux_2_17_armv7l_0411beb0": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "nh3-0.2.18-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "nh3==0.2.18", + "sha256": "0411beb0589eacb6734f28d5497ca2ed379eafab8ad8c84b31bb5c34072b7164", + "urls": [ + "https://files.pythonhosted.org/packages/05/2b/85977d9e11713b5747595ee61f381bc820749daf83f07b90b6c9964cf932/nh3-0.2.18-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl" + ] + } + }, + "rules_python_publish_deps_311_nh3_cp37_abi3_manylinux_2_17_ppc64_5f36b271": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "nh3-0.2.18-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "nh3==0.2.18", + "sha256": "5f36b271dae35c465ef5e9090e1fdaba4a60a56f0bb0ba03e0932a66f28b9189", + "urls": [ + "https://files.pythonhosted.org/packages/72/f2/5c894d5265ab80a97c68ca36f25c8f6f0308abac649aaf152b74e7e854a8/nh3-0.2.18-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl" + ] + } + }, + "rules_python_publish_deps_311_nh3_cp37_abi3_manylinux_2_17_ppc64le_34c03fa7": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "nh3-0.2.18-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "nh3==0.2.18", + "sha256": "34c03fa78e328c691f982b7c03d4423bdfd7da69cd707fe572f544cf74ac23ad", + "urls": [ + "https://files.pythonhosted.org/packages/ab/a7/375afcc710dbe2d64cfbd69e31f82f3e423d43737258af01f6a56d844085/nh3-0.2.18-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl" + ] + } + }, + "rules_python_publish_deps_311_nh3_cp37_abi3_manylinux_2_17_s390x_19aaba96": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "nh3-0.2.18-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "nh3==0.2.18", + "sha256": "19aaba96e0f795bd0a6c56291495ff59364f4300d4a39b29a0abc9cb3774a84b", + "urls": [ + "https://files.pythonhosted.org/packages/c2/a8/3bb02d0c60a03ad3a112b76c46971e9480efa98a8946677b5a59f60130ca/nh3-0.2.18-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl" + ] + } + }, + "rules_python_publish_deps_311_nh3_cp37_abi3_manylinux_2_17_x86_64_de3ceed6": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "nh3-0.2.18-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "nh3==0.2.18", + "sha256": "de3ceed6e661954871d6cd78b410213bdcb136f79aafe22aa7182e028b8c7307", + "urls": [ + "https://files.pythonhosted.org/packages/1b/63/6ab90d0e5225ab9780f6c9fb52254fa36b52bb7c188df9201d05b647e5e1/nh3-0.2.18-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" + ] + } + }, + "rules_python_publish_deps_311_nh3_cp37_abi3_musllinux_1_2_aarch64_f0eca9ca": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "nh3-0.2.18-cp37-abi3-musllinux_1_2_aarch64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "nh3==0.2.18", + "sha256": "f0eca9ca8628dbb4e916ae2491d72957fdd35f7a5d326b7032a345f111ac07fe", + "urls": [ + "https://files.pythonhosted.org/packages/a3/da/0c4e282bc3cff4a0adf37005fa1fb42257673fbc1bbf7d1ff639ec3d255a/nh3-0.2.18-cp37-abi3-musllinux_1_2_aarch64.whl" + ] + } + }, + "rules_python_publish_deps_311_nh3_cp37_abi3_musllinux_1_2_armv7l_3a157ab1": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "nh3-0.2.18-cp37-abi3-musllinux_1_2_armv7l.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "nh3==0.2.18", + "sha256": "3a157ab149e591bb638a55c8c6bcb8cdb559c8b12c13a8affaba6cedfe51713a", + "urls": [ + "https://files.pythonhosted.org/packages/de/81/c291231463d21da5f8bba82c8167a6d6893cc5419b0639801ee5d3aeb8a9/nh3-0.2.18-cp37-abi3-musllinux_1_2_armv7l.whl" + ] + } + }, + "rules_python_publish_deps_311_nh3_cp37_abi3_musllinux_1_2_x86_64_36c95d4b": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "nh3-0.2.18-cp37-abi3-musllinux_1_2_x86_64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "nh3==0.2.18", + "sha256": "36c95d4b70530b320b365659bb5034341316e6a9b30f0b25fa9c9eff4c27a204", + "urls": [ + "https://files.pythonhosted.org/packages/eb/61/73a007c74c37895fdf66e0edcd881f5eaa17a348ff02f4bb4bc906d61085/nh3-0.2.18-cp37-abi3-musllinux_1_2_x86_64.whl" + ] + } + }, + "rules_python_publish_deps_311_nh3_cp37_abi3_win_amd64_8ce0f819": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "nh3-0.2.18-cp37-abi3-win_amd64.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "nh3==0.2.18", + "sha256": "8ce0f819d2f1933953fca255db2471ad58184a60508f03e6285e5114b6254844", + "urls": [ + "https://files.pythonhosted.org/packages/26/8d/53c5b19c4999bdc6ba95f246f4ef35ca83d7d7423e5e38be43ad66544e5d/nh3-0.2.18-cp37-abi3-win_amd64.whl" + ] + } + }, + "rules_python_publish_deps_311_nh3_sdist_94a16692": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "nh3-0.2.18.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "nh3==0.2.18", + "sha256": "94a166927e53972a9698af9542ace4e38b9de50c34352b962f4d9a7d4c927af4", + "urls": [ + "https://files.pythonhosted.org/packages/62/73/10df50b42ddb547a907deeb2f3c9823022580a7a47281e8eae8e003a9639/nh3-0.2.18.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_pkginfo_py3_none_any_889a6da2": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "pkginfo-1.10.0-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "pkginfo==1.10.0", + "sha256": "889a6da2ed7ffc58ab5b900d888ddce90bce912f2d2de1dc1c26f4cb9fe65097", + "urls": [ + "https://files.pythonhosted.org/packages/56/09/054aea9b7534a15ad38a363a2bd974c20646ab1582a387a95b8df1bfea1c/pkginfo-1.10.0-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_pkginfo_sdist_5df73835": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "pkginfo-1.10.0.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "pkginfo==1.10.0", + "sha256": "5df73835398d10db79f8eecd5cd86b1f6d29317589ea70796994d49399af6297", + "urls": [ + "https://files.pythonhosted.org/packages/2f/72/347ec5be4adc85c182ed2823d8d1c7b51e13b9a6b0c1aae59582eca652df/pkginfo-1.10.0.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_pycparser_py3_none_any_c3702b6d": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "filename": "pycparser-2.22-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "pycparser==2.22", + "sha256": "c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", + "urls": [ + "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_pycparser_sdist_491c8be9": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "pycparser-2.22.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "pycparser==2.22", + "sha256": "491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", + "urls": [ + "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_pygments_py3_none_any_b8e6aca0": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "pygments-2.18.0-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "pygments==2.18.0", + "sha256": "b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", + "urls": [ + "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_pygments_sdist_786ff802": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "pygments-2.18.0.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "pygments==2.18.0", + "sha256": "786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", + "urls": [ + "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_pywin32_ctypes_py3_none_any_8a151337": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_windows_x86_64" + ], + "filename": "pywin32_ctypes-0.2.3-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "pywin32-ctypes==0.2.3", + "sha256": "8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", + "urls": [ + "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_pywin32_ctypes_sdist_d162dc04": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "pywin32-ctypes-0.2.3.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "pywin32-ctypes==0.2.3", + "sha256": "d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", + "urls": [ + "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_readme_renderer_py3_none_any_2fbca89b": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "readme_renderer-44.0-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "readme-renderer==44.0", + "sha256": "2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", + "urls": [ + "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_readme_renderer_sdist_8712034e": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "readme_renderer-44.0.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "readme-renderer==44.0", + "sha256": "8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", + "urls": [ + "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_requests_py3_none_any_70761cfe": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "requests-2.32.3-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "requests==2.32.3", + "sha256": "70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", + "urls": [ + "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_requests_sdist_55365417": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "requests-2.32.3.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "requests==2.32.3", + "sha256": "55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", + "urls": [ + "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_requests_toolbelt_py2_none_any_cccfdd66": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "requests_toolbelt-1.0.0-py2.py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "requests-toolbelt==1.0.0", + "sha256": "cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", + "urls": [ + "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_requests_toolbelt_sdist_7681a0a3": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "requests-toolbelt-1.0.0.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "requests-toolbelt==1.0.0", + "sha256": "7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", + "urls": [ + "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_rfc3986_py2_none_any_50b1502b": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "rfc3986-2.0.0-py2.py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "rfc3986==2.0.0", + "sha256": "50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", + "urls": [ + "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_rfc3986_sdist_97aacf9d": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "rfc3986-2.0.0.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "rfc3986==2.0.0", + "sha256": "97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", + "urls": [ + "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_rich_py3_none_any_9836f509": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "rich-13.9.3-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "rich==13.9.3", + "sha256": "9836f5096eb2172c9e77df411c1b009bace4193d6a481d534fea75ebba758283", + "urls": [ + "https://files.pythonhosted.org/packages/9a/e2/10e9819cf4a20bd8ea2f5dabafc2e6bf4a78d6a0965daeb60a4b34d1c11f/rich-13.9.3-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_rich_sdist_bc1e01b8": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "rich-13.9.3.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "rich==13.9.3", + "sha256": "bc1e01b899537598cf02579d2b9f4a415104d3fc439313a7a2c165d76557a08e", + "urls": [ + "https://files.pythonhosted.org/packages/d9/e9/cf9ef5245d835065e6673781dbd4b8911d352fb770d56cf0879cf11b7ee1/rich-13.9.3.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_secretstorage_py3_none_any_f356e662": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "filename": "SecretStorage-3.3.3-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "secretstorage==3.3.3", + "sha256": "f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", + "urls": [ + "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_secretstorage_sdist_2403533e": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "SecretStorage-3.3.3.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "secretstorage==3.3.3", + "sha256": "2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", + "urls": [ + "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_twine_py3_none_any_215dbe7b": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "twine-5.1.1-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "twine==5.1.1", + "sha256": "215dbe7b4b94c2c50a7315c0275d2258399280fbb7d04182c7e55e24b5f93997", + "urls": [ + "https://files.pythonhosted.org/packages/5d/ec/00f9d5fd040ae29867355e559a94e9a8429225a0284a3f5f091a3878bfc0/twine-5.1.1-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_twine_sdist_9aa08251": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "twine-5.1.1.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "twine==5.1.1", + "sha256": "9aa0825139c02b3434d913545c7b847a21c835e11597f5255842d457da2322db", + "urls": [ + "https://files.pythonhosted.org/packages/77/68/bd982e5e949ef8334e6f7dcf76ae40922a8750aa2e347291ae1477a4782b/twine-5.1.1.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_urllib3_py3_none_any_ca899ca0": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "urllib3-2.2.3-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "urllib3==2.2.3", + "sha256": "ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", + "urls": [ + "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_urllib3_sdist_e7d814a8": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "urllib3-2.2.3.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "urllib3==2.2.3", + "sha256": "e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", + "urls": [ + "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz" + ] + } + }, + "rules_python_publish_deps_311_zipp_py3_none_any_a817ac80": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "filename": "zipp-3.20.2-py3-none-any.whl", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "zipp==3.20.2", + "sha256": "a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350", + "urls": [ + "https://files.pythonhosted.org/packages/62/8b/5ba542fa83c90e09eac972fc9baca7a88e7e7ca4b221a89251954019308b/zipp-3.20.2-py3-none-any.whl" + ] + } + }, + "rules_python_publish_deps_311_zipp_sdist_bc9eb26f": { + "repoRuleId": "@@rules_python+//python/private/pypi:whl_library.bzl%whl_library", + "attributes": { + "dep_template": "@rules_python_publish_deps//{name}:{target}", + "experimental_target_platforms": [ + "cp311_linux_aarch64", + "cp311_linux_arm", + "cp311_linux_ppc", + "cp311_linux_s390x", + "cp311_linux_x86_64", + "cp311_osx_aarch64", + "cp311_osx_x86_64", + "cp311_windows_x86_64" + ], + "extra_pip_args": [ + "--index-url", + "https://pypi.org/simple" + ], + "filename": "zipp-3.20.2.tar.gz", + "python_interpreter_target": "@@rules_python++python+python_3_11_host//:python", + "repo": "rules_python_publish_deps_311", + "requirement": "zipp==3.20.2", + "sha256": "bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29", + "urls": [ + "https://files.pythonhosted.org/packages/54/bf/5c0000c44ebc80123ecbdddba1f5dcd94a5ada602a9c225d84b5aaa55e86/zipp-3.20.2.tar.gz" + ] + } + }, + "rules_python_publish_deps": { + "repoRuleId": "@@rules_python+//python/private/pypi:hub_repository.bzl%hub_repository", + "attributes": { + "repo_name": "rules_python_publish_deps", + "extra_hub_aliases": {}, + "whl_map": { + "backports_tarfile": "[{\"filename\":\"backports.tarfile-1.2.0-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_backports_tarfile_py3_none_any_77e284d7\",\"version\":\"3.11\"},{\"filename\":\"backports_tarfile-1.2.0.tar.gz\",\"repo\":\"rules_python_publish_deps_311_backports_tarfile_sdist_d75e02c2\",\"version\":\"3.11\"}]", + "certifi": "[{\"filename\":\"certifi-2024.8.30-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_certifi_py3_none_any_922820b5\",\"version\":\"3.11\"},{\"filename\":\"certifi-2024.8.30.tar.gz\",\"repo\":\"rules_python_publish_deps_311_certifi_sdist_bec941d2\",\"version\":\"3.11\"}]", + "cffi": "[{\"filename\":\"cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl\",\"repo\":\"rules_python_publish_deps_311_cffi_cp311_cp311_manylinux_2_17_aarch64_a1ed2dd2\",\"version\":\"3.11\"},{\"filename\":\"cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl\",\"repo\":\"rules_python_publish_deps_311_cffi_cp311_cp311_manylinux_2_17_ppc64le_46bf4316\",\"version\":\"3.11\"},{\"filename\":\"cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl\",\"repo\":\"rules_python_publish_deps_311_cffi_cp311_cp311_manylinux_2_17_s390x_a24ed04c\",\"version\":\"3.11\"},{\"filename\":\"cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl\",\"repo\":\"rules_python_publish_deps_311_cffi_cp311_cp311_manylinux_2_17_x86_64_610faea7\",\"version\":\"3.11\"},{\"filename\":\"cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl\",\"repo\":\"rules_python_publish_deps_311_cffi_cp311_cp311_musllinux_1_1_aarch64_a9b15d49\",\"version\":\"3.11\"},{\"filename\":\"cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl\",\"repo\":\"rules_python_publish_deps_311_cffi_cp311_cp311_musllinux_1_1_x86_64_fc48c783\",\"version\":\"3.11\"},{\"filename\":\"cffi-1.17.1.tar.gz\",\"repo\":\"rules_python_publish_deps_311_cffi_sdist_1c39c601\",\"version\":\"3.11\"}]", + "charset_normalizer": "[{\"filename\":\"charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl\",\"repo\":\"rules_python_publish_deps_311_charset_normalizer_cp311_cp311_macosx_10_9_universal2_0d99dd8f\",\"version\":\"3.11\"},{\"filename\":\"charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl\",\"repo\":\"rules_python_publish_deps_311_charset_normalizer_cp311_cp311_macosx_10_9_x86_64_c57516e5\",\"version\":\"3.11\"},{\"filename\":\"charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl\",\"repo\":\"rules_python_publish_deps_311_charset_normalizer_cp311_cp311_macosx_11_0_arm64_6dba5d19\",\"version\":\"3.11\"},{\"filename\":\"charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl\",\"repo\":\"rules_python_publish_deps_311_charset_normalizer_cp311_cp311_manylinux_2_17_aarch64_bf4475b8\",\"version\":\"3.11\"},{\"filename\":\"charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl\",\"repo\":\"rules_python_publish_deps_311_charset_normalizer_cp311_cp311_manylinux_2_17_ppc64le_ce031db0\",\"version\":\"3.11\"},{\"filename\":\"charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl\",\"repo\":\"rules_python_publish_deps_311_charset_normalizer_cp311_cp311_manylinux_2_17_s390x_8ff4e7cd\",\"version\":\"3.11\"},{\"filename\":\"charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl\",\"repo\":\"rules_python_publish_deps_311_charset_normalizer_cp311_cp311_manylinux_2_17_x86_64_3710a975\",\"version\":\"3.11\"},{\"filename\":\"charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl\",\"repo\":\"rules_python_publish_deps_311_charset_normalizer_cp311_cp311_musllinux_1_2_aarch64_47334db7\",\"version\":\"3.11\"},{\"filename\":\"charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl\",\"repo\":\"rules_python_publish_deps_311_charset_normalizer_cp311_cp311_musllinux_1_2_ppc64le_f1a2f519\",\"version\":\"3.11\"},{\"filename\":\"charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl\",\"repo\":\"rules_python_publish_deps_311_charset_normalizer_cp311_cp311_musllinux_1_2_s390x_63bc5c4a\",\"version\":\"3.11\"},{\"filename\":\"charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl\",\"repo\":\"rules_python_publish_deps_311_charset_normalizer_cp311_cp311_musllinux_1_2_x86_64_bcb4f8ea\",\"version\":\"3.11\"},{\"filename\":\"charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl\",\"repo\":\"rules_python_publish_deps_311_charset_normalizer_cp311_cp311_win_amd64_cee4373f\",\"version\":\"3.11\"},{\"filename\":\"charset_normalizer-3.4.0-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_charset_normalizer_py3_none_any_fe9f97fe\",\"version\":\"3.11\"},{\"filename\":\"charset_normalizer-3.4.0.tar.gz\",\"repo\":\"rules_python_publish_deps_311_charset_normalizer_sdist_223217c3\",\"version\":\"3.11\"}]", + "cryptography": "[{\"filename\":\"cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl\",\"repo\":\"rules_python_publish_deps_311_cryptography_cp39_abi3_manylinux_2_17_aarch64_846da004\",\"version\":\"3.11\"},{\"filename\":\"cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl\",\"repo\":\"rules_python_publish_deps_311_cryptography_cp39_abi3_manylinux_2_17_x86_64_0f996e72\",\"version\":\"3.11\"},{\"filename\":\"cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl\",\"repo\":\"rules_python_publish_deps_311_cryptography_cp39_abi3_manylinux_2_28_aarch64_f7b178f1\",\"version\":\"3.11\"},{\"filename\":\"cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl\",\"repo\":\"rules_python_publish_deps_311_cryptography_cp39_abi3_manylinux_2_28_x86_64_c2e6fc39\",\"version\":\"3.11\"},{\"filename\":\"cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl\",\"repo\":\"rules_python_publish_deps_311_cryptography_cp39_abi3_musllinux_1_2_aarch64_e1be4655\",\"version\":\"3.11\"},{\"filename\":\"cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl\",\"repo\":\"rules_python_publish_deps_311_cryptography_cp39_abi3_musllinux_1_2_x86_64_df6b6c6d\",\"version\":\"3.11\"},{\"filename\":\"cryptography-43.0.3.tar.gz\",\"repo\":\"rules_python_publish_deps_311_cryptography_sdist_315b9001\",\"version\":\"3.11\"}]", + "docutils": "[{\"filename\":\"docutils-0.21.2-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_docutils_py3_none_any_dafca5b9\",\"version\":\"3.11\"},{\"filename\":\"docutils-0.21.2.tar.gz\",\"repo\":\"rules_python_publish_deps_311_docutils_sdist_3a6b1873\",\"version\":\"3.11\"}]", + "idna": "[{\"filename\":\"idna-3.10-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_idna_py3_none_any_946d195a\",\"version\":\"3.11\"},{\"filename\":\"idna-3.10.tar.gz\",\"repo\":\"rules_python_publish_deps_311_idna_sdist_12f65c9b\",\"version\":\"3.11\"}]", + "importlib_metadata": "[{\"filename\":\"importlib_metadata-8.5.0-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_importlib_metadata_py3_none_any_45e54197\",\"version\":\"3.11\"},{\"filename\":\"importlib_metadata-8.5.0.tar.gz\",\"repo\":\"rules_python_publish_deps_311_importlib_metadata_sdist_71522656\",\"version\":\"3.11\"}]", + "jaraco_classes": "[{\"filename\":\"jaraco.classes-3.4.0-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_jaraco_classes_py3_none_any_f662826b\",\"version\":\"3.11\"},{\"filename\":\"jaraco.classes-3.4.0.tar.gz\",\"repo\":\"rules_python_publish_deps_311_jaraco_classes_sdist_47a024b5\",\"version\":\"3.11\"}]", + "jaraco_context": "[{\"filename\":\"jaraco.context-6.0.1-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_jaraco_context_py3_none_any_f797fc48\",\"version\":\"3.11\"},{\"filename\":\"jaraco_context-6.0.1.tar.gz\",\"repo\":\"rules_python_publish_deps_311_jaraco_context_sdist_9bae4ea5\",\"version\":\"3.11\"}]", + "jaraco_functools": "[{\"filename\":\"jaraco.functools-4.1.0-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_jaraco_functools_py3_none_any_ad159f13\",\"version\":\"3.11\"},{\"filename\":\"jaraco_functools-4.1.0.tar.gz\",\"repo\":\"rules_python_publish_deps_311_jaraco_functools_sdist_70f7e0e2\",\"version\":\"3.11\"}]", + "jeepney": "[{\"filename\":\"jeepney-0.8.0-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_jeepney_py3_none_any_c0a454ad\",\"version\":\"3.11\"},{\"filename\":\"jeepney-0.8.0.tar.gz\",\"repo\":\"rules_python_publish_deps_311_jeepney_sdist_5efe48d2\",\"version\":\"3.11\"}]", + "keyring": "[{\"filename\":\"keyring-25.4.1-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_keyring_py3_none_any_5426f817\",\"version\":\"3.11\"},{\"filename\":\"keyring-25.4.1.tar.gz\",\"repo\":\"rules_python_publish_deps_311_keyring_sdist_b07ebc55\",\"version\":\"3.11\"}]", + "markdown_it_py": "[{\"filename\":\"markdown-it-py-3.0.0.tar.gz\",\"repo\":\"rules_python_publish_deps_311_markdown_it_py_sdist_e3f60a94\",\"version\":\"3.11\"},{\"filename\":\"markdown_it_py-3.0.0-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_markdown_it_py_py3_none_any_35521684\",\"version\":\"3.11\"}]", + "mdurl": "[{\"filename\":\"mdurl-0.1.2-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_mdurl_py3_none_any_84008a41\",\"version\":\"3.11\"},{\"filename\":\"mdurl-0.1.2.tar.gz\",\"repo\":\"rules_python_publish_deps_311_mdurl_sdist_bb413d29\",\"version\":\"3.11\"}]", + "more_itertools": "[{\"filename\":\"more-itertools-10.5.0.tar.gz\",\"repo\":\"rules_python_publish_deps_311_more_itertools_sdist_5482bfef\",\"version\":\"3.11\"},{\"filename\":\"more_itertools-10.5.0-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_more_itertools_py3_none_any_037b0d32\",\"version\":\"3.11\"}]", + "nh3": "[{\"filename\":\"nh3-0.2.18-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl\",\"repo\":\"rules_python_publish_deps_311_nh3_cp37_abi3_macosx_10_12_x86_64_14c5a72e\",\"version\":\"3.11\"},{\"filename\":\"nh3-0.2.18-cp37-abi3-macosx_10_12_x86_64.whl\",\"repo\":\"rules_python_publish_deps_311_nh3_cp37_abi3_macosx_10_12_x86_64_7b7c2a3c\",\"version\":\"3.11\"},{\"filename\":\"nh3-0.2.18-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl\",\"repo\":\"rules_python_publish_deps_311_nh3_cp37_abi3_manylinux_2_17_aarch64_42c64511\",\"version\":\"3.11\"},{\"filename\":\"nh3-0.2.18-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl\",\"repo\":\"rules_python_publish_deps_311_nh3_cp37_abi3_manylinux_2_17_armv7l_0411beb0\",\"version\":\"3.11\"},{\"filename\":\"nh3-0.2.18-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl\",\"repo\":\"rules_python_publish_deps_311_nh3_cp37_abi3_manylinux_2_17_ppc64_5f36b271\",\"version\":\"3.11\"},{\"filename\":\"nh3-0.2.18-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl\",\"repo\":\"rules_python_publish_deps_311_nh3_cp37_abi3_manylinux_2_17_ppc64le_34c03fa7\",\"version\":\"3.11\"},{\"filename\":\"nh3-0.2.18-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl\",\"repo\":\"rules_python_publish_deps_311_nh3_cp37_abi3_manylinux_2_17_s390x_19aaba96\",\"version\":\"3.11\"},{\"filename\":\"nh3-0.2.18-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl\",\"repo\":\"rules_python_publish_deps_311_nh3_cp37_abi3_manylinux_2_17_x86_64_de3ceed6\",\"version\":\"3.11\"},{\"filename\":\"nh3-0.2.18-cp37-abi3-musllinux_1_2_aarch64.whl\",\"repo\":\"rules_python_publish_deps_311_nh3_cp37_abi3_musllinux_1_2_aarch64_f0eca9ca\",\"version\":\"3.11\"},{\"filename\":\"nh3-0.2.18-cp37-abi3-musllinux_1_2_armv7l.whl\",\"repo\":\"rules_python_publish_deps_311_nh3_cp37_abi3_musllinux_1_2_armv7l_3a157ab1\",\"version\":\"3.11\"},{\"filename\":\"nh3-0.2.18-cp37-abi3-musllinux_1_2_x86_64.whl\",\"repo\":\"rules_python_publish_deps_311_nh3_cp37_abi3_musllinux_1_2_x86_64_36c95d4b\",\"version\":\"3.11\"},{\"filename\":\"nh3-0.2.18-cp37-abi3-win_amd64.whl\",\"repo\":\"rules_python_publish_deps_311_nh3_cp37_abi3_win_amd64_8ce0f819\",\"version\":\"3.11\"},{\"filename\":\"nh3-0.2.18.tar.gz\",\"repo\":\"rules_python_publish_deps_311_nh3_sdist_94a16692\",\"version\":\"3.11\"}]", + "pkginfo": "[{\"filename\":\"pkginfo-1.10.0-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_pkginfo_py3_none_any_889a6da2\",\"version\":\"3.11\"},{\"filename\":\"pkginfo-1.10.0.tar.gz\",\"repo\":\"rules_python_publish_deps_311_pkginfo_sdist_5df73835\",\"version\":\"3.11\"}]", + "pycparser": "[{\"filename\":\"pycparser-2.22-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_pycparser_py3_none_any_c3702b6d\",\"version\":\"3.11\"},{\"filename\":\"pycparser-2.22.tar.gz\",\"repo\":\"rules_python_publish_deps_311_pycparser_sdist_491c8be9\",\"version\":\"3.11\"}]", + "pygments": "[{\"filename\":\"pygments-2.18.0-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_pygments_py3_none_any_b8e6aca0\",\"version\":\"3.11\"},{\"filename\":\"pygments-2.18.0.tar.gz\",\"repo\":\"rules_python_publish_deps_311_pygments_sdist_786ff802\",\"version\":\"3.11\"}]", + "pywin32_ctypes": "[{\"filename\":\"pywin32-ctypes-0.2.3.tar.gz\",\"repo\":\"rules_python_publish_deps_311_pywin32_ctypes_sdist_d162dc04\",\"version\":\"3.11\"},{\"filename\":\"pywin32_ctypes-0.2.3-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_pywin32_ctypes_py3_none_any_8a151337\",\"version\":\"3.11\"}]", + "readme_renderer": "[{\"filename\":\"readme_renderer-44.0-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_readme_renderer_py3_none_any_2fbca89b\",\"version\":\"3.11\"},{\"filename\":\"readme_renderer-44.0.tar.gz\",\"repo\":\"rules_python_publish_deps_311_readme_renderer_sdist_8712034e\",\"version\":\"3.11\"}]", + "requests": "[{\"filename\":\"requests-2.32.3-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_requests_py3_none_any_70761cfe\",\"version\":\"3.11\"},{\"filename\":\"requests-2.32.3.tar.gz\",\"repo\":\"rules_python_publish_deps_311_requests_sdist_55365417\",\"version\":\"3.11\"}]", + "requests_toolbelt": "[{\"filename\":\"requests-toolbelt-1.0.0.tar.gz\",\"repo\":\"rules_python_publish_deps_311_requests_toolbelt_sdist_7681a0a3\",\"version\":\"3.11\"},{\"filename\":\"requests_toolbelt-1.0.0-py2.py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_requests_toolbelt_py2_none_any_cccfdd66\",\"version\":\"3.11\"}]", + "rfc3986": "[{\"filename\":\"rfc3986-2.0.0-py2.py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_rfc3986_py2_none_any_50b1502b\",\"version\":\"3.11\"},{\"filename\":\"rfc3986-2.0.0.tar.gz\",\"repo\":\"rules_python_publish_deps_311_rfc3986_sdist_97aacf9d\",\"version\":\"3.11\"}]", + "rich": "[{\"filename\":\"rich-13.9.3-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_rich_py3_none_any_9836f509\",\"version\":\"3.11\"},{\"filename\":\"rich-13.9.3.tar.gz\",\"repo\":\"rules_python_publish_deps_311_rich_sdist_bc1e01b8\",\"version\":\"3.11\"}]", + "secretstorage": "[{\"filename\":\"SecretStorage-3.3.3-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_secretstorage_py3_none_any_f356e662\",\"version\":\"3.11\"},{\"filename\":\"SecretStorage-3.3.3.tar.gz\",\"repo\":\"rules_python_publish_deps_311_secretstorage_sdist_2403533e\",\"version\":\"3.11\"}]", + "twine": "[{\"filename\":\"twine-5.1.1-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_twine_py3_none_any_215dbe7b\",\"version\":\"3.11\"},{\"filename\":\"twine-5.1.1.tar.gz\",\"repo\":\"rules_python_publish_deps_311_twine_sdist_9aa08251\",\"version\":\"3.11\"}]", + "urllib3": "[{\"filename\":\"urllib3-2.2.3-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_urllib3_py3_none_any_ca899ca0\",\"version\":\"3.11\"},{\"filename\":\"urllib3-2.2.3.tar.gz\",\"repo\":\"rules_python_publish_deps_311_urllib3_sdist_e7d814a8\",\"version\":\"3.11\"}]", + "zipp": "[{\"filename\":\"zipp-3.20.2-py3-none-any.whl\",\"repo\":\"rules_python_publish_deps_311_zipp_py3_none_any_a817ac80\",\"version\":\"3.11\"},{\"filename\":\"zipp-3.20.2.tar.gz\",\"repo\":\"rules_python_publish_deps_311_zipp_sdist_bc9eb26f\",\"version\":\"3.11\"}]" + }, + "packages": [ + "backports_tarfile", + "certifi", + "charset_normalizer", + "docutils", + "idna", + "importlib_metadata", + "jaraco_classes", + "jaraco_context", + "jaraco_functools", + "keyring", + "markdown_it_py", + "mdurl", + "more_itertools", + "nh3", + "pkginfo", + "pygments", + "readme_renderer", + "requests", + "requests_toolbelt", + "rfc3986", + "rich", + "twine", + "urllib3", + "zipp" + ], + "groups": {} + } + } + }, + "recordedRepoMappingEntries": [ + [ + "bazel_features+", + "bazel_features_globals", + "bazel_features++version_extension+bazel_features_globals" + ], + [ + "bazel_features+", + "bazel_features_version", + "bazel_features++version_extension+bazel_features_version" + ], + [ + "rules_python+", + "bazel_features", + "bazel_features+" + ], + [ + "rules_python+", + "bazel_skylib", + "bazel_skylib+" + ], + [ + "rules_python+", + "bazel_tools", + "bazel_tools" + ], + [ + "rules_python+", + "pypi__build", + "rules_python++internal_deps+pypi__build" + ], + [ + "rules_python+", + "pypi__click", + "rules_python++internal_deps+pypi__click" + ], + [ + "rules_python+", + "pypi__colorama", + "rules_python++internal_deps+pypi__colorama" + ], + [ + "rules_python+", + "pypi__importlib_metadata", + "rules_python++internal_deps+pypi__importlib_metadata" + ], + [ + "rules_python+", + "pypi__installer", + "rules_python++internal_deps+pypi__installer" + ], + [ + "rules_python+", + "pypi__more_itertools", + "rules_python++internal_deps+pypi__more_itertools" + ], + [ + "rules_python+", + "pypi__packaging", + "rules_python++internal_deps+pypi__packaging" + ], + [ + "rules_python+", + "pypi__pep517", + "rules_python++internal_deps+pypi__pep517" + ], + [ + "rules_python+", + "pypi__pip", + "rules_python++internal_deps+pypi__pip" + ], + [ + "rules_python+", + "pypi__pip_tools", + "rules_python++internal_deps+pypi__pip_tools" + ], + [ + "rules_python+", + "pypi__pyproject_hooks", + "rules_python++internal_deps+pypi__pyproject_hooks" + ], + [ + "rules_python+", + "pypi__setuptools", + "rules_python++internal_deps+pypi__setuptools" + ], + [ + "rules_python+", + "pypi__tomli", + "rules_python++internal_deps+pypi__tomli" + ], + [ + "rules_python+", + "pypi__wheel", + "rules_python++internal_deps+pypi__wheel" + ], + [ + "rules_python+", + "pypi__zipp", + "rules_python++internal_deps+pypi__zipp" + ], + [ + "rules_python+", + "pythons_hub", + "rules_python++python+pythons_hub" + ], + [ + "rules_python++python+pythons_hub", + "python_3_10_host", + "rules_python++python+python_3_10_host" + ], + [ + "rules_python++python+pythons_hub", + "python_3_11_host", + "rules_python++python+python_3_11_host" + ], + [ + "rules_python++python+pythons_hub", + "python_3_12_host", + "rules_python++python+python_3_12_host" + ], + [ + "rules_python++python+pythons_hub", + "python_3_8_host", + "rules_python++python+python_3_8_host" + ], + [ + "rules_python++python+pythons_hub", + "python_3_9_host", + "rules_python++python+python_3_9_host" + ] + ] + } + }, "@@tar.bzl+//tar:extensions.bzl%toolchains": { "general": { "bzlTransitiveDigest": "/2afh6fPjq/rcyE/jztQDK3ierehmFFngfvmqyRv72M=", diff --git a/README.md b/README.md index fa64161..e08f56b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ pnpm add @tummycrypt/tinyvectors Peer dependency: -- `svelte@^5` +- `svelte@>=5.20.0` ## Quick Start @@ -40,6 +40,40 @@ Peer dependency: ``` +Device motion must be requested from a user gesture on browsers that gate sensor APIs: + +```svelte + + + + + +``` + +TinyVectors auto-starts device-orientation motion on secure browsers that do not require a +permission prompt. On permission-gated browsers, keep `enableDeviceMotion={true}` and call +`requestDeviceMotionPermission()` from a user gesture. If sensor events pause or the document is +hidden, TinyVectors resets device motion to neutral so stale tilt cannot keep steering the blobs. +Tune that watchdog with `deviceMotionIdleResetMs` when a host app needs faster or slower sensor +liveness handling. Pointer physics is enabled by default only when pointer, touch, or mouse input is +detected, and resets to center when pointer input is canceled, the pointer leaves the viewport, or +the window blurs. +Use `getDeviceMotionStatus()` on the component handle to inspect support, permission, and listener +state before deciding whether to show motion-permission UI. + ## Entry Points The package exports these public entry points: @@ -67,8 +101,21 @@ Useful extra commands: - `pnpm dev` runs the local Vite demo app - `pnpm dev:watch` rebuilds the library on change - `pnpm test:pbt` runs the property-based invariants only +- `pnpm test:browser:motion` launches a headless Chrome/CDP probe for synthetic orientation, CDP orientation, pointer delivery, directional motion signs, reduced-motion listener lifecycle, and CDP accelerometer input - `pnpm check:release-metadata` verifies `package.json`, `BUILD.bazel`, and `MODULE.bazel` stay aligned - `pnpm check:package` runs `publint` +- `pnpm check:bundle-size` measures the tree-shaken `{ TinyVectors }` consumer bundle with Svelte externalized +- `pnpm check:package-consumer` validates the Bazel-built package from `./bazel-bin/pkg` in a temporary consumer workspace + +The Bazel-to-npm release flow is documented in [docs/release-flow.md](./docs/release-flow.md). +The physics interaction direction is documented in [docs/physics-feel-contract.md](./docs/physics-feel-contract.md). + +The dev app includes a browser/device harness for interaction work: + +- Use the panel toggles to isolate pointer, scroll, and device-motion physics. +- Use `Spoof Tilt` and `Neutral Tilt` to verify TinyVectors motion wiring without relying on browser sensor tooling. +- On a phone or tablet, open the dev URL, tap `Request Motion`, keep the device still, tap `Calibrate`, then tilt the device. +- In desktop Chrome DevTools, use the Sensors panel to emulate orientation changes and watch the motion `x/y/z` status line. The browser probe also exercises Chrome's CDP pointer, reduced-motion media emulation, and accelerometer override paths. ## Release Truth diff --git a/dev/App.svelte b/dev/App.svelte index 33d3bf0..80b92ba 100644 --- a/dev/App.svelte +++ b/dev/App.svelte @@ -1,25 +1,74 @@
@@ -32,7 +81,9 @@ diff --git a/dev/index.html b/dev/index.html index 614988b..29c36df 100644 --- a/dev/index.html +++ b/dev/index.html @@ -9,38 +9,73 @@ html, body { width: 100%; height: 100%; + min-height: 100vh; + min-height: 100dvh; font-family: system-ui, -apple-system, sans-serif; + overflow: hidden; } + html, body { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); } + html.light, body.light { background: linear-gradient(135deg, #f0f0f0 0%, #e0e0e0 50%, #d0d0d0 100%); } #app { + position: fixed; + inset: 0; width: 100%; - height: 100%; + height: 100vh; + height: 100dvh; } .controls { position: fixed; top: 16px; right: 16px; z-index: 1000; - background: rgba(0, 0, 0, 0.7); + width: min(282px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); + max-height: calc(100dvh - 32px); + overflow: auto; + background: rgba(6, 8, 18, 0.9); + backdrop-filter: blur(14px); padding: 16px; border-radius: 8px; color: white; font-size: 14px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32); } .controls label { - display: block; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; margin-bottom: 8px; } + .controls .checkbox-row { + grid-template-columns: auto minmax(0, 1fr); + justify-content: start; + } + .controls .range-row { + grid-template-columns: minmax(0, 1fr) minmax(118px, 1.2fr) auto; + } .controls select, .controls input { - margin-left: 8px; padding: 4px 8px; border-radius: 4px; border: none; + min-width: 0; + } + .controls select { + max-width: 132px; + } + .controls input[type="range"] { + width: 100%; + } + .controls input[type="checkbox"] { + width: 14px; + height: 14px; + padding: 0; } .controls button { margin-top: 8px; @@ -51,15 +86,36 @@ color: white; cursor: pointer; } + .controls .button-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + } + .controls .button-row button { + margin-top: 0; + padding: 8px 10px; + } + .controls .status { + display: block; + margin-top: 8px; + min-height: 18px; + opacity: 0.75; + } .controls button:hover { background: #4a7de0; } + .hide-controls .controls { + display: none; + } /* Mobile responsive */ - @media (max-width: 480px) { + @media (max-width: 720px) { .controls { top: 8px; - right: 8px; left: 8px; + right: auto; + width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + max-height: calc(100dvh - 16px); padding: 12px; font-size: 12px; } @@ -68,18 +124,22 @@ margin-bottom: 8px; } .controls label { - display: flex; - align-items: center; - justify-content: space-between; margin-bottom: 6px; } - .controls select, .controls input[type="range"] { - max-width: 120px; + .controls select { + width: min(132px, 40vw); + max-width: 132px; + } + .controls .range-row { + grid-template-columns: minmax(0, 1fr) minmax(96px, 36vw) auto; } .controls button { width: 100%; padding: 10px; } + .controls .button-row { + grid-template-columns: 1fr; + } } @@ -88,26 +148,47 @@

TinyVectors Dev

-
diff --git a/dev/main.ts b/dev/main.ts index 8bff2d2..95c9d9b 100644 --- a/dev/main.ts +++ b/dev/main.ts @@ -1,11 +1,98 @@ import { mount, unmount } from 'svelte'; import App from './App.svelte'; +import type { MotionVector } from '../src/motion/DeviceMotion.js'; +import type { TinyVectorsDeviceMotionStatus } from '../src/svelte/types.js'; + +interface DevAppHandle { + requestDeviceMotionPermission: () => Promise; + calibrateDeviceMotion: (samples?: number) => void; + getDeviceMotionStatus: () => TinyVectorsDeviceMotionStatus; +} + +interface DevWindow extends Window { + __tinyvectorsDeviceMotionStatus?: () => TinyVectorsDeviceMotionStatus | null; +} + +const params = new URLSearchParams(window.location.search); +const themes = ['tinyland', 'trans', 'pride'] as const; + +function booleanParam(name: string, fallback: boolean): boolean { + const value = params.get(name); + if (value === null) return fallback; + return !['0', 'false', 'off', 'no'].includes(value.toLowerCase()); +} + +function numberParam(name: string, fallback: number, min: number, max: number): number { + const value = Number(params.get(name)); + if (!Number.isFinite(value)) return fallback; + return Math.max(min, Math.min(max, Math.round(value))); +} + +function themeParam(): (typeof themes)[number] { + const value = params.get('theme'); + return themes.includes(value as (typeof themes)[number]) ? (value as (typeof themes)[number]) : 'tinyland'; +} + +const initialDarkMode = booleanParam('dark', true); +const showControls = booleanParam('controls', true); +const devWindow = window as DevWindow; +document.body.classList.toggle('dark', initialDarkMode); +document.body.classList.toggle('light', !initialDarkMode); +document.body.classList.toggle('hide-controls', !showControls); +document.documentElement.classList.toggle('dark', initialDarkMode); +document.documentElement.classList.toggle('light', !initialDarkMode); + +let app: (ReturnType & DevAppHandle) | null = null; + +function updateMotionStatus(text: string): void { + const motionStatus = document.getElementById('motion-status'); + if (motionStatus) { + motionStatus.textContent = text; + } +} + +function formatMotionSample(sample: MotionVector): string { + const x = sample.x.toFixed(2); + const y = sample.y.toFixed(2); + const z = sample.z.toFixed(2); + return `motion x ${x} y ${y} z ${z}`; +} + +function createDeviceOrientationEvent(alpha: number, beta: number, gamma: number): Event { + if (typeof DeviceOrientationEvent === 'function') { + return new DeviceOrientationEvent('deviceorientation', { + alpha, + beta, + gamma, + absolute: false, + }); + } + + const event = new Event('deviceorientation'); + Object.defineProperties(event, { + alpha: { value: alpha }, + beta: { value: beta }, + gamma: { value: gamma }, + absolute: { value: false }, + }); + return event; +} + +function spoofOrientation(alpha: number, beta: number, gamma: number): void { + window.dispatchEvent(createDeviceOrientationEvent(alpha, beta, gamma)); +} -let app: ReturnType | null = null; let currentProps = { - theme: 'tinyland' as 'tinyland' | 'trans' | 'pride', - blobCount: 12, - animated: true, + theme: themeParam(), + blobCount: numberParam('blobs', 8, 4, 16), + animated: booleanParam('animated', true), + enableDeviceMotion: booleanParam('deviceMotion', true), + enableScrollPhysics: booleanParam('scrollPhysics', true), + enablePointerPhysics: booleanParam('pointerPhysics', true), + deviceMotionIdleResetMs: numberParam('motionIdleReset', 2000, 0, 10000), + onMotionSample(sample: MotionVector) { + updateMotionStatus(formatMotionSample(sample)); + }, }; function mountApp() { @@ -19,7 +106,8 @@ function mountApp() { app = mount(App, { target, props: currentProps, - }); + }) as ReturnType & DevAppHandle; + devWindow.__tinyvectorsDeviceMotionStatus = () => app?.getDeviceMotionStatus() ?? null; } @@ -27,6 +115,9 @@ mountApp(); const themeSelect = document.getElementById('theme-select') as HTMLSelectElement; +if (themeSelect) { + themeSelect.value = currentProps.theme; +} themeSelect?.addEventListener('change', () => { currentProps.theme = themeSelect.value as 'tinyland' | 'trans' | 'pride'; mountApp(); @@ -35,6 +126,12 @@ themeSelect?.addEventListener('change', () => { const blobCountSlider = document.getElementById('blob-count') as HTMLInputElement; const blobCountValue = document.getElementById('blob-count-value'); +if (blobCountSlider) { + blobCountSlider.value = String(currentProps.blobCount); +} +if (blobCountValue) { + blobCountValue.textContent = String(currentProps.blobCount); +} blobCountSlider?.addEventListener('input', () => { currentProps.blobCount = parseInt(blobCountSlider.value, 10); if (blobCountValue) { @@ -47,19 +144,74 @@ blobCountSlider?.addEventListener('change', () => { const darkModeCheckbox = document.getElementById('dark-mode') as HTMLInputElement; +if (darkModeCheckbox) { + darkModeCheckbox.checked = initialDarkMode; +} darkModeCheckbox?.addEventListener('change', () => { document.body.classList.toggle('dark', darkModeCheckbox.checked); document.body.classList.toggle('light', !darkModeCheckbox.checked); document.documentElement.classList.toggle('dark', darkModeCheckbox.checked); + document.documentElement.classList.toggle('light', !darkModeCheckbox.checked); }); const animatedCheckbox = document.getElementById('animated') as HTMLInputElement; +if (animatedCheckbox) { + animatedCheckbox.checked = currentProps.animated; +} animatedCheckbox?.addEventListener('change', () => { currentProps.animated = animatedCheckbox.checked; mountApp(); }); +const deviceMotionCheckbox = document.getElementById('device-motion') as HTMLInputElement; +if (deviceMotionCheckbox) { + deviceMotionCheckbox.checked = currentProps.enableDeviceMotion; +} +deviceMotionCheckbox?.addEventListener('change', () => { + currentProps.enableDeviceMotion = deviceMotionCheckbox.checked; + mountApp(); +}); + +const scrollPhysicsCheckbox = document.getElementById('scroll-physics') as HTMLInputElement; +if (scrollPhysicsCheckbox) { + scrollPhysicsCheckbox.checked = currentProps.enableScrollPhysics; +} +scrollPhysicsCheckbox?.addEventListener('change', () => { + currentProps.enableScrollPhysics = scrollPhysicsCheckbox.checked; + mountApp(); +}); + +const pointerPhysicsCheckbox = document.getElementById('pointer-physics') as HTMLInputElement; +if (pointerPhysicsCheckbox) { + pointerPhysicsCheckbox.checked = currentProps.enablePointerPhysics; +} +pointerPhysicsCheckbox?.addEventListener('change', () => { + currentProps.enablePointerPhysics = pointerPhysicsCheckbox.checked; + mountApp(); +}); + +const requestMotionBtn = document.getElementById('request-motion-btn'); +requestMotionBtn?.addEventListener('click', async () => { + const granted = (await app?.requestDeviceMotionPermission()) ?? false; + updateMotionStatus(granted ? 'motion granted; waiting for sample' : 'motion unavailable'); +}); + +const calibrateMotionBtn = document.getElementById('calibrate-motion-btn'); +calibrateMotionBtn?.addEventListener('click', () => { + app?.calibrateDeviceMotion(10); + updateMotionStatus('calibration queued'); +}); + +const spoofTiltBtn = document.getElementById('spoof-tilt-btn'); +spoofTiltBtn?.addEventListener('click', () => { + spoofOrientation(120, 35, -45); +}); + +const neutralTiltBtn = document.getElementById('neutral-tilt-btn'); +neutralTiltBtn?.addEventListener('click', () => { + spoofOrientation(0, 0, 0); +}); const reloadBtn = document.getElementById('reload-btn'); reloadBtn?.addEventListener('click', () => { diff --git a/docs/physics-feel-contract.md b/docs/physics-feel-contract.md new file mode 100644 index 0000000..b7e7b7b --- /dev/null +++ b/docs/physics-feel-contract.md @@ -0,0 +1,61 @@ +# TinyVectors Physics Feel Contract + +TinyVectors is an expressive background system for Svelte and SvelteKit apps, not a physics demo. The animation should feel alive before any user input happens. Device motion, pointer movement, and scrolling should bias that ambient motion instead of taking control of it. + +This document is the local source of truth for the field-based interaction work tracked in Linear `TIN-853` and GitHub #40. + +## Product Intent + +- Pleasant by default: idle blobs drift, breathe, and deform subtly. +- App-safe: the component stays SSR-safe, reduced-motion aware, listener-clean, and small enough for background use. +- Stylable: gel/fluid is a visual language exposed through themes, colors, opacity, and restrained renderer controls. +- Performant: interaction work must preserve the package's bundle budget and avoid heavyweight simulation dependencies. + +## Interaction Model + +Every input should become a small field sampled by the blob physics loop: + +- Ambient field: always on, low-frequency, bounded motion. This is the baseline feel. +- Gravity field: slow directional bias from device orientation. It should make blobs lean or pool, not fall like marbles. +- Pointer field: local soft influence around the pointer. Nearby blobs should react more than distant blobs. +- Scroll field: transient impulse or stickiness that decays. It should not create permanent acceleration. +- Wall field: bounds should keep the background composed without hard visual snaps. +- Input liveness: real sensor and pointer IO should auto-enable only when available. If device-orientation events go quiet, the tab is hidden, pointer input is canceled, the pointer leaves the viewport, or the window blurs, the field must return to neutral instead of preserving stale input. + +Fields may combine, but input fields must not erase the ambient field. If a field makes the background look frozen, jittery, or overly coherent, it violates the contract. + +## Non-Goals + +- Do not revive the Phase A XSPH, soft-wall, and Gaussian anti-clustering rewrite as-is. +- Do not ship coefficient-only tuning without a contract and browser/demo validation. +- Do not introduce a heavyweight fluid solver. +- Do not make the background capture pointer events. + +## Test Strategy + +Tests should describe perceptual behavior in tolerant terms: + +- idle drift is present and bounded; +- gravity creates directional bias without overpowering all motion; +- pointer influence is local and distance-weighted; +- scroll effects decay; +- listener lifecycle stays clean; +- bundle size stays within the configured gate. + +Avoid tests that lock exact coefficients, frame-by-frame positions, or one-off screenshot pixels unless the assertion is about a real compatibility contract. + +## Current Status + +- Gravity/device-orientation is routed through `InteractionField.directionalBiasField()` and cached as a bounded force outside the per-blob hot path. +- The browser probe verifies synthetic and CDP orientation events preserve the expected motion signs, change blob geometry, return to neutral on idle or reduced motion, and receive a real CDP pointer move while pointer physics is active. +- Pointer IO updates the physics pointer anchor, velocity, and per-blob `mouseDistance`; unit coverage verifies the first standalone route applies a small local pointer field only after real pointer input. +- Scroll still uses the restored pre-Phase-A path and can use the pointer anchor for sticky attraction. Route pointer and scroll through fields only after preserving the current feel and bundle headroom. + +## Implementation Slices + +1. Keep PR #39 on the restored pre-Phase-A physics and renderer baseline while retaining the motion harness, lifecycle, pointer, package, and CI work. +2. Add pure field helpers and unit tests without changing runtime feel. +3. Route gravity/device-orientation through the field helper while preserving ambient motion. +4. Route pointer and scroll values through field helpers one input at a time, starting with a low-strength local pointer field. +5. Add browser probes for directional bias, pointer locality, and scroll decay. +6. Revisit renderer stylability after interaction feel is stable. diff --git a/docs/release-flow.md b/docs/release-flow.md new file mode 100644 index 0000000..3c72bf3 --- /dev/null +++ b/docs/release-flow.md @@ -0,0 +1,66 @@ +# Release Flow + +This repo publishes the npm package as the primary consumer artifact. Bazel exists here to produce and validate the same package shape used by downstream Bazel consumers. + +## Authority Chain + +1. `package.json` is the npm package authority for name, version, entry points, package manager, and publish config. +2. `MODULE.bazel` mirrors the package version for Bzlmod consumers. +3. `BUILD.bazel` builds the runtime package with Vite, emits declarations with `tsc`, and assembles `//:pkg` with `npm_package`. +4. `.bazelversion` pins the Bazel runtime. Local Nix exposes `bazel` through Bazelisk so the dev shell follows that pin. +5. `.github/workflows/ci.yml` and `.github/workflows/publish.yml` call the same pinned reusable package workflow. + +`pnpm run check:release-metadata` verifies these surfaces stay aligned before CI, Bazel, or npm publish steps run. + +## Local Verification + +Run the CI checks plus the local consumer check for the Bazel-built package: + +```bash +pnpm run check:release-metadata +pnpm run check +pnpm run test +pnpm run build +pnpm run check:package +pnpm run check:bundle-size +nix develop . --command bazel build //:pkg //:package_consumer_check //:bundle_size_check //:typecheck //:test --verbose_failures +pnpm run check:package-consumer +npm pack --dry-run ./bazel-bin/pkg +npm publish --dry-run --ignore-scripts --access public ./bazel-bin/pkg +``` + +`//:package_consumer_check` and `pnpm run check:package-consumer` both validate the Bazel-built package as an installed consumer would. The pnpm command expects `./bazel-bin/pkg` to exist. It links that package into a temporary consumer workspace with the Svelte peer dependency, verifies runtime subpath exports, and runs TypeScript against the packaged declarations. + +`pnpm run check:bundle-size` measures a realistic tree-shaken consumer import, `import { TinyVectors } from '@tummycrypt/tinyvectors'`, with Svelte externalized as a peer dependency. `//:bundle_size_check` runs the same measurement against the Bazel-built package artifact. The current gate is 12 KiB gzip and the target remains 11 KiB gzip, so the check reports target headroom or overage while leaving a small CI buffer. + +The bundle-size check also reports tracked runtime modules that enter that consumer bundle. `dist/core/InteractionField.js` is expected to appear once runtime physics routes an input through the field contract, and the gzip result is the source of truth for whether that cost is acceptable. + +`bazel query //...` should also work locally. `.bazelignore` excludes direnv, Nix, package-manager, and build-output directories so Bazel does not walk generated local artifacts. + +## Compatibility Notes + +The v0.3 branch currently keeps the renderer-private `--tv-blob-intensity` custom property used by the restored three-layer renderer. Do not document a migration to `--tvi`; that abbreviation was part of the reverted gel-rendering rewrite. + +## CI Flow + +Pull requests and pushes to `main` run `Verify`, which calls `tinyland-inc/ci-templates/.github/workflows/js-bazel-package.yml` at a pinned commit. The reusable workflow: + +- installs the configured pnpm and Node major; +- runs metadata, typecheck, test, build, package, and bundle-size checks; +- builds `//:pkg //:package_consumer_check //:bundle_size_check //:typecheck //:test` through Bazelisk; +- validates the Bazel-built package with `npm pack --dry-run`; +- validates npm publication with `npm publish --dry-run --ignore-scripts`. + +This means CI treats the Bazel package output as the release candidate, not the local `dist/` directory alone. + +## Publish Flow + +Tags matching `v*` run `Publish to npm`. The publish workflow reuses the same package workflow with `dry_run: false`, downloads the Bazel-built package artifact, and publishes that isolated artifact to npm. + +The workflow has `id-token: write` because npm provenance and trusted publishing both depend on OIDC-capable CI. The current reusable template still accepts `NPM_TOKEN`; moving fully to npm trusted publishing should happen in the shared template, not only in this repo. + +## FlakeHub Status + +The flake is currently a development environment only. It does not publish TinyVectors to FlakeHub and does not expose package outputs. + +If FlakeHub publication becomes useful, add it as a separate release surface with its own workflow and metadata checks. FlakeHub publication should use its trusted-platform publishing model rather than ad hoc local publishing. diff --git a/flake.nix b/flake.nix index c0e14ea..3657547 100644 --- a/flake.nix +++ b/flake.nix @@ -4,12 +4,25 @@ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, flake-utils }: - flake-utils.lib.eachDefaultSystem (system: - let pkgs = nixpkgs.legacyPackages.${system}; in { + outputs = + { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + bazel = pkgs.writeShellScriptBin "bazel" '' + exec ${pkgs.bazelisk}/bin/bazelisk "$@" + ''; + in + { devShells.default = pkgs.mkShell { buildInputs = [ - pkgs.bazel_8 + bazel + pkgs.bazelisk pkgs.nodejs_22 (pkgs.pnpm_9 or pkgs.pnpm) ]; @@ -17,10 +30,10 @@ echo "tinyvectors dev shell" echo " node $(node --version)" echo " pnpm $(pnpm --version)" - echo " bazel $(bazel --version | head -n1)" + echo " bazel $(cat .bazelversion) via bazelisk" ''; }; - formatter = pkgs.nixfmt-rfc-style; + formatter = pkgs.nixfmt; } ); } diff --git a/package.json b/package.json index 389cac7..9b73641 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tummycrypt/tinyvectors", - "version": "0.2.5", + "version": "0.3.0", "description": "Animated vector blob backgrounds with physics simulation for Svelte 5", "type": "module", "packageManager": "pnpm@9.15.9", @@ -57,6 +57,7 @@ } }, "files": [ + "CHANGELOG.md", "dist", "dist-types", "src/themes/vector-colors.css", @@ -67,19 +68,22 @@ "**/*.css" ], "scripts": { - "build": "vite build && tsc -p tsconfig.declarations.json", + "build": "vite build && node scripts/build-declarations.mjs", "dev": "vite --config vite.dev.config.ts", "dev:watch": "vite build --watch", "check": "svelte-check --tsconfig ./tsconfig.json", "check:release-metadata": "node scripts/check-release-metadata.mjs", "check:package": "publint", + "check:bundle-size": "node scripts/check-bundle-size.mjs", + "check:package-consumer": "node scripts/check-package-consumer.mjs ./bazel-bin/pkg", "test": "vitest run", + "test:browser:motion": "node scripts/probe-motion-cdp.mjs", "test:watch": "vitest", "test:pbt": "vitest run --testNamePattern='INVARIANT'", - "prepublishOnly": "pnpm run check:release-metadata && pnpm run build && pnpm run check:package" + "prepublishOnly": "pnpm run check:release-metadata && pnpm run build && pnpm run check:package && pnpm run check:bundle-size" }, "peerDependencies": { - "svelte": "^5.0.0" + "svelte": ">=5.20.0" }, "devDependencies": { "@sveltejs/package": "^2.5.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44f16ea..ef64141 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -597,8 +597,8 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + postcss@8.5.12: + resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} engines: {node: ^10 || ^12 || >=14} publint@0.2.12: @@ -1217,7 +1217,7 @@ snapshots: picomatch@4.0.4: {} - postcss@8.5.8: + postcss@8.5.12: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -1342,7 +1342,7 @@ snapshots: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.8 + postcss: 8.5.12 rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: diff --git a/scripts/build-declarations.mjs b/scripts/build-declarations.mjs new file mode 100644 index 0000000..c28a673 --- /dev/null +++ b/scripts/build-declarations.mjs @@ -0,0 +1,14 @@ +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const tscPath = require.resolve('typescript/lib/tsc.js'); +const result = spawnSync(process.execPath, [tscPath, '-p', 'tsconfig.declarations.json'], { + stdio: 'inherit', +}); + +if (result.status !== 0) { + process.exit(result.status ?? 1); +} + +await import('./copy-svelte-declarations.mjs'); diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs new file mode 100644 index 0000000..7365b0d --- /dev/null +++ b/scripts/check-bundle-size.mjs @@ -0,0 +1,119 @@ +import { existsSync } from 'node:fs'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gzipSync } from 'node:zlib'; +import { build } from 'vite'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageRoot = resolve(process.cwd(), process.argv[2] ?? '.'); +const distEntry = resolve(packageRoot, 'dist/index.js'); +const targetGzipKiB = parsePositiveKiB('TINYVECTORS_TARGET_GZIP_KIB', 11); +const maxGzipKiB = parsePositiveKiB('TINYVECTORS_MAX_GZIP_KIB', 12); +const trackedConsumerModules = ['dist/core/InteractionField.js']; + +if (maxGzipKiB < targetGzipKiB) { + console.error( + 'TINYVECTORS_MAX_GZIP_KIB must be greater than or equal to TINYVECTORS_TARGET_GZIP_KIB', + ); + process.exit(1); +} + +if (!existsSync(distEntry)) { + console.error(`Bundle entry is missing: ${distEntry}`); + console.error('Run: pnpm run build'); + process.exit(1); +} + +const tempDir = await mkdtemp(join(tmpdir(), 'tinyvectors-bundle-size-')); + +try { + const entry = join(tempDir, 'consumer-entry.js'); + await writeFile( + entry, + ` +import { TinyVectors } from ${JSON.stringify(distEntry)}; +console.log(TinyVectors); +`.trimStart(), + ); + + const output = await build({ + configFile: false, + logLevel: 'silent', + build: { + write: false, + minify: 'esbuild', + target: 'es2022', + rollupOptions: { + input: entry, + external: (id) => id === 'svelte' || id.startsWith('svelte/'), + output: { + format: 'es', + inlineDynamicImports: true, + }, + }, + }, + }); + + const outputs = Array.isArray(output) + ? output.flatMap((bundle) => bundle.output) + : output.output; + const chunks = outputs.filter((item) => item.type === 'chunk'); + const includedModules = new Set( + chunks.flatMap((chunk) => + chunk.moduleIds.map((moduleId) => moduleId.replaceAll('\\', '/')), + ), + ); + const trackedIncluded = trackedConsumerModules.filter((modulePath) => + [...includedModules].some((moduleId) => moduleId.endsWith(`/${modulePath}`)), + ); + + const js = outputs + .filter((item) => item.type === 'chunk') + .map((item) => item.code) + .join('\n'); + const rawKiB = js.length / 1024; + const gzipKiB = gzipSync(js).length / 1024; + const targetDelta = gzipKiB - targetGzipKiB; + + console.log( + [ + `bundle size check for ${relativeFromRepo(distEntry)}`, + `consumer import: { TinyVectors }`, + `raw ${rawKiB.toFixed(2)} KiB, gzip ${gzipKiB.toFixed(2)} KiB`, + `target ${targetGzipKiB.toFixed(2)} KiB, gate ${maxGzipKiB.toFixed(2)} KiB`, + targetDelta <= 0 + ? `target headroom ${Math.abs(targetDelta).toFixed(2)} KiB` + : `target overage ${targetDelta.toFixed(2)} KiB`, + trackedIncluded.length > 0 + ? `tracked modules included: ${trackedIncluded.join(', ')}` + : 'tracked modules included: none', + ].join('\n'), + ); + + if (gzipKiB > maxGzipKiB) { + console.error( + `Consumer bundle gzip ${gzipKiB.toFixed(2)} KiB exceeds ${maxGzipKiB.toFixed(2)} KiB gate`, + ); + process.exit(1); + } +} finally { + await rm(tempDir, { recursive: true, force: true }); +} + +function relativeFromRepo(path) { + return path.startsWith(`${repoRoot}/`) ? path.slice(repoRoot.length + 1) : path; +} + +function parsePositiveKiB(envName, defaultValue) { + const rawValue = process.env[envName]; + const value = rawValue == null ? defaultValue : Number(rawValue.trim()); + + if (!Number.isFinite(value) || value <= 0) { + console.error(`${envName} must be a positive number`); + process.exit(1); + } + + return value; +} diff --git a/scripts/check-package-consumer.mjs b/scripts/check-package-consumer.mjs new file mode 100644 index 0000000..b3a9784 --- /dev/null +++ b/scripts/check-package-consumer.mjs @@ -0,0 +1,163 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageDir = resolve(process.cwd(), process.argv[2] ?? './bazel-bin/pkg'); +const svelteDir = resolve(repoRoot, 'node_modules/svelte'); +const tscPath = require.resolve('typescript/lib/tsc.js'); + +if (!existsSync(resolve(packageDir, 'package.json'))) { + console.error(`Package directory is missing package.json: ${packageDir}`); + console.error('Run: nix develop . --command bazel build //:pkg'); + process.exit(1); +} + +if (!existsSync(resolve(svelteDir, 'package.json'))) { + console.error(`Svelte peer dependency is missing: ${svelteDir}`); + console.error('Run: pnpm install'); + process.exit(1); +} + +const tempDir = await mkdtemp(join(tmpdir(), 'tinyvectors-consumer-')); + +try { + await mkdir(join(tempDir, 'node_modules/@tummycrypt'), { recursive: true }); + await symlink( + packageDir, + join(tempDir, 'node_modules/@tummycrypt/tinyvectors'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + await symlink( + svelteDir, + join(tempDir, 'node_modules/svelte'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + + await writeFile( + join(tempDir, 'consumer-runtime.mjs'), + ` +import * as root from '@tummycrypt/tinyvectors'; +import * as motion from '@tummycrypt/tinyvectors/motion'; +import * as core from '@tummycrypt/tinyvectors/core'; +import * as themes from '@tummycrypt/tinyvectors/themes'; +import * as svelteComponents from '@tummycrypt/tinyvectors/svelte'; +import { readFileSync } from 'node:fs'; + +const cssUrl = import.meta.resolve('@tummycrypt/tinyvectors/themes/css'); +const css = readFileSync(new URL(cssUrl), 'utf8'); +const requiredRoot = ['BlobPhysics', 'DeviceMotion', 'TinyVectors', 'THEME_PRESETS']; +const requiredMotion = ['DeviceMotion', 'ScrollHandler', 'mapClientPointToPhysics', 'createPointerPhysicsController']; +const tinylandColors = root.THEME_PRESETS?.tinyland?.colors?.map((color) => color.color) ?? []; +const missing = [ + ...requiredRoot.filter((name) => !(name in root)).map((name) => \`root:\${name}\`), + ...requiredMotion.filter((name) => !(name in motion)).map((name) => \`motion:\${name}\`), + ...(!('BlobPhysics' in core) ? ['core:BlobPhysics'] : []), + ...(!('THEME_PRESETS' in themes) ? ['themes:THEME_PRESETS'] : []), + ...(tinylandColors.includes('rgba(139, 92, 246, 0.55)') ? [] : ['root:THEME_PRESETS.tinyland.colors']), + ...(themes.getThemePreset?.('tinyland') === themes.THEME_PRESETS?.tinyland ? [] : ['themes:getThemePreset']), + ...(!('TinyVectors' in svelteComponents) ? ['svelte:TinyVectors'] : []), + ...(!('BlobSVG' in svelteComponents) ? ['svelte:BlobSVG'] : []), + ...(css.includes('--vector-tinyland-purple') ? [] : ['themes/css:variables']), +]; + +if (missing.length > 0) { + throw new Error(\`Missing exports: \${missing.join(', ')}\`); +} +`.trimStart(), + ); + + await writeFile( + join(tempDir, 'consumer-types.ts'), + ` +import { BlobPhysics, DeviceMotion, TinyVectors, THEME_PRESETS } from '@tummycrypt/tinyvectors'; +import type { ScrollHandlerConfig as RootScrollHandlerConfig } from '@tummycrypt/tinyvectors'; +import type { ThemePreset, ThemePresetName } from '@tummycrypt/tinyvectors/core'; +import { +\tScrollHandler, +\tcreatePointerPhysicsController, +\tmapClientPointToPhysics, +\ttype MotionVector, +\ttype PointerBounds, +\ttype PointerCancelEventName, +\ttype ScrollHandlerConfig, +} from '@tummycrypt/tinyvectors/motion'; +import { getThemePreset } from '@tummycrypt/tinyvectors/themes'; +import { +\tBlobSVG, +\ttype BlobSVGProps, +\ttype TinyVectorsDeviceMotionStatus, +\ttype TinyVectorsProps, +} from '@tummycrypt/tinyvectors/svelte'; +import type { ComponentProps } from 'svelte'; + +const bounds: PointerBounds = { left: 0, top: 0, width: 100, height: 100 }; +const cancelEvent: PointerCancelEventName = 'pointercancel'; +const scrollConfig: ScrollHandlerConfig = { decayRate: 0.9, maxForces: 2 }; +const rootScrollConfig: RootScrollHandlerConfig = { maxForces: 0 }; +const scrollHandler = new ScrollHandler(scrollConfig); +const point = mapClientPointToPhysics(50, 50, bounds); +const sample: MotionVector = { x: 0, y: 0, z: 1 }; +const props: ComponentProps = { theme: 'tinyland', enableDeviceMotion: true }; +const explicitProps: TinyVectorsProps = props; +const motionStatus: TinyVectorsDeviceMotionStatus = { +\tenabled: true, +\tsupported: true, +\trequiresPermission: false, +\tpermissionState: 'granted', +\tactive: true, +}; +const blobProps: BlobSVGProps = { blobs: [] }; +const themeName: ThemePresetName = 'tinyland'; +const themePreset: ThemePreset = THEME_PRESETS[themeName]; +const names = [BlobPhysics, DeviceMotion, TinyVectors, BlobSVG, ScrollHandler, createPointerPhysicsController, THEME_PRESETS, getThemePreset, scrollHandler, scrollConfig, rootScrollConfig, point, sample, explicitProps, motionStatus, blobProps, themePreset, cancelEvent]; +console.log(names.length); +`.trimStart(), + ); + + await writeFile( + join(tempDir, 'tsconfig.json'), + `${JSON.stringify( + { + compilerOptions: { + target: 'ES2022', + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + skipLibCheck: false, + noEmit: true, + }, + include: ['consumer-types.ts'], + }, + null, + 2, + )}\n`, + ); + + run(process.execPath, ['consumer-runtime.mjs'], tempDir); + run(process.execPath, [tscPath, '-p', 'tsconfig.json'], tempDir); + console.log(`package consumer check passed for ${packageDir}`); +} finally { + if (process.env.TINYVECTORS_KEEP_CONSUMER_CHECK !== '1') { + await rm(tempDir, { recursive: true, force: true }); + } else { + console.log(`kept consumer check workspace: ${tempDir}`); + } +} + +function run(command, args, cwd) { + const result = spawnSync(command, args, { + cwd, + stdio: 'inherit', + env: process.env, + }); + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} diff --git a/scripts/check-release-metadata.mjs b/scripts/check-release-metadata.mjs index 2c95fde..0032888 100644 --- a/scripts/check-release-metadata.mjs +++ b/scripts/check-release-metadata.mjs @@ -6,7 +6,33 @@ const read = (relativePath) => const packageJson = JSON.parse(read('../package.json')); const moduleBazel = read('../MODULE.bazel'); const buildBazel = read('../BUILD.bazel'); +const ciWorkflow = read('../.github/workflows/ci.yml'); +const publishWorkflow = read('../.github/workflows/publish.yml'); const expectedPnpmVersion = packageJson.packageManager?.replace(/^pnpm@/, ''); +const expectedNodeMajor = packageJson.engines?.node?.match(/>=\s*(\d+)/)?.[1]; +const expectedBazelTargets = + '//:pkg //:package_consumer_check //:bundle_size_check //:typecheck //:test'; +const expectedPackageDir = './bazel-bin/pkg'; +const expectedPackageConsumerCommand = 'node scripts/check-package-consumer.mjs ./bazel-bin/pkg'; +const expectedPackageCheckCommand = 'pnpm run check:package && pnpm run check:bundle-size'; +const expectedPrepublishOnlyCommand = + 'pnpm run check:release-metadata && pnpm run build && pnpm run check:package && pnpm run check:bundle-size'; +const expectedSharedWorkflowInputs = { + runner_mode: 'hosted', + workspace_mode: 'isolated', + publish_mode: 'same_runner', + node_versions: `["${expectedNodeMajor}"]`, + publish_node_version: expectedNodeMajor, + pnpm_version: expectedPnpmVersion, + metadata_check_command: 'pnpm run check:release-metadata', + typecheck_command: 'pnpm run check', + unit_test_command: 'pnpm run test', + build_command: 'pnpm run build', + package_check_command: expectedPackageCheckCommand, + bazel_targets: expectedBazelTargets, + package_dir: expectedPackageDir, + npm_access: 'public', +}; const extract = (source, pattern, label) => { const match = source.match(pattern); @@ -16,6 +42,24 @@ const extract = (source, pattern, label) => { return match[1]; }; +const extractWorkflowValue = (source, key, label) => { + const rawValue = extract(source, new RegExp(`^\\s*${key}:\\s*(.+?)\\s*$`, 'm'), label).trim(); + if ( + (rawValue.startsWith("'") && rawValue.endsWith("'")) || + (rawValue.startsWith('"') && rawValue.endsWith('"')) + ) { + return rawValue.slice(1, -1); + } + return rawValue; +}; + +const sharedPackageWorkflow = (source, label) => + extract( + source, + /uses:\s*(tinyland-inc\/ci-templates\/\.github\/workflows\/js-bazel-package\.yml@[0-9a-f]{40})/, + label, + ); + const checks = [ { label: 'MODULE.bazel version', @@ -49,10 +93,68 @@ const checks = [ actual: extract(moduleBazel, /pnpm_version = "([^"]+)"/, 'pnpm_version'), expected: expectedPnpmVersion, }, + { + label: 'MODULE.bazel Node toolchain major', + actual: extract(moduleBazel, /node_version = "(\d+)\./, 'node_version'), + expected: expectedNodeMajor, + }, + { + label: 'CI reusable package workflow', + actual: sharedPackageWorkflow(ciWorkflow, 'CI reusable workflow'), + expected: sharedPackageWorkflow(publishWorkflow, 'publish reusable workflow'), + }, + { + label: 'package consumer check script', + actual: packageJson.scripts?.['check:package-consumer'], + expected: expectedPackageConsumerCommand, + }, + { + label: 'bundle size check script', + actual: packageJson.scripts?.['check:bundle-size'], + expected: 'node scripts/check-bundle-size.mjs', + }, + { + label: 'prepublishOnly script', + actual: packageJson.scripts?.prepublishOnly, + expected: expectedPrepublishOnlyCommand, + }, + { + label: 'CI publish dry-run', + actual: extractWorkflowValue(ciWorkflow, 'dry_run', 'CI dry_run'), + expected: 'true', + }, + { + label: 'tag publish dry-run', + actual: extractWorkflowValue(publishWorkflow, 'dry_run', 'publish dry_run'), + expected: 'false', + }, ]; +for (const [key, expected] of Object.entries(expectedSharedWorkflowInputs)) { + checks.push( + { + label: `CI ${key}`, + actual: extractWorkflowValue(ciWorkflow, key, `CI ${key}`), + expected, + }, + { + label: `publish ${key}`, + actual: extractWorkflowValue(publishWorkflow, key, `publish ${key}`), + expected, + }, + ); +} + const failures = checks.filter((check) => check.actual !== check.expected); +if (packageJson.publishConfig?.provenance && !/id-token:\s*write/.test(publishWorkflow)) { + failures.push({ + label: 'publish workflow id-token permission', + actual: 'missing', + expected: 'write', + }); +} + if (failures.length > 0) { for (const failure of failures) { console.error( diff --git a/scripts/copy-svelte-declarations.mjs b/scripts/copy-svelte-declarations.mjs new file mode 100644 index 0000000..c14489e --- /dev/null +++ b/scripts/copy-svelte-declarations.mjs @@ -0,0 +1,32 @@ +import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises'; + +const declarationPairs = [ + ['BlobSVG.svelte.d.ts', 'BlobSVG.d.ts'], + ['TinyVectors.svelte.d.ts', 'TinyVectors.d.ts'], +]; +const sourceDir = new URL('../src/svelte/', import.meta.url); +const outputDir = new URL('../dist-types/svelte/', import.meta.url); + +await mkdir(outputDir, { recursive: true }); + +await Promise.all( + declarationPairs.map(([sourceName, outputName]) => + copyFile(new URL(sourceName, sourceDir), new URL(outputName, outputDir)), + ), +); + +await writeFile( + new URL('index.d.ts', outputDir), + [ + "export { default as TinyVectors, type TinyVectorsExports, type TinyVectorsProps } from './TinyVectors.js';", + "export type { TinyVectorsDeviceMotionStatus } from './types.js';", + "export { default as BlobSVG, type BlobSVGProps } from './BlobSVG.js';", + '', + ].join('\n'), +); + +await Promise.all([ + rm(new URL('index.d.ts.map', outputDir), { force: true }), + rm(new URL('BlobSVG.svelte.d.ts', outputDir), { force: true }), + rm(new URL('TinyVectors.svelte.d.ts', outputDir), { force: true }), +]); diff --git a/scripts/probe-motion-cdp.mjs b/scripts/probe-motion-cdp.mjs new file mode 100644 index 0000000..ab73e77 --- /dev/null +++ b/scripts/probe-motion-cdp.mjs @@ -0,0 +1,720 @@ +import { spawn, spawnSync } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const host = '127.0.0.1'; +const vitePort = Number(process.env.TINYVECTORS_VITE_PORT ?? 5176); +const viteHmrPort = Number(process.env.TINYVECTORS_VITE_HMR_PORT ?? vitePort + 19000); +const cdpPort = Number(process.env.TINYVECTORS_CDP_PORT ?? 9228); +const chromePath = findChrome(); + +if (!chromePath) { + console.error('Chrome executable not found. Set CHROME_PATH to run this probe.'); + process.exit(1); +} + +const children = new Set(); + +process.on('exit', () => { + for (const child of children) { + child.kill('SIGTERM'); + } +}); + +function spawnChild(command, args, options = {}) { + const child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env, + ...options, + }); + children.add(child); + child.once('exit', () => children.delete(child)); + return child; +} + +function findChrome() { + if (process.env.CHROME_PATH) return process.env.CHROME_PATH; + + const candidates = [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + 'google-chrome', + 'google-chrome-stable', + 'chromium', + 'chromium-browser', + ]; + + for (const candidate of candidates) { + if (candidate.startsWith('/')) { + const result = spawnSync('test', ['-x', candidate]); + if (result.status === 0) return candidate; + continue; + } + + const result = spawnSync('command', ['-v', candidate], { + shell: true, + encoding: 'utf8', + }); + if (result.status === 0 && result.stdout.trim()) { + return result.stdout.trim(); + } + } + + return null; +} + +async function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForFetch(url, timeoutMs = 15000) { + const started = Date.now(); + let lastError; + + while (Date.now() - started < timeoutMs) { + try { + const response = await fetch(url); + if (response.ok) return response; + lastError = new Error(`${response.status} ${response.statusText}`); + } catch (error) { + lastError = error; + } + await delay(100); + } + + throw lastError ?? new Error(`Timed out waiting for ${url}`); +} + +async function waitForJson(url, timeoutMs = 15000) { + const response = await waitForFetch(url, timeoutMs); + return await response.json(); +} + +async function terminateChildren() { + const exiting = [...children].map( + (child) => + new Promise((resolve) => { + child.once('exit', resolve); + child.kill('SIGTERM'); + setTimeout(resolve, 2000); + }), + ); + + await Promise.all(exiting); + children.clear(); +} + +async function removeDirectoryWithRetry(directory) { + for (let attempt = 0; attempt < 5; attempt++) { + try { + await rm(directory, { recursive: true, force: true }); + return; + } catch (error) { + if (attempt === 4) throw error; + await delay(200); + } + } +} + +class CdpClient { + constructor(url) { + this.nextId = 1; + this.pending = new Map(); + this.ws = new WebSocket(url); + this.ready = new Promise((resolve, reject) => { + this.ws.addEventListener('open', resolve, { once: true }); + this.ws.addEventListener('error', reject, { once: true }); + }); + this.ws.addEventListener('message', (event) => { + const message = JSON.parse(event.data); + if (!message.id || !this.pending.has(message.id)) return; + + const pending = this.pending.get(message.id); + this.pending.delete(message.id); + + if (message.error) { + pending.reject(new Error(`${message.error.code}: ${message.error.message}`)); + } else { + pending.resolve(message.result ?? {}); + } + }); + } + + async send(method, params = {}) { + await this.ready; + + const id = this.nextId++; + this.ws.send(JSON.stringify({ id, method, params })); + + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (!this.pending.has(id)) return; + this.pending.delete(id); + reject(new Error(`Timed out waiting for ${method}`)); + }, 10000); + + this.pending.set(id, { + resolve(value) { + clearTimeout(timer); + resolve(value); + }, + reject(error) { + clearTimeout(timer); + reject(error); + }, + }); + }); + } + + close() { + this.ws.close(); + } +} + +async function evaluate(client, expression) { + const result = await client.send('Runtime.evaluate', { + expression, + awaitPromise: true, + returnByValue: true, + }); + + if (result.exceptionDetails) { + throw new Error(JSON.stringify(result.exceptionDetails)); + } + + return result.result.value; +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function parseMotionStatus(status) { + const match = /^motion x (-?\d+(?:\.\d+)?) y (-?\d+(?:\.\d+)?) z (-?\d+(?:\.\d+)?)$/.exec(status ?? ''); + if (!match) return null; + return { + x: Number(match[1]), + y: Number(match[2]), + z: Number(match[3]), + }; +} + +let chromeProfile; +let client; + +try { + const vite = spawnChild('pnpm', [ + 'exec', + 'vite', + '--config', + 'vite.dev.config.ts', + '--host', + host, + '--port', + String(vitePort), + ], { + env: { + ...process.env, + CI: 'true', + TINYVECTORS_VITE_PORT: String(vitePort), + TINYVECTORS_VITE_HMR_PORT: String(viteHmrPort), + }, + }); + + vite.stderr.on('data', (chunk) => { + process.stderr.write(chunk); + }); + + await waitForFetch(`http://${host}:${vitePort}/`); + + chromeProfile = await mkdtemp(join(tmpdir(), 'tinyvectors-cdp-profile-')); + const chrome = spawnChild(chromePath, [ + '--headless=new', + '--disable-gpu', + '--disable-dev-shm-usage', + '--no-first-run', + '--no-default-browser-check', + `--remote-debugging-address=${host}`, + `--remote-debugging-port=${cdpPort}`, + `--user-data-dir=${chromeProfile}`, + 'about:blank', + ]); + + chrome.stderr.on('data', (chunk) => { + if (process.env.DEBUG_CHROME === 'true') { + process.stderr.write(chunk); + } + }); + + const version = await waitForJson(`http://${host}:${cdpPort}/json/version`); + const tabs = await waitForJson(`http://${host}:${cdpPort}/json/list`); + const page = tabs.find((tab) => tab.type === 'page') ?? tabs[0]; + + client = new CdpClient(page.webSocketDebuggerUrl); + await client.send('Runtime.enable'); + await client.send('Page.enable'); + await client.send('Page.addScriptToEvaluateOnNewDocument', { + source: ` + window.__tinyvectorsEvents = []; + (() => { + const originalAddEventListener = EventTarget.prototype.addEventListener; + const originalRemoveEventListener = EventTarget.prototype.removeEventListener; + const listenerIds = new WeakMap(); + const activeWindowListeners = new Map(); + let nextListenerId = 1; + + const listenerId = (listener) => { + if ((typeof listener !== 'function' && typeof listener !== 'object') || listener === null) { + return String(listener); + } + if (!listenerIds.has(listener)) { + listenerIds.set(listener, nextListenerId++); + } + return listenerIds.get(listener); + }; + + window.__tinyvectorsListenerLedger = { + snapshot() { + const counts = {}; + for (const type of activeWindowListeners.values()) { + counts[type] = (counts[type] || 0) + 1; + } + return counts; + }, + }; + + EventTarget.prototype.addEventListener = function(type, listener, options) { + if (this === window && listener) { + activeWindowListeners.set(type + ':' + listenerId(listener), type); + } + return originalAddEventListener.call(this, type, listener, options); + }; + + EventTarget.prototype.removeEventListener = function(type, listener, options) { + if (this === window && listener) { + activeWindowListeners.delete(type + ':' + listenerId(listener)); + } + return originalRemoveEventListener.call(this, type, listener, options); + }; + + originalAddEventListener.call(window, 'deviceorientation', (event) => { + window.__tinyvectorsEvents.push({ + type: 'deviceorientation', + alpha: event.alpha, + beta: event.beta, + gamma: event.gamma, + at: performance.now() + }); + }); + originalAddEventListener.call(window, 'devicemotion', (event) => { + const gravity = event.accelerationIncludingGravity; + window.__tinyvectorsEvents.push({ + type: 'devicemotion', + x: gravity && gravity.x, + y: gravity && gravity.y, + z: gravity && gravity.z, + at: performance.now() + }); + }); + originalAddEventListener.call(window, 'pointermove', (event) => { + window.__tinyvectorsEvents.push({ + type: 'pointermove', + x: event.clientX, + y: event.clientY, + at: performance.now() + }); + }); + })(); + `, + }); + + const disabledUrl = `http://${host}:${vitePort}/?controls=true&animated=true&deviceMotion=false&pointerPhysics=false&scrollPhysics=false&blobs=8&listenerProbe=1`; + await client.send('Page.navigate', { url: disabledUrl }); + await delay(1000); + + const disabledInitial = await evaluate(client, `({ + motionStatus: window.__tinyvectorsDeviceMotionStatus?.() ?? null, + pathCount: document.querySelectorAll('path').length, + listeners: window.__tinyvectorsListenerLedger?.snapshot?.() ?? {} + })`); + + assert(disabledInitial.pathCount > 0, 'TinyVectors did not render when IO features were disabled.'); + assert( + disabledInitial.motionStatus?.enabled === false, + 'Disabled device motion page reported device motion enabled.', + ); + assert( + disabledInitial.motionStatus?.active === false, + 'Disabled device motion page reported an active listener.', + ); + assert(!disabledInitial.listeners.wheel, 'Wheel listener attached when scroll physics was disabled.'); + assert( + !disabledInitial.listeners.pointermove, + 'Pointer listener attached when pointer physics was disabled.', + ); + assert( + !disabledInitial.listeners.pointercancel, + 'Pointer cancel listener attached when pointer physics was disabled.', + ); + assert( + !disabledInitial.listeners.deviceorientation, + 'Device orientation listener attached when device motion was disabled.', + ); + + const pageUrl = `http://${host}:${vitePort}/?controls=true&animated=true&deviceMotion=true&pointerPhysics=false&scrollPhysics=false&blobs=8&motionIdleReset=700`; + await client.send('Page.navigate', { url: pageUrl }); + await delay(1500); + + const initial = await evaluate(client, `({ + secure: window.isSecureContext, + hasDeviceMotionEvent: 'DeviceMotionEvent' in window, + hasDeviceOrientationEvent: 'DeviceOrientationEvent' in window, + hasAccelerometer: 'Accelerometer' in window, + motionStatus: window.__tinyvectorsDeviceMotionStatus?.() ?? null, + status: document.getElementById('motion-status')?.textContent ?? null, + pathCount: document.querySelectorAll('path').length, + firstPath: document.querySelector('path')?.getAttribute('d') ?? null, + events: window.__tinyvectorsEvents + })`); + + assert(initial.secure, 'Page must be a secure context for device motion APIs.'); + assert(initial.hasDeviceOrientationEvent, 'DeviceOrientationEvent is not exposed in Chrome.'); + assert(initial.pathCount > 0, 'TinyVectors SVG paths were not rendered.'); + assert(initial.motionStatus?.enabled === true, 'Device motion status did not report enabled.'); + assert(initial.motionStatus?.supported === true, 'Device motion status did not report support.'); + assert( + initial.motionStatus?.permissionState === 'granted', + `Device motion status did not report granted; got ${initial.motionStatus?.permissionState}.`, + ); + assert(initial.motionStatus?.active === true, 'Device motion status did not report active listener.'); + + await client.send('Runtime.evaluate', { + expression: `document.getElementById('spoof-tilt-btn')?.click()`, + awaitPromise: true, + }); + await delay(350); + + const afterSpoof = await evaluate(client, `({ + status: document.getElementById('motion-status')?.textContent ?? null, + firstPath: document.querySelector('path')?.getAttribute('d') ?? null, + events: window.__tinyvectorsEvents + })`); + + assert( + afterSpoof.status?.startsWith('motion x '), + `Synthetic orientation did not reach TinyVectors; status was ${afterSpoof.status}`, + ); + assert(afterSpoof.events.length > initial.events.length, 'Synthetic orientation was not observed.'); + assert(afterSpoof.firstPath !== initial.firstPath, 'Synthetic orientation did not change blob geometry.'); + const syntheticMotion = parseMotionStatus(afterSpoof.status); + assert(syntheticMotion, `Synthetic orientation status was not parseable: ${afterSpoof.status}`); + assert( + syntheticMotion.x < 0 && syntheticMotion.y > 0, + `Synthetic orientation did not preserve expected direction; got ${afterSpoof.status}`, + ); + + await delay(550); + const afterIdleReset = await evaluate(client, `({ + status: document.getElementById('motion-status')?.textContent ?? null, + events: window.__tinyvectorsEvents + })`); + + assert( + afterIdleReset.status === 'motion x 0.00 y 0.00 z 0.00', + `Device orientation idle reset did not neutralize motion; status was ${afterIdleReset.status}`, + ); + + await client.send('Emulation.setEmulatedMedia', { + features: [{ name: 'prefers-reduced-motion', value: 'reduce' }], + }); + await delay(350); + + const afterReducedMotion = await evaluate(client, `({ + motionStatus: window.__tinyvectorsDeviceMotionStatus?.() ?? null, + status: document.getElementById('motion-status')?.textContent ?? null, + listeners: window.__tinyvectorsListenerLedger?.snapshot?.() ?? {} + })`); + + assert( + afterReducedMotion.status === 'motion x 0.00 y 0.00 z 0.00', + `Reduced motion did not neutralize active motion; status was ${afterReducedMotion.status}`, + ); + assert( + afterReducedMotion.motionStatus?.active === false, + 'Reduced motion did not stop the device orientation listener.', + ); + assert( + !afterReducedMotion.listeners.deviceorientation, + 'Device orientation listener leaked while reduced motion was enabled.', + ); + + await client.send('Emulation.setEmulatedMedia', { + features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }], + }); + await delay(350); + + const afterReducedMotionRestore = await evaluate(client, `({ + motionStatus: window.__tinyvectorsDeviceMotionStatus?.() ?? null, + listeners: window.__tinyvectorsListenerLedger?.snapshot?.() ?? {} + })`); + + assert( + afterReducedMotionRestore.motionStatus?.active === true, + 'Device orientation listener did not restart after reduced motion was disabled.', + ); + assert( + afterReducedMotionRestore.listeners.deviceorientation === 1, + `Expected one deviceorientation listener after reduced motion restore, got ${afterReducedMotionRestore.listeners.deviceorientation}.`, + ); + + await client.send('DeviceOrientation.setDeviceOrientationOverride', { + alpha: 180, + beta: 50, + gamma: -40, + }); + await delay(350); + + const afterCdpOrientation = await evaluate(client, `({ + status: document.getElementById('motion-status')?.textContent ?? null, + firstPath: document.querySelector('path')?.getAttribute('d') ?? null, + events: window.__tinyvectorsEvents + })`); + + assert( + afterCdpOrientation.events.length > afterSpoof.events.length, + 'CDP device orientation override did not emit a page event.', + ); + assert( + afterCdpOrientation.firstPath !== afterSpoof.firstPath, + 'CDP device orientation override did not change blob geometry.', + ); + const cdpOrientationMotion = parseMotionStatus(afterCdpOrientation.status); + assert( + cdpOrientationMotion, + `CDP orientation status was not parseable: ${afterCdpOrientation.status}`, + ); + assert( + cdpOrientationMotion.x < 0 && cdpOrientationMotion.y > 0, + `CDP orientation did not preserve expected direction; got ${afterCdpOrientation.status}`, + ); + + const listenerProbeUrl = `http://${host}:${vitePort}/?controls=true&animated=true&deviceMotion=true&pointerPhysics=true&scrollPhysics=true&blobs=8&listenerProbe=1`; + await client.send('Page.navigate', { url: listenerProbeUrl }); + await delay(1500); + + const listenerInitial = await evaluate(client, `({ + pathCount: document.querySelectorAll('path').length, + bodyPathCount: document.querySelectorAll('svg g')[1]?.querySelectorAll('path').length ?? 0, + gradientCount: document.querySelectorAll('radialGradient').length, + listeners: window.__tinyvectorsListenerLedger?.snapshot?.() ?? {} + })`); + + assert(listenerInitial.pathCount === 24, `Expected 24 SVG paths, got ${listenerInitial.pathCount}.`); + assert(listenerInitial.bodyPathCount === 8, `Expected 8 body paths, got ${listenerInitial.bodyPathCount}.`); + assert( + listenerInitial.gradientCount === 24, + `Expected 24 radial gradients, got ${listenerInitial.gradientCount}.`, + ); + assert( + listenerInitial.listeners.wheel === 1, + `Expected one wheel listener, got ${listenerInitial.listeners.wheel}.`, + ); + assert( + listenerInitial.listeners.pointermove === 1, + `Expected one pointermove listener, got ${listenerInitial.listeners.pointermove}.`, + ); + assert( + listenerInitial.listeners.pointerout === 1, + `Expected one pointerout listener, got ${listenerInitial.listeners.pointerout}.`, + ); + assert( + listenerInitial.listeners.pointercancel === 1, + `Expected one pointercancel listener, got ${listenerInitial.listeners.pointercancel}.`, + ); + assert( + listenerInitial.listeners.blur === 1, + `Expected one blur listener, got ${listenerInitial.listeners.blur}.`, + ); + assert( + listenerInitial.listeners.deviceorientation === 1, + `Expected one deviceorientation listener, got ${listenerInitial.listeners.deviceorientation}.`, + ); + + const beforePointerMove = await evaluate(client, `({ + firstBodyPath: document.querySelectorAll('svg g')[1]?.querySelector('path')?.getAttribute('d') ?? null, + pointerEvents: window.__tinyvectorsEvents.filter((event) => event.type === 'pointermove').length + })`); + await client.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', + x: 120, + y: 180, + button: 'none', + }); + await delay(500); + const afterPointerMove = await evaluate(client, `({ + firstBodyPath: document.querySelectorAll('svg g')[1]?.querySelector('path')?.getAttribute('d') ?? null, + pointerEvents: window.__tinyvectorsEvents.filter((event) => event.type === 'pointermove').length, + lastPointerEvent: window.__tinyvectorsEvents.filter((event) => event.type === 'pointermove').at(-1) + })`); + assert(beforePointerMove.firstBodyPath, 'Pointer probe could not read initial blob geometry.'); + assert(afterPointerMove.firstBodyPath, 'Pointer probe could not read updated blob geometry.'); + assert( + afterPointerMove.pointerEvents > beforePointerMove.pointerEvents, + 'CDP pointer move did not reach the page.', + ); + assert( + afterPointerMove.lastPointerEvent?.x === 120 && afterPointerMove.lastPointerEvent?.y === 180, + `CDP pointer move reached the page with unexpected coordinates ${JSON.stringify(afterPointerMove.lastPointerEvent)}.`, + ); + assert( + afterPointerMove.firstBodyPath !== beforePointerMove.firstBodyPath, + 'Pointer probe did not observe animated blob geometry movement after pointer delivery.', + ); + + await client.send('Runtime.evaluate', { + expression: `document.getElementById('scroll-physics')?.click()`, + awaitPromise: true, + }); + await delay(300); + const afterScrollOff = await evaluate(client, `({ + listeners: window.__tinyvectorsListenerLedger?.snapshot?.() ?? {} + })`); + assert(!afterScrollOff.listeners.wheel, 'Wheel listener leaked after disabling scroll physics.'); + + await client.send('Runtime.evaluate', { + expression: `document.getElementById('pointer-physics')?.click()`, + awaitPromise: true, + }); + await delay(300); + const afterPointerOff = await evaluate(client, `({ + listeners: window.__tinyvectorsListenerLedger?.snapshot?.() ?? {} + })`); + assert(!afterPointerOff.listeners.pointermove, 'Pointer listener leaked after disabling pointer physics.'); + assert(!afterPointerOff.listeners.pointerout, 'Pointer exit listener leaked after disabling pointer physics.'); + assert(!afterPointerOff.listeners.pointercancel, 'Pointer cancel listener leaked after disabling pointer physics.'); + assert(!afterPointerOff.listeners.blur, 'Pointer blur listener leaked after disabling pointer physics.'); + + await client.send('Runtime.evaluate', { + expression: `document.getElementById('device-motion')?.click()`, + awaitPromise: true, + }); + await delay(300); + const afterDeviceMotionOff = await evaluate(client, `({ + listeners: window.__tinyvectorsListenerLedger?.snapshot?.() ?? {} + })`); + assert( + !afterDeviceMotionOff.listeners.deviceorientation, + 'Device orientation listener leaked after disabling device motion.', + ); + + await client.send('Emulation.setSensorOverrideEnabled', { + type: 'accelerometer', + enabled: true, + metadata: { available: true, minimumFrequency: 1, maximumFrequency: 60 }, + }); + await client.send('Page.navigate', { url: `${pageUrl}&accelerometerProbe=1` }); + await delay(1500); + + const beforeCdpAccelerometer = await evaluate(client, `({ + hasAccelerometer: 'Accelerometer' in window, + status: document.getElementById('motion-status')?.textContent ?? null, + firstPath: document.querySelector('path')?.getAttribute('d') ?? null, + events: window.__tinyvectorsEvents + })`); + + await client.send('Emulation.setSensorOverrideReadings', { + type: 'accelerometer', + reading: { + xyz: { + x: 4, + y: -3, + z: 9.80665, + }, + }, + }); + await delay(1000); + + const afterCdpAccelerometer = await evaluate(client, `({ + status: document.getElementById('motion-status')?.textContent ?? null, + firstPath: document.querySelector('path')?.getAttribute('d') ?? null, + events: window.__tinyvectorsEvents + })`); + + const cdpAccelerometerChanged = + afterCdpAccelerometer.firstPath !== beforeCdpAccelerometer.firstPath; + + console.log( + JSON.stringify( + { + chrome: version.Browser, + pageUrl, + initial: { + secure: initial.secure, + hasDeviceMotionEvent: initial.hasDeviceMotionEvent, + hasDeviceOrientationEvent: initial.hasDeviceOrientationEvent, + hasAccelerometer: initial.hasAccelerometer, + motionStatus: initial.motionStatus, + pathCount: initial.pathCount, + }, + disabledInitial: { + motionStatus: disabledInitial.motionStatus, + pathCount: disabledInitial.pathCount, + listeners: disabledInitial.listeners, + }, + syntheticOrientation: { + status: afterSpoof.status, + motion: syntheticMotion, + events: afterSpoof.events.length, + pathChanged: afterSpoof.firstPath !== initial.firstPath, + idleResetStatus: afterIdleReset.status, + }, + reducedMotion: { + status: afterReducedMotion.status, + activeAfterReduce: afterReducedMotion.motionStatus?.active, + listenersAfterReduce: afterReducedMotion.listeners, + activeAfterRestore: afterReducedMotionRestore.motionStatus?.active, + listenersAfterRestore: afterReducedMotionRestore.listeners, + }, + cdpOrientation: { + status: afterCdpOrientation.status, + motion: cdpOrientationMotion, + events: afterCdpOrientation.events.length, + pathChanged: afterCdpOrientation.firstPath !== afterSpoof.firstPath, + lastEvent: afterCdpOrientation.events.at(-1), + }, + cdpAccelerometer: { + hasAccelerometer: beforeCdpAccelerometer.hasAccelerometer, + status: afterCdpAccelerometer.status, + windowEvents: afterCdpAccelerometer.events.length, + pathChanged: cdpAccelerometerChanged, + note: 'TinyVectors uses DeviceOrientationEvent/TiltSource; raw accelerometer CDP is informational.', + }, + pointerDelivery: { + events: afterPointerMove.pointerEvents - beforePointerMove.pointerEvents, + pathChanged: afterPointerMove.firstBodyPath !== beforePointerMove.firstBodyPath, + lastEvent: afterPointerMove.lastPointerEvent, + }, + listenerLifecycle: { + initial: listenerInitial.listeners, + afterScrollOff: afterScrollOff.listeners, + afterPointerOff: afterPointerOff.listeners, + afterDeviceMotionOff: afterDeviceMotionOff.listeners, + }, + }, + null, + 2, + ), + ); +} catch (error) { + console.error(error); + process.exitCode = 1; +} finally { + client?.close(); + await terminateChildren(); + if (chromeProfile) { + await removeDirectoryWithRetry(chromeProfile); + } +} diff --git a/src/core/BlobPhysics.ts b/src/core/BlobPhysics.ts index aaa49b4..1e54972 100644 --- a/src/core/BlobPhysics.ts +++ b/src/core/BlobPhysics.ts @@ -16,6 +16,10 @@ import type { ConvexBlob, GravityVector, TiltVector } from './types.js'; import { SpatialHash } from './SpatialHash.js'; import { GaussianKernel } from './GaussianKernel.js'; import { SpringSystem, DEFAULT_SPRING_CONFIG, type SpringConfig } from './SpringSystem.js'; +import { directionalBiasField } from './InteractionField.js'; + +const ACCELEROMETER_STRENGTH = 0.0008; +const ACCELEROMETER_MAX_FORCE = 0.003; export interface BlobPhysicsConfig { antiClusteringStrength: number; @@ -33,7 +37,7 @@ export interface BlobPhysicsConfig { springConfig: Partial; } -const DEFAULT_CONFIG: BlobPhysicsConfig = { +export const DEFAULT_BLOB_PHYSICS_CONFIG: BlobPhysicsConfig = { antiClusteringStrength: 0.15, bounceDamping: 0.7, deformationSpeed: 0.5, @@ -56,11 +60,10 @@ export class BlobPhysics { private mouseY = 50; private mouseVelX = 0; private mouseVelY = 0; - private lastMouseX = 50; - private lastMouseY = 50; private gravity: GravityVector = { x: 0, y: 0 }; + private gravityField: GravityVector = { x: 0, y: 0 }; private tilt: TiltVector = { x: 0, y: 0, z: 0 }; private scrollStickiness = 0; @@ -72,10 +75,11 @@ export class BlobPhysics { private spatialHash: SpatialHash; private gaussianKernel: GaussianKernel; private springSystem: SpringSystem; + private controlRadiusScratch: number[] = []; constructor(numBlobs: number, config: Partial = {}) { this.numBlobs = numBlobs; - this.config = { ...DEFAULT_CONFIG, ...config }; + this.config = { ...DEFAULT_BLOB_PHYSICS_CONFIG, ...config }; this.spatialHash = new SpatialHash(60); @@ -115,6 +119,11 @@ export class BlobPhysics { setGravity(gravity: GravityVector): void { this.gravity = gravity; + this.gravityField = directionalBiasField( + gravity, + ACCELEROMETER_STRENGTH, + ACCELEROMETER_MAX_FORCE, + ); } @@ -199,10 +208,10 @@ export class BlobPhysics { updateMousePosition(x: number, y: number): void { - this.mouseVelX = x - this.lastMouseX; - this.mouseVelY = y - this.lastMouseY; - this.lastMouseX = this.mouseX; - this.lastMouseY = this.mouseY; + const previousMouseX = this.mouseX; + const previousMouseY = this.mouseY; + this.mouseVelX = x - previousMouseX; + this.mouseVelY = y - previousMouseY; this.mouseX = x; this.mouseY = y; } @@ -210,15 +219,21 @@ export class BlobPhysics { + // Mutate blob.color in place when themeColors is supplied — kills the + // 300 object spreads/sec the previous .map(blob => ({...blob, color})) + // performed at 5 blobs × 60 fps. Return a *shallow copy* so the array + // reference is fresh each call: TinyVectors.svelte assigns the result + // to a $state rune inside its rAF loop, and Svelte 5's signal compares + // by reference — returning the same array would freeze the animation + // after frame 1. Same blob object refs across calls; only the outer + // array shell is reallocated. getBlobs(themeColors?: string[]): ConvexBlob[] { if (themeColors && themeColors.length > 0) { - - return this.blobs.map((blob, i) => ({ - ...blob, - color: themeColors[i % themeColors.length], - })); + for (let i = 0; i < this.blobs.length; i++) { + this.blobs[i].color = themeColors[i % themeColors.length]; + } } - return this.blobs; + return this.blobs.slice(); } @@ -249,21 +264,23 @@ export class BlobPhysics { const convexPoints = this.generateConvexHull(points); - - let path = `M ${convexPoints[0].x.toFixed(2)},${convexPoints[0].y.toFixed(2)}`; + // Numbers are interpolated directly: Number.prototype.toString() in + // V8 is faster than toFixed and SVG accepts any precision. Each + // .toFixed call allocated a fresh string ~18,000 times/sec at + // 5 blobs × 12 control points × 60 fps. + let path = `M ${convexPoints[0].x},${convexPoints[0].y}`; for (let i = 0; i < convexPoints.length; i++) { const current = convexPoints[i]; const next = convexPoints[(i + 1) % convexPoints.length]; const nextNext = convexPoints[(i + 2) % convexPoints.length]; - const cp1x = current.x + (next.x - current.x) * 0.15; const cp1y = current.y + (next.y - current.y) * 0.15; const cp2x = next.x - (nextNext.x - current.x) * 0.05; const cp2y = next.y - (nextNext.y - current.y) * 0.05; - path += ` C ${cp1x.toFixed(2)},${cp1y.toFixed(2)} ${cp2x.toFixed(2)},${cp2y.toFixed(2)} ${next.x.toFixed(2)},${next.y.toFixed(2)}`; + path += ` C ${cp1x},${cp1y} ${cp2x},${cp2y} ${next.x},${next.y}`; } path += ' Z'; @@ -436,6 +453,9 @@ export class BlobPhysics { this.applyAccelerometerForces(blob); + + this.applyPointerField(blob); + this.updateMovementWithAccelerometer(blob, time); @@ -463,14 +483,8 @@ export class BlobPhysics { } private applyAccelerometerForces(blob: ConvexBlob): void { - const accelerometerStrength = 0.0008; - const maxForce = 0.003; - - const gravityX = Math.max(-maxForce, Math.min(maxForce, this.gravity.x * accelerometerStrength)); - const gravityY = Math.max(-maxForce, Math.min(maxForce, this.gravity.y * accelerometerStrength)); - - blob.velocityX += gravityX; - blob.velocityY += gravityY; + blob.velocityX += this.gravityField.x; + blob.velocityY += this.gravityField.y; if (blob.controlPoints && (Math.abs(this.gravity.x) > 0.3 || Math.abs(this.gravity.y) > 0.3)) { @@ -479,6 +493,20 @@ export class BlobPhysics { } } + private applyPointerField(blob: ConvexBlob): void { + if (this.mouseX === 50 && this.mouseY === 50) return; + + const dx = this.mouseX - blob.currentX; + const dy = this.mouseY - blob.currentY; + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance === 0 || distance >= 34) return; + + const normalized = 1 - distance / 34; + const scale = (normalized * normalized * 0.0014) / distance; + blob.velocityX += dx * scale; + blob.velocityY += dy * scale; + } + private updateMovementWithAccelerometer(blob: ConvexBlob, time: number): void { const neutralDriftX = (Math.random() - 0.5) * 0.001; @@ -651,27 +679,32 @@ export class BlobPhysics { private smoothControlPoints(blob: ConvexBlob): void { if (!blob.controlPoints || blob.controlPoints.length < 3) return; - for (let i = 0; i < blob.controlPoints.length; i++) { - const current = blob.controlPoints[i]; - const prev = blob.controlPoints[(i - 1 + blob.controlPoints.length) % blob.controlPoints.length]; - const next = blob.controlPoints[(i + 1) % blob.controlPoints.length]; + const controlPoints = blob.controlPoints; + const originalRadii = this.controlRadiusScratch; + const pointCount = controlPoints.length; + + for (let i = 0; i < pointCount; i++) { + originalRadii[i] = controlPoints[i].radius; + } + + for (let i = 0; i < pointCount; i++) { + const current = controlPoints[i]; + const prevRadius = originalRadii[(i - 1 + pointCount) % pointCount]; + const currentRadius = originalRadii[i]; + const nextRadius = originalRadii[(i + 1) % pointCount]; - const avgRadius = (prev.radius + current.radius + next.radius) / 3; + const avgRadius = (prevRadius + currentRadius + nextRadius) / 3; const smoothingFactor = 0.05; - current.radius = current.radius * (1 - smoothingFactor) + avgRadius * smoothingFactor; + current.radius = currentRadius * (1 - smoothingFactor) + avgRadius * smoothingFactor; const minRadiusDiff = blob.size * 0.1; - if (Math.abs(current.radius - prev.radius) > minRadiusDiff) { - const adjustment = (Math.abs(current.radius - prev.radius) - minRadiusDiff) * 0.5; - if (current.radius > prev.radius) { - current.radius -= adjustment; - prev.radius += adjustment; - } else { - current.radius += adjustment; - prev.radius -= adjustment; - } + const neighborRadius = (prevRadius + nextRadius) * 0.5; + const radiusDiff = current.radius - neighborRadius; + const excessRadiusDiff = Math.abs(radiusDiff) - minRadiusDiff; + if (excessRadiusDiff > 0) { + current.radius -= radiusDiff > 0 ? excessRadiusDiff * 0.5 : -excessRadiusDiff * 0.5; } } } diff --git a/src/core/InteractionField.ts b/src/core/InteractionField.ts new file mode 100644 index 0000000..14bf8cd --- /dev/null +++ b/src/core/InteractionField.ts @@ -0,0 +1,90 @@ +export interface FieldVector { + x: number; + y: number; +} + +export interface PointFieldOptions { + origin: FieldVector; + target: FieldVector; + radius: number; + strength: number; +} + +const magnitude = (vector: FieldVector): number => + Math.sqrt(vector.x * vector.x + vector.y * vector.y); + +export function clampFieldVector(vector: FieldVector, maxMagnitude = 1): FieldVector { + const max = Math.max(0, maxMagnitude); + const currentMagnitude = magnitude(vector); + if (max === 0 || currentMagnitude === 0) return { x: 0, y: 0 }; + if (currentMagnitude <= max) return vector; + + const scale = max / currentMagnitude; + return { + x: vector.x * scale, + y: vector.y * scale, + }; +} + +export function combineFieldVectors( + fields: FieldVector[], + maxMagnitude = 1, +): FieldVector { + const total = fields.reduce( + (accumulator, field) => ({ + x: accumulator.x + field.x, + y: accumulator.y + field.y, + }), + { x: 0, y: 0 }, + ); + + return clampFieldVector(total, maxMagnitude); +} + +export function directionalBiasField( + input: FieldVector, + strength: number, + maxMagnitude = 1, +): FieldVector { + const max = Math.max(0, maxMagnitude); + const x = input.x * strength; + const y = input.y * strength; + const currentMagnitude = Math.sqrt(x * x + y * y); + + if (max === 0 || currentMagnitude === 0) return { x: 0, y: 0 }; + if (currentMagnitude <= max) return { x, y }; + + const scale = max / currentMagnitude; + return { + x: x * scale, + y: y * scale, + }; +} + +export function smoothDistanceFalloff(distance: number, radius: number): number { + const boundedDistance = Math.max(0, distance); + if (radius <= 0 || boundedDistance >= radius) return 0; + + const normalized = 1 - boundedDistance / radius; + return normalized * normalized; +} + +export function pointAttractorField({ + origin, + target, + radius, + strength, +}: PointFieldOptions): FieldVector { + const dx = target.x - origin.x; + const dy = target.y - origin.y; + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance === 0 || radius <= 0 || distance >= radius) return { x: 0, y: 0 }; + + const normalized = 1 - distance / radius; + const falloff = normalized * normalized; + const scale = (falloff * strength) / distance; + return { + x: dx * scale, + y: dy * scale, + }; +} diff --git a/src/core/index.ts b/src/core/index.ts index a704a23..c4264da 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,34 +1,19 @@ +// Curated public surface for the /core entry point. All exports below +// were previously emitted via `export *` from types.js and schema.js; +// this file makes the surface explicit and tree-shake-friendly without +// changing it. - - - - - -export * from './types.js'; - - -export * from './schema.js'; - - +// — Physics core — export { BlobPhysics, type BlobPhysicsConfig } from './BlobPhysics.js'; - +// — Path generation — export { generateSmoothBlobPath, generateSmoothBlobPathSync, preInitPathGenerator, } from './PathGenerator.js'; - -export { browser, isBrowser } from './browser.js'; - - -export { GaussianKernel } from './GaussianKernel.js'; - - -export { SpatialHash } from './SpatialHash.js'; - - +// — Spring system — export { SpringSystem, type SpringConfig, @@ -39,3 +24,79 @@ export { enforceAreaConservation, createControlPointVelocities, } from './SpringSystem.js'; + +// — Spatial / kernel utilities — +export { GaussianKernel } from './GaussianKernel.js'; +export { SpatialHash } from './SpatialHash.js'; + +// — Browser detection — +export { browser, isBrowser } from './browser.js'; + +// — Blob and motion types — +export type { + ControlPoint, + ControlPointVelocity, + ConvexBlob, + ColorDefinition, + DeviceMotionData, + GravityVector, + TiltVector, + PullForce, +} from './types.js'; + +// — Configuration types — +export type { + TinyVectorsConfig, + CoreConfig, + PhysicsConfig, + RenderingConfig, + ThemeConfig, + FeatureFlags, + TinyVectorsConfigOverride, + DeepPartial, +} from './schema.js'; + +// — Theme types — +export type { + ThemePresetName, + BlendMode, + ThemeColor, + ThemePreset, +} from './theme-presets.js'; + +// — Render blob shapes — +export type { + BlobCore, + RenderBlob, + PhysicsBlob, +} from './schema.js'; + +// — Input event types — +export type { + ScrollData, + PointerData, +} from './schema.js'; + +// — Custom event types — +export type { + TinyVectorsEventType, + TinyVectorsEventHandler, + TinyVectorsEvent, + FrameEventData, + ThemeChangeEventData, +} from './schema.js'; + +// — Theme presets and config — +export { + TRANS_THEME, + PRIDE_THEME, + TINYLAND_THEME, + HIGH_CONTRAST_THEME, + THEME_PRESETS, +} from './theme-presets.js'; +export { THEME_PRESET_COLORS } from './theme-colors.js'; + +export { + DEFAULT_CONFIG, + mergeConfig, +} from './schema.js'; diff --git a/src/core/schema.ts b/src/core/schema.ts index 1de1ac2..6873d06 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -7,6 +7,17 @@ import type { ControlPoint, ControlPointVelocity, DeviceMotionData } from './types.js'; +import type { + BlendMode, + ThemeColor, + ThemePresetName, +} from './theme-presets.js'; +export type { + BlendMode, + ThemeColor, + ThemePreset, + ThemePresetName, +} from './theme-presets.js'; @@ -166,53 +177,16 @@ export interface FeatureFlags { -export type ThemePresetName = 'tinyland' | 'trans' | 'pride' | 'high-contrast' | 'custom'; -export type BlendMode = 'multiply' | 'screen' | 'overlay' | 'soft-light' | 'normal'; -export interface ThemeColor { - - id: string; - - color: string; - - attractive: boolean; - - scrollAffinity: number; - - layer: 'background' | 'mid' | 'foreground'; -} - - - - -export interface ThemePreset { - - name: ThemePresetName; - - - label: string; - - - colors: ThemeColor[]; - - - blendModeLight: BlendMode; - - - blendModeDark: BlendMode; - - - hasVectors: boolean; -} @@ -510,89 +484,14 @@ export const DEFAULT_CONFIG: TinyVectorsConfig = { -export const TRANS_THEME: ThemePreset = { - name: 'trans', - label: 'Trans Pride', - hasVectors: true, - blendModeLight: 'multiply', - blendModeDark: 'screen', - colors: [ - { id: 'trans-blue', color: 'rgba(91, 206, 250, 0.60)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, - { id: 'trans-pink', color: 'rgba(245, 169, 184, 0.65)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, - { id: 'trans-white', color: 'rgba(242, 242, 245, 0.50)', attractive: false, scrollAffinity: 0.5, layer: 'mid' }, - { id: 'trans-sky-blue', color: 'rgba(170, 225, 250, 0.55)', attractive: false, scrollAffinity: 0.6, layer: 'mid' }, - { id: 'trans-powder-blue', color: 'rgba(160, 190, 255, 0.65)', attractive: true, scrollAffinity: 0.7, layer: 'foreground' }, - { id: 'trans-rose-pink', color: 'rgba(250, 200, 210, 0.55)', attractive: false, scrollAffinity: 0.6, layer: 'mid' }, - { id: 'trans-blush-pink', color: 'rgba(255, 160, 220, 0.65)', attractive: true, scrollAffinity: 0.7, layer: 'foreground' }, - { id: 'trans-lavender', color: 'rgba(220, 220, 255, 0.55)', attractive: false, scrollAffinity: 0.5, layer: 'background' }, - ], -}; - - - - -export const PRIDE_THEME: ThemePreset = { - name: 'pride', - label: 'Pride Rainbow', - hasVectors: true, - blendModeLight: 'multiply', - blendModeDark: 'screen', - colors: [ - { id: 'pride-red', color: 'rgba(228, 3, 3, 0.55)', attractive: true, scrollAffinity: 0.9, layer: 'foreground' }, - { id: 'pride-orange', color: 'rgba(255, 140, 0, 0.55)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, - { id: 'pride-yellow', color: 'rgba(255, 237, 0, 0.55)', attractive: false, scrollAffinity: 0.7, layer: 'mid' }, - { id: 'pride-green', color: 'rgba(0, 128, 38, 0.55)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, - { id: 'pride-blue', color: 'rgba(36, 64, 142, 0.55)', attractive: true, scrollAffinity: 0.9, layer: 'foreground' }, - { id: 'pride-purple', color: 'rgba(115, 41, 130, 0.55)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, - ], -}; - - - - -export const TINYLAND_THEME: ThemePreset = { - name: 'tinyland', - label: 'Tinyland', - hasVectors: true, - blendModeLight: 'multiply', - blendModeDark: 'screen', - colors: [ - { id: 'tinyland-purple', color: 'rgba(139, 92, 246, 0.55)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, - { id: 'tinyland-blue', color: 'rgba(59, 130, 246, 0.55)', attractive: true, scrollAffinity: 0.7, layer: 'foreground' }, - { id: 'tinyland-pink', color: 'rgba(236, 72, 153, 0.50)', attractive: true, scrollAffinity: 0.8, layer: 'mid' }, - { id: 'tinyland-white', color: 'rgba(242, 242, 245, 0.45)', attractive: false, scrollAffinity: 0.4, layer: 'background' }, - ], -}; - - - - -export const HIGH_CONTRAST_THEME: ThemePreset = { - name: 'high-contrast', - label: 'High Contrast', - hasVectors: false, - blendModeLight: 'normal', - blendModeDark: 'normal', - colors: [], -}; - - - - -export const THEME_PRESETS: Record = { - tinyland: TINYLAND_THEME, - trans: TRANS_THEME, - pride: PRIDE_THEME, - 'high-contrast': HIGH_CONTRAST_THEME, - custom: { - name: 'custom', - label: 'Custom', - hasVectors: true, - blendModeLight: 'multiply', - blendModeDark: 'screen', - colors: [], - }, -}; +export { + HIGH_CONTRAST_THEME, + PRIDE_THEME, + THEME_PRESETS, + TINYLAND_THEME, + TRANS_THEME, +} from './theme-presets.js'; +export { THEME_PRESET_COLORS } from './theme-colors.js'; diff --git a/src/core/theme-colors.ts b/src/core/theme-colors.ts new file mode 100644 index 0000000..46029c7 --- /dev/null +++ b/src/core/theme-colors.ts @@ -0,0 +1,30 @@ +import type { ThemePresetName } from './theme-presets.js'; + +export const THEME_PRESET_COLORS: Record = { + tinyland: [ + 'rgba(139,92,246,.55)', + 'rgba(59,130,246,.55)', + 'rgba(236,72,153,.50)', + 'rgba(242,242,245,.45)', + ], + trans: [ + 'rgba(91,206,250,.60)', + 'rgba(245,169,184,.65)', + 'rgba(242,242,245,.50)', + 'rgba(170,225,250,.55)', + 'rgba(160,190,255,.65)', + 'rgba(250,200,210,.55)', + 'rgba(255,160,220,.65)', + 'rgba(220,220,255,.55)', + ], + pride: [ + 'rgba(228,3,3,.55)', + 'rgba(255,140,0,.55)', + 'rgba(255,237,0,.55)', + 'rgba(0,128,38,.55)', + 'rgba(36,64,142,.55)', + 'rgba(115,41,130,.55)', + ], + 'high-contrast': [], + custom: [], +}; diff --git a/src/core/theme-presets.ts b/src/core/theme-presets.ts new file mode 100644 index 0000000..02be596 --- /dev/null +++ b/src/core/theme-presets.ts @@ -0,0 +1,92 @@ +export type ThemePresetName = 'tinyland' | 'trans' | 'pride' | 'high-contrast' | 'custom'; + +export type BlendMode = 'multiply' | 'screen' | 'overlay' | 'soft-light' | 'normal'; + +export interface ThemeColor { + id: string; + color: string; + attractive: boolean; + scrollAffinity: number; + layer: 'background' | 'mid' | 'foreground'; +} + +export interface ThemePreset { + name: ThemePresetName; + label: string; + colors: ThemeColor[]; + blendModeLight: BlendMode; + blendModeDark: BlendMode; + hasVectors: boolean; +} + +export const TRANS_THEME: ThemePreset = { + name: 'trans', + label: 'Trans Pride', + hasVectors: true, + blendModeLight: 'multiply', + blendModeDark: 'screen', + colors: [ + { id: 'trans-blue', color: 'rgba(91, 206, 250, 0.60)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, + { id: 'trans-pink', color: 'rgba(245, 169, 184, 0.65)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, + { id: 'trans-white', color: 'rgba(242, 242, 245, 0.50)', attractive: false, scrollAffinity: 0.5, layer: 'mid' }, + { id: 'trans-sky-blue', color: 'rgba(170, 225, 250, 0.55)', attractive: false, scrollAffinity: 0.6, layer: 'mid' }, + { id: 'trans-powder-blue', color: 'rgba(160, 190, 255, 0.65)', attractive: true, scrollAffinity: 0.7, layer: 'foreground' }, + { id: 'trans-rose-pink', color: 'rgba(250, 200, 210, 0.55)', attractive: false, scrollAffinity: 0.6, layer: 'mid' }, + { id: 'trans-blush-pink', color: 'rgba(255, 160, 220, 0.65)', attractive: true, scrollAffinity: 0.7, layer: 'foreground' }, + { id: 'trans-lavender', color: 'rgba(220, 220, 255, 0.55)', attractive: false, scrollAffinity: 0.5, layer: 'background' }, + ], +}; + +export const PRIDE_THEME: ThemePreset = { + name: 'pride', + label: 'Pride Rainbow', + hasVectors: true, + blendModeLight: 'multiply', + blendModeDark: 'screen', + colors: [ + { id: 'pride-red', color: 'rgba(228, 3, 3, 0.55)', attractive: true, scrollAffinity: 0.9, layer: 'foreground' }, + { id: 'pride-orange', color: 'rgba(255, 140, 0, 0.55)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, + { id: 'pride-yellow', color: 'rgba(255, 237, 0, 0.55)', attractive: false, scrollAffinity: 0.7, layer: 'mid' }, + { id: 'pride-green', color: 'rgba(0, 128, 38, 0.55)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, + { id: 'pride-blue', color: 'rgba(36, 64, 142, 0.55)', attractive: true, scrollAffinity: 0.9, layer: 'foreground' }, + { id: 'pride-purple', color: 'rgba(115, 41, 130, 0.55)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, + ], +}; + +export const TINYLAND_THEME: ThemePreset = { + name: 'tinyland', + label: 'Tinyland', + hasVectors: true, + blendModeLight: 'multiply', + blendModeDark: 'screen', + colors: [ + { id: 'tinyland-purple', color: 'rgba(139, 92, 246, 0.55)', attractive: true, scrollAffinity: 0.8, layer: 'foreground' }, + { id: 'tinyland-blue', color: 'rgba(59, 130, 246, 0.55)', attractive: true, scrollAffinity: 0.7, layer: 'foreground' }, + { id: 'tinyland-pink', color: 'rgba(236, 72, 153, 0.50)', attractive: true, scrollAffinity: 0.8, layer: 'mid' }, + { id: 'tinyland-white', color: 'rgba(242, 242, 245, 0.45)', attractive: false, scrollAffinity: 0.4, layer: 'background' }, + ], +}; + +export const HIGH_CONTRAST_THEME: ThemePreset = { + name: 'high-contrast', + label: 'High Contrast', + hasVectors: false, + blendModeLight: 'normal', + blendModeDark: 'normal', + colors: [], +}; + +export const THEME_PRESETS: Record = { + tinyland: TINYLAND_THEME, + trans: TRANS_THEME, + pride: PRIDE_THEME, + 'high-contrast': HIGH_CONTRAST_THEME, + custom: { + name: 'custom', + label: 'Custom', + hasVectors: true, + blendModeLight: 'multiply', + blendModeDark: 'screen', + colors: [], + }, +}; diff --git a/src/index.ts b/src/index.ts index 0578c05..9bbf0fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,30 +1,109 @@ +// Curated public package root. Keep this explicit so the npm surface stays +// reviewable and tree-shake-friendly. - - - - - -export * from './core/index.js'; - - -export * from './motion/index.js'; - - -export * from './themes/index.js'; - - -export * from './svelte/index.js'; - +export { + BlobPhysics, + generateSmoothBlobPath, + generateSmoothBlobPathSync, + preInitPathGenerator, + SpringSystem, + DEFAULT_SPRING_CONFIG, + computePolygonArea, + computeCircularity, + enforceAreaConservation, + createControlPointVelocities, + GaussianKernel, + SpatialHash, + browser, + isBrowser, + TRANS_THEME, + PRIDE_THEME, + TINYLAND_THEME, + HIGH_CONTRAST_THEME, + THEME_PRESETS, + THEME_PRESET_COLORS, + DEFAULT_CONFIG, + mergeConfig, +} from './core/index.js'; export type { + BlobPhysicsConfig, + SpringConfig, + GelControlPoint, + ControlPoint, + ControlPointVelocity, + ConvexBlob, + ColorDefinition, + DeviceMotionData, + GravityVector, + TiltVector, TinyVectorsConfig, CoreConfig, PhysicsConfig, RenderingConfig, ThemeConfig, FeatureFlags, - RenderBlob, + TinyVectorsConfigOverride, + DeepPartial, + ThemePresetName, + BlendMode, ThemeColor, ThemePreset, - ThemePresetName, -} from './core/schema.js'; + BlobCore, + RenderBlob, + PhysicsBlob, + ScrollData, + PointerData, + TinyVectorsEventType, + TinyVectorsEventHandler, + TinyVectorsEvent, + FrameEventData, + ThemeChangeEventData, +} from './core/index.js'; + +export { + DeviceMotion, + mapClientPointToPhysics, + createPointerPhysicsController, + detectPointerPhysicsCapability, + getLatestPointerEvent, + ScrollHandler, +} from './motion/index.js'; + +export type { + DeviceMotionCallback, + DeviceMotionOptions, + DeviceMotionPermissionState, + MotionVector, + PhysicsPoint, + PhysicsRange, + PointerBounds, + PointerCapabilityEnvironment, + PointerCancelEventName, + PointerExitEventName, + PointerExitLikeEvent, + PointerLifecycleEventName, + PointerLikeEvent, + PointerMoveEventName, + PointerPhysicsController, + PointerPhysicsControllerOptions, + PointerPhysicsEventTarget, + ScrollHandlerConfig, + PullForce, +} from './motion/index.js'; + +export { + getThemePreset, + generateThemeCSS, + isDarkMode, + watchDarkMode, +} from './themes/index.js'; + +export { + TinyVectors, + BlobSVG, +} from './svelte/index.js'; + +export type { + TinyVectorsDeviceMotionStatus, +} from './svelte/index.js'; diff --git a/src/motion/DeviceMotion.ts b/src/motion/DeviceMotion.ts index 7a54094..70ef5ef 100644 --- a/src/motion/DeviceMotion.ts +++ b/src/motion/DeviceMotion.ts @@ -1,146 +1,473 @@ +import { OneEuro } from './OneEuro.js'; +export interface MotionVector { + x: number; + y: number; + z: number; +} + +export type DeviceMotionCallback = (data: MotionVector) => void; + +export type DeviceMotionPermissionState = + | 'unknown' + | 'unsupported' + | 'insecure' + | 'prompt' + | 'granted' + | 'denied'; + +export interface DeviceMotionOptions { + /** One-Euro min cutoff (Hz). Lower = smoother at rest. Default 0.5. */ + oneEuroMinCutoff?: number; + /** One-Euro speed responsiveness. Default 0.01 for ambient backgrounds. */ + oneEuroBeta?: number; + /** One-Euro speed-estimate cutoff (Hz). Default 1.0. */ + oneEuroDCutoff?: number; + /** Slow continuous baseline EMA. Default 0.0008, roughly 30 s tau. */ + baselineAlpha?: number; + /** Discard events for the first N ms after listener startup. Default 250. */ + warmupMs?: number; + /** Suppress output when |beta| exceeds this. Default 120 degrees. */ + faceDownThreshold?: number; + /** Reset filters if event gap exceeds this. Default 2000 ms. */ + staleEventMs?: number; + /** Emit neutral output if events stop for this long. Default 2000 ms. */ + idleResetMs?: number; + /** Degrees mapped to +/-1. Default 45, matching casual tilt range. */ + range?: number; + /** Manual calibration sample count used by calibrate(). Default 8. */ + calibrationSamples?: number; + /** Values smaller than this are treated as rest-state noise. Default 0.015. */ + deadZone?: number; +} + +interface MotionWindow { + DeviceOrientationEvent?: { + requestPermission?: () => Promise<'granted' | 'denied'>; + }; +} + +interface LegacyMediaQueryList { + addListener?: (listener: () => void) => void; + removeListener?: (listener: () => void) => void; +} + +const DEFAULTS = { + oneEuroMinCutoff: 0.5, + oneEuroBeta: 0.01, + oneEuroDCutoff: 1.0, + baselineAlpha: 0.0008, + warmupMs: 250, + faceDownThreshold: 120, + staleEventMs: 2000, + idleResetMs: 2000, + range: 45, + calibrationSamples: 8, + deadZone: 0.015, +} satisfies Required; + +// Convert raw (beta, gamma) to screen-aligned tilt. sx is left/right +// tilt as felt by the user; sy is front/back tilt as felt by the user. +export function remapToScreen(beta: number, gamma: number, angle: number): [number, number] { + switch (angle) { + case 90: + return [beta, -gamma]; + case 180: + return [-gamma, -beta]; + case 270: + return [-beta, gamma]; + case 0: + default: + return [gamma, beta]; + } +} + +function clamp(value: number, min = -1, max = 1): number { + return Math.max(min, Math.min(max, value)); +} + +function applyDeadZone(value: number, deadZone: number): number { + return Math.abs(value) < deadZone ? 0 : value; +} + +function getPermissionApi(): (() => Promise<'granted' | 'denied'>) | null { + if (typeof window === 'undefined') return null; + const constructor = (window as unknown as MotionWindow).DeviceOrientationEvent; + const requestPermission = constructor?.requestPermission; + return typeof requestPermission === 'function' ? requestPermission.bind(constructor) : null; +} +export function getDeviceMotionCapabilityState(): DeviceMotionPermissionState { + if (typeof window === 'undefined') return 'unsupported'; + if (!window.isSecureContext) return 'insecure'; + return 'DeviceOrientationEvent' in window ? 'unknown' : 'unsupported'; +} +export function isDeviceMotionPermissionRequired(): boolean { + return getPermissionApi() !== null; +} +function getScreenOrientationAngle(): number { + if (typeof screen === 'undefined') return 0; + return screen.orientation?.angle ?? 0; +} -export type DeviceMotionCallback = (data: { x: number; y: number; z: number }) => void; +function addMediaQueryChangeListener(mql: MediaQueryList, listener: () => void): void { + if (typeof mql.addEventListener === 'function') { + mql.addEventListener('change', listener); + return; + } + + (mql as LegacyMediaQueryList).addListener?.(listener); +} + +function removeMediaQueryChangeListener(mql: MediaQueryList, listener: () => void): void { + if (typeof mql.removeEventListener === 'function') { + mql.removeEventListener('change', listener); + return; + } + + (mql as LegacyMediaQueryList).removeListener?.(listener); +} export class DeviceMotion { - private callback: DeviceMotionCallback; + private readonly callback: DeviceMotionCallback; + private readonly opts: Required; + private readonly filterX: OneEuro; + private readonly filterY: OneEuro; + private permissionState: DeviceMotionPermissionState = 'unknown'; private isListening = false; - private useMotionAPI = false; + private disposed = false; + private listenerStartedAt = 0; + private lastEventAt = 0; + private baseX = 0; + private baseY = 0; + private lastScreen: { x: number; y: number } | null = null; + private calibrationRemaining = 0; + private calibrationTargetSamples = 0; + private calibrationTotalX = 0; + private calibrationTotalY = 0; + private boundOrientation: ((event: DeviceOrientationEvent) => void) | null = null; + private boundVisibility: (() => void) | null = null; + private reducedMotionMql: MediaQueryList | null = null; + private reducedMotionListener: (() => void) | null = null; + private blockedByReducedMotion = false; + private idleResetTimer: ReturnType | null = null; - constructor(callback: DeviceMotionCallback) { + constructor(callback: DeviceMotionCallback, options: DeviceMotionOptions = {}) { this.callback = callback; + this.opts = { ...DEFAULTS, ...options }; + const params = { + minCutoff: this.opts.oneEuroMinCutoff, + beta: this.opts.oneEuroBeta, + dCutoff: this.opts.oneEuroDCutoff, + }; + this.filterX = new OneEuro(params); + this.filterY = new OneEuro(params); } - async initialize(): Promise { - - if (typeof window === 'undefined') { - return; + async initialize(): Promise { + if (!this.detectSupport()) return false; + this.observeReducedMotion(); + + if (this.prefersReducedMotion()) { + this.blockedByReducedMotion = true; + this.permissionState = 'denied'; + this.stopListening(); + return false; } - - if (!window.isSecureContext) { - console.warn('DeviceMotion APIs require a secure context (HTTPS)'); - return; + if (getPermissionApi()) { + this.permissionState = 'prompt'; + return false; } - - if ('DeviceMotionEvent' in window) { - this.useMotionAPI = true; - } else if ('DeviceOrientationEvent' in window) { - this.useMotionAPI = false; + this.permissionState = 'granted'; + this.startListening(); + return true; + } + + async requestPermission(): Promise { + if (!this.detectSupport()) return false; + this.observeReducedMotion(); + + if (this.prefersReducedMotion()) { + this.blockedByReducedMotion = true; + this.permissionState = 'denied'; + this.stopListening(); + return false; + } + + const requestPermission = getPermissionApi(); + if (requestPermission) { + this.permissionState = 'prompt'; + try { + this.permissionState = await requestPermission(); + } catch { + this.permissionState = 'denied'; + } } else { - console.log('No device motion/orientation APIs supported'); + this.permissionState = 'granted'; + } + + if (this.disposed) { + this.stopListening(); + return false; + } + + if (this.prefersReducedMotion()) { + this.blockedByReducedMotion = true; + this.stopListening(); + this.resetFilterState(); + this.emitNeutral(); + return false; + } + + if (this.permissionState !== 'granted') { + this.stopListening(); + return false; + } + + this.startListening(); + return true; + } + + /** + * Re-zero tilt from the next N orientation samples. Calibration samples are + * consumed for the baseline only; normal output resumes on the following event. + */ + calibrate(samples = this.opts.calibrationSamples): void { + const sampleCount = Math.max(0, Math.floor(samples)); + this.resetFilterState({ resetWarmup: false }); + + if (sampleCount === 0) { + if (this.lastScreen) { + this.baseX = this.lastScreen.x; + this.baseY = this.lastScreen.y; + } + this.calibrationRemaining = 0; return; } - - const hasPermission = await this.requestPermission(); - if (hasPermission) { - this.startListening(); - } else { - console.warn('Device motion permission denied or not available'); + this.calibrationRemaining = sampleCount; + this.calibrationTargetSamples = sampleCount; + this.calibrationTotalX = 0; + this.calibrationTotalY = 0; + } + + getPermissionState(): DeviceMotionPermissionState { + return this.permissionState; + } + + isActive(): boolean { + return this.isListening; + } + + cleanup(): void { + this.disposed = true; + this.stopListening(); + + if (this.reducedMotionMql && this.reducedMotionListener) { + removeMediaQueryChangeListener(this.reducedMotionMql, this.reducedMotionListener); } + this.reducedMotionMql = null; + this.reducedMotionListener = null; + this.blockedByReducedMotion = false; + this.resetFilterState(); } - async requestPermission(): Promise { - - if ( - this.useMotionAPI && - typeof (DeviceMotionEvent as unknown as { requestPermission?: () => Promise }) - .requestPermission === 'function' - ) { - try { - const response = await ( - DeviceMotionEvent as unknown as { requestPermission: () => Promise } - ).requestPermission(); - return response === 'granted'; - } catch (error) { - console.error('Error requesting device motion permission:', error); - return false; + private detectSupport(): boolean { + if (this.disposed) return false; + + const capabilityState = getDeviceMotionCapabilityState(); + if (capabilityState !== 'unknown') { + this.permissionState = capabilityState; + return false; + } + + return true; + } + + private observeReducedMotion(): void { + if (this.reducedMotionMql || typeof window === 'undefined') return; + + this.reducedMotionMql = window.matchMedia?.('(prefers-reduced-motion: reduce)') ?? null; + this.reducedMotionListener = () => { + if (this.disposed || !this.reducedMotionMql) return; + + if (this.reducedMotionMql.matches) { + this.blockedByReducedMotion = true; + this.stopListening(); + this.resetFilterState(); + this.emitNeutral(); + return; } - } else if ( - !this.useMotionAPI && - typeof (DeviceOrientationEvent as unknown as { requestPermission?: () => Promise }) - .requestPermission === 'function' - ) { - try { - const response = await ( - DeviceOrientationEvent as unknown as { requestPermission: () => Promise } - ).requestPermission(); - return response === 'granted'; - } catch (error) { - console.error('Error requesting device orientation permission:', error); - return false; + + if (this.blockedByReducedMotion) { + this.blockedByReducedMotion = false; + if (this.permissionState === 'granted' || !getPermissionApi()) { + this.permissionState = 'granted'; + this.startListening(); + return; + } + this.permissionState = 'prompt'; + return; + } + + if (this.permissionState === 'granted') { + this.startListening(); } + }; + if (this.reducedMotionMql) { + addMediaQueryChangeListener(this.reducedMotionMql, this.reducedMotionListener); } + } - - return true; + private prefersReducedMotion(): boolean { + return this.reducedMotionMql?.matches ?? false; } private startListening(): void { - if (this.isListening) return; + if (this.disposed || this.isListening || typeof window === 'undefined') return; - if (this.useMotionAPI) { - window.addEventListener('devicemotion', this.handleMotion); - } else { - window.addEventListener('deviceorientation', this.handleOrientation); - } + this.boundOrientation = (event: DeviceOrientationEvent) => this.handleOrientation(event); + window.addEventListener('deviceorientation', this.boundOrientation, { passive: true }); + + this.boundVisibility = () => { + if (document.hidden) { + this.resetFilterState(); + this.emitNeutral(); + } + }; + document.addEventListener('visibilitychange', this.boundVisibility); + + this.listenerStartedAt = this.now(); + this.lastEventAt = 0; this.isListening = true; + this.armIdleReset(); } - private handleMotion = (event: DeviceMotionEvent): void => { - try { - if (!event.accelerationIncludingGravity) return; + private stopListening(): void { + if (!this.isListening) return; + this.clearIdleReset(); - const { x, y, z } = event.accelerationIncludingGravity; - if (x === null || y === null || z === null) return; - - - - const data = { - x: Math.max(-1, Math.min(1, x / 9.8)), - y: Math.max(-1, Math.min(1, y / 9.8)), - z: Math.max(-1, Math.min(1, z / 9.8)), - }; + if (this.boundOrientation) { + window.removeEventListener('deviceorientation', this.boundOrientation); + this.boundOrientation = null; + } - this.callback(data); - } catch (error) { - console.error('Error handling device motion:', error); + if (this.boundVisibility) { + document.removeEventListener('visibilitychange', this.boundVisibility); + this.boundVisibility = null; } - }; - private handleOrientation = (event: DeviceOrientationEvent): void => { - try { - if (event.beta === null || event.gamma === null) return; + this.isListening = false; + } + + private handleOrientation(event: DeviceOrientationEvent): void { + if (this.disposed || event.beta == null || event.gamma == null) return; - - - + const now = this.now(); + if (now - this.listenerStartedAt < this.opts.warmupMs) return; - const data = { - x: event.beta / 90, - y: event.gamma / 90, - z: event.alpha ? event.alpha / 360 : 0, - }; + if (this.lastEventAt > 0 && now - this.lastEventAt > this.opts.staleEventMs) { + this.resetFilterState(); + this.listenerStartedAt = now; + this.lastEventAt = now; + this.emitNeutral(); + this.armIdleReset(); + return; + } + this.lastEventAt = now; - this.callback(data); - } catch (error) { - console.error('Error handling device orientation:', error); + if (Math.abs(event.beta) > this.opts.faceDownThreshold) { + this.emitNeutral(); + this.armIdleReset(); + return; } - }; - cleanup(): void { - if (this.isListening) { - if (this.useMotionAPI) { - window.removeEventListener('devicemotion', this.handleMotion); - } else { - window.removeEventListener('deviceorientation', this.handleOrientation); - } - this.isListening = false; + const [screenX, screenY] = remapToScreen( + event.beta, + event.gamma, + getScreenOrientationAngle(), + ); + this.lastScreen = { x: screenX, y: screenY }; + + if (!this.consumeCalibrationSample(screenX, screenY)) { + this.armIdleReset(); + return; + } + + const alpha = this.opts.baselineAlpha; + this.baseX += alpha * (screenX - this.baseX); + this.baseY += alpha * (screenY - this.baseY); + + const xRaw = (screenX - this.baseX) / this.opts.range; + const yRaw = (screenY - this.baseY) / this.opts.range; + const xFiltered = this.filterX.filter(xRaw, now); + const yFiltered = this.filterY.filter(yRaw, now); + + this.callback({ + x: applyDeadZone(clamp(xFiltered), this.opts.deadZone), + y: applyDeadZone(clamp(yFiltered), this.opts.deadZone), + z: 0, + }); + this.armIdleReset(); + } + + private consumeCalibrationSample(screenX: number, screenY: number): boolean { + if (this.calibrationRemaining <= 0) return true; + + this.calibrationTotalX += screenX; + this.calibrationTotalY += screenY; + this.calibrationRemaining -= 1; + + if (this.calibrationRemaining > 0) return false; + + const sampleCount = Math.max(1, this.calibrationTargetSamples); + this.baseX = this.calibrationTotalX / sampleCount; + this.baseY = this.calibrationTotalY / sampleCount; + this.calibrationTotalX = 0; + this.calibrationTotalY = 0; + this.resetFilterState({ resetWarmup: false }); + return false; + } + + private resetFilterState({ resetWarmup = true } = {}): void { + this.filterX.reset(); + this.filterY.reset(); + if (resetWarmup) { + this.listenerStartedAt = this.now(); } + this.lastEventAt = 0; + } + + private emitNeutral(): void { + this.callback({ x: 0, y: 0, z: 0 }); + } + + private armIdleReset(): void { + this.clearIdleReset(); + if (!this.isListening || this.opts.idleResetMs <= 0) return; + + this.idleResetTimer = setTimeout(() => { + this.idleResetTimer = null; + if (this.disposed || !this.isListening) return; + + this.resetFilterState({ resetWarmup: false }); + this.emitNeutral(); + }, this.opts.idleResetMs); + + (this.idleResetTimer as { unref?: () => void }).unref?.(); + } + + private clearIdleReset(): void { + if (this.idleResetTimer === null) return; + clearTimeout(this.idleResetTimer); + this.idleResetTimer = null; + } + + private now(): number { + return typeof performance !== 'undefined' ? performance.now() : Date.now(); } } diff --git a/src/motion/OneEuro.ts b/src/motion/OneEuro.ts new file mode 100644 index 0000000..d7b9348 --- /dev/null +++ b/src/motion/OneEuro.ts @@ -0,0 +1,63 @@ +// One-Euro filter — Casiez, Roussel & Vogel, CHI 2012. + +const TAU = (fc: number) => 1 / (2 * Math.PI * fc); +const alpha = (fc: number, dt: number) => 1 / (1 + TAU(fc) / dt); + +class LowPass { + private y: number | undefined; + + filter(x: number, a: number): number { + this.y = this.y === undefined ? x : a * x + (1 - a) * this.y; + return this.y; + } + + reset(): void { + this.y = undefined; + } +} + +export interface OneEuroParams { + /** Hz — cutoff at zero velocity. Lower = smoother at rest. */ + minCutoff: number; + /** Hz/(unit/s) — cutoff increase per unit of speed. Higher = faster response. */ + beta: number; + /** Hz — speed-estimate smoothing cutoff. */ + dCutoff: number; +} + +export class OneEuro { + private x = new LowPass(); + private dx = new LowPass(); + private prevX: number | undefined; + private prevT: number | undefined; + + constructor(private p: OneEuroParams) { + if (p.minCutoff <= 0 || p.dCutoff <= 0) { + throw new RangeError('OneEuro: minCutoff and dCutoff must be > 0'); + } + } + + /** Filter sample `x` at time `tMs` (milliseconds). */ + filter(x: number, tMs: number): number { + if (this.prevT === undefined || this.prevX === undefined) { + this.prevT = tMs; + this.prevX = x; + return this.x.filter(x, 1); + } + // tMs must be monotonically non-decreasing; backward jumps clamp to 1 ms. + const dt = Math.max(1e-3, (tMs - this.prevT) / 1000); + const dxRaw = (x - this.prevX) / dt; + const dxHat = this.dx.filter(dxRaw, alpha(this.p.dCutoff, dt)); + const fc = this.p.minCutoff + this.p.beta * Math.abs(dxHat); + const xHat = this.x.filter(x, alpha(fc, dt)); + this.prevX = x; + this.prevT = tMs; + return xHat; + } + + reset(): void { + this.x.reset(); + this.dx.reset(); + this.prevX = this.prevT = undefined; + } +} diff --git a/src/motion/PointerMapper.ts b/src/motion/PointerMapper.ts new file mode 100644 index 0000000..adb48e3 --- /dev/null +++ b/src/motion/PointerMapper.ts @@ -0,0 +1,43 @@ +export interface PointerBounds { + left: number; + top: number; + width: number; + height: number; +} + +export interface PhysicsPoint { + x: number; + y: number; +} + +export interface PhysicsRange { + min: number; + max: number; +} + +const DEFAULT_RANGE: PhysicsRange = { min: 0, max: 100 }; + +const clamp01 = (value: number): number => Math.max(0, Math.min(1, value)); + +export function mapClientPointToPhysics( + clientX: number, + clientY: number, + bounds: PointerBounds, + range: PhysicsRange = DEFAULT_RANGE, +): PhysicsPoint { + if (bounds.width <= 0 || bounds.height <= 0) { + return { + x: (range.min + range.max) / 2, + y: (range.min + range.max) / 2, + }; + } + + const span = range.max - range.min; + const normalizedX = clamp01((clientX - bounds.left) / bounds.width); + const normalizedY = clamp01((clientY - bounds.top) / bounds.height); + + return { + x: range.min + normalizedX * span, + y: range.min + normalizedY * span, + }; +} diff --git a/src/motion/PointerPhysicsController.ts b/src/motion/PointerPhysicsController.ts new file mode 100644 index 0000000..69a7e3b --- /dev/null +++ b/src/motion/PointerPhysicsController.ts @@ -0,0 +1,173 @@ +import { + mapClientPointToPhysics, + type PhysicsPoint, + type PhysicsRange, + type PointerBounds, +} from './PointerMapper.js'; + +export type PointerMoveEventName = 'pointermove' | 'mousemove'; +export type PointerExitEventName = 'pointerout' | 'mouseout'; +export type PointerCancelEventName = 'pointercancel'; +export type PointerLifecycleEventName = + | PointerMoveEventName + | PointerExitEventName + | PointerCancelEventName + | 'blur'; + +export interface PointerPhysicsEventTarget { + addEventListener( + type: PointerLifecycleEventName, + listener: EventListener, + options?: AddEventListenerOptions, + ): void; + removeEventListener(type: PointerLifecycleEventName, listener: EventListener): void; +} + +export interface PointerLikeEvent { + clientX: number; + clientY: number; + getCoalescedEvents?: () => PointerLikeEvent[]; +} + +export interface PointerExitLikeEvent { + relatedTarget?: EventTarget | null; +} + +export interface PointerPhysicsControllerOptions { + target: PointerPhysicsEventTarget; + getBounds: () => PointerBounds; + updatePosition: (position: PhysicsPoint) => void; + range?: PhysicsRange; + supportsPointerEvents?: boolean; + requestFrame?: (callback: FrameRequestCallback) => number; + cancelFrame?: (handle: number) => void; +} + +export interface PointerCapabilityEnvironment { + PointerEvent?: unknown; + MouseEvent?: unknown; + navigator?: { + maxTouchPoints?: number; + }; + matchMedia?: (query: string) => { matches: boolean }; +} + +export interface PointerPhysicsController { + readonly eventName: PointerMoveEventName; + readonly exitEventName: PointerExitEventName; + readonly cancelEventName: PointerCancelEventName | null; + flush(): void; + dispose(): void; +} + +export function getLatestPointerEvent(event: PointerLikeEvent): PointerLikeEvent { + const coalesced = + typeof event.getCoalescedEvents === 'function' ? event.getCoalescedEvents() : []; + return coalesced.length > 0 ? coalesced[coalesced.length - 1] : event; +} + +export function detectPointerPhysicsCapability( + environment: PointerCapabilityEnvironment = globalThis, +): boolean { + if (typeof environment.PointerEvent !== 'undefined') return true; + if ((environment.navigator?.maxTouchPoints ?? 0) > 0) return true; + if (environment.matchMedia?.('(pointer: fine), (pointer: coarse)').matches) return true; + return typeof environment.MouseEvent !== 'undefined'; +} + +function getRangeCenter(range?: PhysicsRange): PhysicsPoint { + const center = range ? (range.min + range.max) / 2 : 50; + return { x: center, y: center }; +} + +export function createPointerPhysicsController( + options: PointerPhysicsControllerOptions, +): PointerPhysicsController { + const requestFrame = options.requestFrame ?? requestAnimationFrame; + const cancelFrame = options.cancelFrame ?? cancelAnimationFrame; + const supportsPointerEvents = + options.supportsPointerEvents ?? typeof PointerEvent !== 'undefined'; + const eventName: PointerMoveEventName = supportsPointerEvents ? 'pointermove' : 'mousemove'; + const exitEventName: PointerExitEventName = supportsPointerEvents ? 'pointerout' : 'mouseout'; + const cancelEventName: PointerCancelEventName | null = supportsPointerEvents + ? 'pointercancel' + : null; + + let frame: number | null = null; + let pendingPosition: PhysicsPoint | null = null; + let disposed = false; + + const flush = () => { + frame = null; + if (disposed || !pendingPosition) return; + + options.updatePosition(pendingPosition); + pendingPosition = null; + }; + + const resetPosition = () => { + if (frame !== null) { + cancelFrame(frame); + frame = null; + } + pendingPosition = null; + options.updatePosition(getRangeCenter(options.range)); + }; + + const handleMove: EventListener = (event) => { + if (disposed) return; + + const pointerEvent = getLatestPointerEvent(event as unknown as PointerLikeEvent); + pendingPosition = mapClientPointToPhysics( + pointerEvent.clientX, + pointerEvent.clientY, + options.getBounds(), + options.range, + ); + + if (frame === null) { + frame = requestFrame(flush); + } + }; + + const handleExit: EventListener = (event) => { + if (disposed) return; + const exitEvent = event as unknown as PointerExitLikeEvent; + if (exitEvent.relatedTarget) return; + resetPosition(); + }; + + const handleBlur: EventListener = () => { + if (disposed) return; + resetPosition(); + }; + + options.target.addEventListener(eventName, handleMove, { passive: true }); + options.target.addEventListener(exitEventName, handleExit, { passive: true }); + if (cancelEventName) { + options.target.addEventListener(cancelEventName, handleBlur); + } + options.target.addEventListener('blur', handleBlur); + + return { + eventName, + exitEventName, + cancelEventName, + flush, + dispose() { + if (disposed) return; + disposed = true; + options.target.removeEventListener(eventName, handleMove); + options.target.removeEventListener(exitEventName, handleExit); + if (cancelEventName) { + options.target.removeEventListener(cancelEventName, handleBlur); + } + options.target.removeEventListener('blur', handleBlur); + if (frame !== null) { + cancelFrame(frame); + frame = null; + } + pendingPosition = null; + }, + }; +} diff --git a/src/motion/ScrollHandler.ts b/src/motion/ScrollHandler.ts index c30acd6..3b3c389 100644 --- a/src/motion/ScrollHandler.ts +++ b/src/motion/ScrollHandler.ts @@ -1,11 +1,7 @@ - - - - - - export interface ScrollHandlerConfig { + /** Per-frame decay multiplier for scroll stickiness and velocity. Defaults to 0.92. */ decayRate?: number; + /** Maximum retained pull-force impulses. Defaults to 8, or 10 for explosive scrolls. Use 0 to keep scroll stickiness without retained pull forces. */ maxForces?: number; } @@ -23,12 +19,21 @@ export class ScrollHandler { private scrollDirection = 0; private pullForces: PullForce[] = []; private peakVelocity = 0; + private decayFrame: number | null = null; + private scrollEndTimer: ReturnType | null = null; + private disposed = false; + private maxForces: number | null = null; constructor(config?: ScrollHandlerConfig) { if (config?.decayRate) this.decayRate = config.decayRate; + if (typeof config?.maxForces === 'number') { + this.maxForces = Math.max(0, Math.floor(config.maxForces)); + } } public handleScroll(event: WheelEvent): void { + if (this.disposed) return; + const currentTime = Date.now(); const deltaTime = currentTime - this.lastScrollTime; @@ -67,9 +72,17 @@ export class ScrollHandler { this.lastScrollTime = currentTime; this.startDecay(); + this.scheduleScrollEnd(); + } + + private scheduleScrollEnd(): void { + if (this.scrollEndTimer !== null) { + clearTimeout(this.scrollEndTimer); + } - setTimeout(() => { - if (currentTime - this.lastScrollTime >= 200) { + this.scrollEndTimer = setTimeout(() => { + this.scrollEndTimer = null; + if (Date.now() - this.lastScrollTime >= 200) { this.isScrolling = false; this.totalScrollDistance = 0; this.peakVelocity = 0; @@ -81,7 +94,7 @@ export class ScrollHandler { speedStickiness: number, distanceStickiness: number, direction: number, - explosive: boolean + explosive: boolean, ): void { if (direction <= 0 || speedStickiness > 0.4 || distanceStickiness > 0.4 || explosive) { let pullStrength = speedStickiness + distanceStickiness * 0.7; @@ -100,17 +113,23 @@ export class ScrollHandler { strength: pullStrength, time: 0, randomness: randomnessFactor, - explosive: explosive, + explosive, }); - if (this.pullForces.length > (explosive ? 10 : 8)) { - this.pullForces.shift(); + const maxForces = this.maxForces ?? (explosive ? 10 : 8); + if (this.pullForces.length > maxForces) { + this.pullForces.splice(0, this.pullForces.length - maxForces); } } } private startDecay(): void { + if (this.decayFrame !== null) return; + const decay = () => { + this.decayFrame = null; + if (this.disposed) return; + this.stickiness *= this.decayRate; this.scrollVelocity *= this.decayRate; @@ -126,13 +145,13 @@ export class ScrollHandler { })); if (this.stickiness > 0.01 || this.pullForces.length > 0) { - requestAnimationFrame(decay); + this.decayFrame = requestAnimationFrame(decay); } else { this.stickiness = 0; this.scrollVelocity = 0; } }; - requestAnimationFrame(decay); + this.decayFrame = requestAnimationFrame(decay); } public getStickiness(): number { @@ -162,4 +181,25 @@ export class ScrollHandler { public getPeakVelocity(): number { return this.peakVelocity; } + + public dispose(): void { + this.disposed = true; + + if (this.decayFrame !== null) { + cancelAnimationFrame(this.decayFrame); + this.decayFrame = null; + } + + if (this.scrollEndTimer !== null) { + clearTimeout(this.scrollEndTimer); + this.scrollEndTimer = null; + } + + this.stickiness = 0; + this.scrollVelocity = 0; + this.totalScrollDistance = 0; + this.peakVelocity = 0; + this.isScrolling = false; + this.pullForces = []; + } } diff --git a/src/motion/index.ts b/src/motion/index.ts index 5ded2ef..1e26e10 100644 --- a/src/motion/index.ts +++ b/src/motion/index.ts @@ -2,5 +2,32 @@ -export { DeviceMotion, type DeviceMotionCallback } from './DeviceMotion.js'; +export { + DeviceMotion, + type DeviceMotionCallback, + type DeviceMotionOptions, + type DeviceMotionPermissionState, + type MotionVector, +} from './DeviceMotion.js'; +export { + mapClientPointToPhysics, + type PhysicsPoint, + type PhysicsRange, + type PointerBounds, +} from './PointerMapper.js'; +export { + createPointerPhysicsController, + detectPointerPhysicsCapability, + getLatestPointerEvent, + type PointerCapabilityEnvironment, + type PointerCancelEventName, + type PointerExitEventName, + type PointerExitLikeEvent, + type PointerLifecycleEventName, + type PointerLikeEvent, + type PointerMoveEventName, + type PointerPhysicsController, + type PointerPhysicsControllerOptions, + type PointerPhysicsEventTarget, +} from './PointerPhysicsController.js'; export { ScrollHandler, type ScrollHandlerConfig, type PullForce } from './ScrollHandler.js'; diff --git a/src/svelte/BlobSVG.svelte b/src/svelte/BlobSVG.svelte index 1b86fe7..50d3dbd 100644 --- a/src/svelte/BlobSVG.svelte +++ b/src/svelte/BlobSVG.svelte @@ -6,11 +6,10 @@ // Props using Svelte 5 $props() syntax interface Props { blobs?: ConvexBlob[]; - containerElement?: HTMLElement | undefined; physics?: BlobPhysics | null; } - let { blobs = [], containerElement = undefined, physics = null }: Props = $props(); + let { blobs = [], physics = null }: Props = $props(); // Track dark mode for blend mode switching let isDarkMode = $state(false); @@ -68,29 +67,35 @@ - + {#each blobs as blob, i (blob.gradientId)} - - - - + + + + - - - - + + + + - - - - + + + + {/each} diff --git a/src/svelte/BlobSVG.svelte.d.ts b/src/svelte/BlobSVG.svelte.d.ts new file mode 100644 index 0000000..4e9ee49 --- /dev/null +++ b/src/svelte/BlobSVG.svelte.d.ts @@ -0,0 +1,11 @@ +import type { Component } from 'svelte'; +import type { BlobPhysics } from '../core/BlobPhysics.js'; +import type { ConvexBlob } from '../core/types.js'; + +export interface BlobSVGProps { + blobs?: ConvexBlob[]; + physics?: BlobPhysics | null; +} + +declare const BlobSVG: Component; +export default BlobSVG; diff --git a/src/svelte/TinyVectors.svelte b/src/svelte/TinyVectors.svelte index f284cc5..8a70989 100644 --- a/src/svelte/TinyVectors.svelte +++ b/src/svelte/TinyVectors.svelte @@ -2,12 +2,24 @@ import { browser } from '../core/browser.js'; import { untrack } from 'svelte'; import { BlobPhysics, type BlobPhysicsConfig } from '../core/BlobPhysics.js'; - import { DeviceMotion } from '../motion/DeviceMotion.js'; + import { + DeviceMotion, + getDeviceMotionCapabilityState, + isDeviceMotionPermissionRequired, + type MotionVector, + } from '../motion/DeviceMotion.js'; + import { + createPointerPhysicsController, + detectPointerPhysicsCapability, + type PointerPhysicsController, + } from '../motion/PointerPhysicsController.js'; + import type { PointerBounds } from '../motion/PointerMapper.js'; import { ScrollHandler } from '../motion/ScrollHandler.js'; - import { THEME_PRESETS, type ThemePresetName } from '../core/schema.js'; + import { THEME_PRESET_COLORS } from '../core/theme-colors.js'; + import type { ThemePresetName } from '../core/theme-presets.js'; + import type { TinyVectorsDeviceMotionStatus } from './types.js'; import BlobSVG from './BlobSVG.svelte'; - // Props interface Props { /** Theme preset name */ theme?: ThemePresetName; @@ -23,10 +35,20 @@ blobCount?: number; /** Physics configuration */ physicsConfig?: Partial; - /** Enable device motion (accelerometer) */ + /** Enable device orientation based motion */ enableDeviceMotion?: boolean; /** Enable scroll physics */ enableScrollPhysics?: boolean; + /** Enable pointer/mouse physics */ + enablePointerPhysics?: boolean; + /** Scales normalized screen-aligned tilt vectors before applying them to physics. */ + deviceMotionStrength?: number; + /** Samples used by calibrateDeviceMotion() when no explicit count is supplied. */ + deviceMotionCalibrationSamples?: number; + /** Milliseconds before paused device-orientation IO resets to neutral. */ + deviceMotionIdleResetMs?: number; + /** Optional diagnostics hook for browser/dev harnesses. */ + onDeviceMotion?: (motionData: MotionVector) => void; } let { @@ -39,98 +61,79 @@ physicsConfig = {}, enableDeviceMotion = true, enableScrollPhysics = true, + enablePointerPhysics = true, + deviceMotionStrength = 0.8, + deviceMotionCalibrationSamples = 8, + deviceMotionIdleResetMs = 2000, + onDeviceMotion, }: Props = $props(); - // State - use regular variables for non-reactive state let containerElement: HTMLDivElement | undefined = $state(undefined); let blobs = $state>([]); let isReady = $state(false); - let isMobileDevice = $state(false); - let hasAccelerometerAccess = $state(false); - // Internal handles that are passed into child components need to stay reactive. let physics = $state(null); let animationFrame: number | null = null; let lastTime = 0; let deviceMotion: DeviceMotion | null = null; let scrollHandler: ScrollHandler | null = null; - let gravityX = 0; - let gravityY = 0; - let tiltX = 0; - let tiltY = 0; - let tiltZ = 0; + let pointerController: PointerPhysicsController | null = null; - // Get theme colors - use $derived.by for computed values const themeColors = $derived.by(() => { if (colors.length > 0) return colors; - const preset = THEME_PRESETS[theme]; - if (!preset || !preset.hasVectors) return []; - return preset.colors.map((c) => c.color); + return THEME_PRESET_COLORS[theme] ?? []; }); - // Default physics config - const defaultPhysicsConfig: BlobPhysicsConfig = { - antiClusteringStrength: 0.15, - bounceDamping: 0.7, - deformationSpeed: 0.5, - territoryStrength: 0.1, - viscosity: 0.3, - useSpatialHash: true, - useGaussianSmoothing: true, - useSpringSystem: true, - springConfig: {}, + const detectDeviceMotionCapability = (): boolean => { + return browser && getDeviceMotionCapabilityState() === 'unknown'; }; - // Detect mobile device - const detectMobileDevice = (): boolean => { - if (!browser) return false; - const userAgent = navigator.userAgent.toLowerCase(); - const mobileKeywords = ['mobile', 'android', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone']; - const isMobileUserAgent = mobileKeywords.some((keyword) => userAgent.includes(keyword)); - const isMobileScreen = window.innerWidth <= 768 || window.innerHeight <= 768; - const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0; - const hasOrientationAPI = 'DeviceOrientationEvent' in window; - return (isMobileUserAgent || (isMobileScreen && hasTouchScreen)) && hasOrientationAPI; - }; + const createDeviceMotion = (): DeviceMotion => + new DeviceMotion(handleDeviceMotion, { + calibrationSamples: deviceMotionCalibrationSamples, + deadZone: 0.015, + idleResetMs: deviceMotionIdleResetMs, + }); - // Handle device motion - no reactive state updates - const handleDeviceMotion = (motionData: { x: number; y: number; z: number }) => { - if (!hasAccelerometerAccess || !physics) return; + const handleDeviceMotion = (motionData: MotionVector) => { + if (!physics) return; - tiltX = motionData.x; - tiltY = motionData.y; - tiltZ = motionData.z; + onDeviceMotion?.(motionData); - // Convert to gravity vector - const newX = motionData.y * 0.8; - const newY = -motionData.x * 0.8; + // DeviceMotion emits screen-aligned tilt, so no old beta/gamma axis swap. + physics.setGravity({ + x: motionData.x * deviceMotionStrength, + y: motionData.y * deviceMotionStrength, + }); + physics.setTilt(motionData); + }; - // Smooth the values - gravityX = newX * 0.7 + gravityX * 0.3; - gravityY = newY * 0.7 + gravityY * 0.3; + export async function requestDeviceMotionPermission(): Promise { + if (!browser || !enableDeviceMotion || !physics || !detectDeviceMotionCapability()) return false; - // Pass to physics - physics.setGravity({ x: gravityX, y: gravityY }); - physics.setTilt({ x: tiltX, y: tiltY, z: tiltZ }); - }; + deviceMotion ??= createDeviceMotion(); - // Request accelerometer permission - const requestAccelerometerPermission = async (): Promise => { - if (!isMobileDevice || !deviceMotion) return; + const hasPermission = await deviceMotion.requestPermission(); + return hasPermission; + } - try { - const hasPermission = await deviceMotion.requestPermission(); - hasAccelerometerAccess = hasPermission; - if (hasPermission) { - console.log('[TinyVectors] Accelerometer access granted'); - } - } catch (error) { - console.log('[TinyVectors] Could not request accelerometer permission:', error); - hasAccelerometerAccess = false; - } - }; + export function calibrateDeviceMotion(samples?: number): void { + deviceMotion?.calibrate(samples); + } + + export function getDeviceMotionStatus(): TinyVectorsDeviceMotionStatus { + const capabilityState = getDeviceMotionCapabilityState(); + const permissionState = deviceMotion?.getPermissionState() ?? capabilityState; + + return { + enabled: enableDeviceMotion, + supported: capabilityState !== 'unsupported' && capabilityState !== 'insecure', + requiresPermission: browser && isDeviceMotionPermissionRequired(), + permissionState, + active: deviceMotion?.isActive() ?? false, + }; + } - // Handle scroll passively — never block native scrolling const handleScroll = (event: WheelEvent) => { if (!scrollHandler || !physics) return; scrollHandler.handleScroll(event); @@ -138,14 +141,32 @@ physics.setScrollStickiness(stickiness); }; - // Animation tick function - updates blobs state once per frame + const getPointerBounds = (): PointerBounds => { + const rect = containerElement?.getBoundingClientRect(); + + if (rect && rect.width > 0 && rect.height > 0) { + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; + } + + return { + left: 0, + top: 0, + width: window.innerWidth || 1, + height: window.innerHeight || 1, + }; + }; + function tick(currentTime: number) { const dt = Math.min((currentTime - lastTime) / 1000, 0.033); lastTime = currentTime; if (physics) { physics.tick(dt, currentTime / 1000); - // Update blobs state - this triggers re-render blobs = physics.getBlobs(themeColors); } @@ -165,34 +186,37 @@ } } - // Single initialization effect $effect(() => { if (!browser || !shouldLoad) return; - // Use untrack to prevent this effect from re-running on state changes + let disposed = false; + const deviceMotionEnabled = enableDeviceMotion; + const scrollPhysicsEnabled = enableScrollPhysics; + const pointerPhysicsEnabled = enablePointerPhysics; + let wheelListenerAttached = false; + untrack(() => { - const config = { ...defaultPhysicsConfig, ...physicsConfig }; - physics = new BlobPhysics(blobCount, config); + // BlobPhysics owns base defaults; this component forwards caller overrides. + const currentPhysics = new BlobPhysics(blobCount, physicsConfig); + physics = currentPhysics; - physics.init().then(() => { - // Detect mobile - isMobileDevice = detectMobileDevice(); + currentPhysics.init().then(() => { + if (disposed || physics !== currentPhysics) return; + + const hasDeviceMotionCapability = detectDeviceMotionCapability(); isReady = true; - // Initialize device motion on mobile - if (enableDeviceMotion && isMobileDevice) { - deviceMotion = new DeviceMotion(handleDeviceMotion); - deviceMotion.initialize().then(() => { - setTimeout(requestAccelerometerPermission, 1000); - }); + if (deviceMotionEnabled && hasDeviceMotionCapability) { + if (!deviceMotion) { + deviceMotion = createDeviceMotion(); + void deviceMotion.initialize(); + } } - // Initialize scroll handler - if (enableScrollPhysics) { + if (scrollPhysicsEnabled) { scrollHandler = new ScrollHandler(); } - // Start animation if enabled if (animated) { startAnimation(); } else if (physics) { @@ -200,24 +224,45 @@ } }); - // Set up scroll listener - if (enableScrollPhysics) { + if (scrollPhysicsEnabled) { window.addEventListener('wheel', handleScroll, { passive: true }); + wheelListenerAttached = true; + } + + if (pointerPhysicsEnabled) { + const hasPointerCapability = detectPointerPhysicsCapability(window); + if (hasPointerCapability) { + pointerController = createPointerPhysicsController({ + target: window, + getBounds: getPointerBounds, + supportsPointerEvents: 'PointerEvent' in window, + updatePosition(position) { + physics?.updateMousePosition(position.x, position.y); + }, + }); + } } }); return () => { + disposed = true; stopAnimation(); - if (enableScrollPhysics && browser) { + if (wheelListenerAttached) { window.removeEventListener('wheel', handleScroll); } + pointerController?.dispose(); + pointerController = null; deviceMotion?.cleanup(); + deviceMotion = null; + scrollHandler?.dispose(); + scrollHandler = null; physics?.dispose(); physics = null; + isReady = false; + blobs = []; }; }); - // Handle animated prop changes $effect(() => { if (!isReady) return; @@ -232,19 +277,17 @@ {#if shouldLoad && themeColors.length > 0} {/if} diff --git a/src/svelte/TinyVectors.svelte.d.ts b/src/svelte/TinyVectors.svelte.d.ts new file mode 100644 index 0000000..69d178a --- /dev/null +++ b/src/svelte/TinyVectors.svelte.d.ts @@ -0,0 +1,45 @@ +import type { Component } from 'svelte'; +import type { BlobPhysicsConfig } from '../core/BlobPhysics.js'; +import type { ThemePresetName } from '../core/theme-presets.js'; +import type { MotionVector } from '../motion/DeviceMotion.js'; +import type { TinyVectorsDeviceMotionStatus } from './types.js'; + +export interface TinyVectorsProps { + /** Theme preset name */ + theme?: ThemePresetName; + /** Custom colors (overrides theme preset) */ + colors?: string[]; + /** Whether animation is enabled */ + animated?: boolean; + /** Component opacity */ + opacity?: number; + /** Whether component should load */ + shouldLoad?: boolean; + /** Number of blobs */ + blobCount?: number; + /** Physics configuration */ + physicsConfig?: Partial; + /** Enable device orientation based motion */ + enableDeviceMotion?: boolean; + /** Enable scroll physics */ + enableScrollPhysics?: boolean; + /** Enable pointer/mouse physics */ + enablePointerPhysics?: boolean; + /** Scales normalized screen-aligned tilt vectors before applying them to physics. */ + deviceMotionStrength?: number; + /** Samples used by calibrateDeviceMotion() when no explicit count is supplied. */ + deviceMotionCalibrationSamples?: number; + /** Milliseconds before paused device-orientation IO resets to neutral. */ + deviceMotionIdleResetMs?: number; + /** Optional diagnostics hook for browser/dev harnesses. */ + onDeviceMotion?: (motionData: MotionVector) => void; +} + +export interface TinyVectorsExports { + requestDeviceMotionPermission(): Promise; + calibrateDeviceMotion(samples?: number): void; + getDeviceMotionStatus(): TinyVectorsDeviceMotionStatus; +} + +declare const TinyVectors: Component; +export default TinyVectors; diff --git a/src/svelte/index.ts b/src/svelte/index.ts index 1089ede..01c2ebb 100644 --- a/src/svelte/index.ts +++ b/src/svelte/index.ts @@ -4,6 +4,9 @@ export { default as TinyVectors } from './TinyVectors.svelte'; +export type { + TinyVectorsDeviceMotionStatus, +} from './types.js'; export { default as BlobSVG } from './BlobSVG.svelte'; diff --git a/src/svelte/types.ts b/src/svelte/types.ts new file mode 100644 index 0000000..6d14687 --- /dev/null +++ b/src/svelte/types.ts @@ -0,0 +1,9 @@ +import type { DeviceMotionPermissionState } from '../motion/DeviceMotion.js'; + +export interface TinyVectorsDeviceMotionStatus { + enabled: boolean; + supported: boolean; + requiresPermission: boolean; + permissionState: DeviceMotionPermissionState; + active: boolean; +} diff --git a/src/themes/index.ts b/src/themes/index.ts index 72068d4..46ead4d 100644 --- a/src/themes/index.ts +++ b/src/themes/index.ts @@ -2,7 +2,7 @@ -import { THEME_PRESETS, type ThemePreset } from '../core/schema.js'; +import { THEME_PRESETS, type ThemePreset } from '../core/theme-presets.js'; export { THEME_PRESETS, @@ -13,7 +13,7 @@ export { type ThemePreset, type ThemeColor, type ThemePresetName, -} from '../core/schema.js'; +} from '../core/theme-presets.js'; diff --git a/src/themes/vector-colors.css b/src/themes/vector-colors.css index 3d6bbf6..1e48a02 100644 --- a/src/themes/vector-colors.css +++ b/src/themes/vector-colors.css @@ -2,6 +2,18 @@ * Uses Skeleton's .dark class for theme switching */ +/* Per-blob intensity, registered so calc() in SVG stop-opacity is + * interpolated as a number rather than a string. BlobSVG.svelte sets + * --tv-blob-intensity inline on each ; gradient stops + * compute their actual opacity via calc(var(--tv-blob-intensity) + * * ). + */ +@property --tv-blob-intensity { + syntax: ''; + inherits: true; + initial-value: 1; +} + /* Light mode defaults */ :root { /* === Trans Pride Colors === */ diff --git a/tests/unit/core.test.ts b/tests/unit/core.test.ts index efcc51c..726a96d 100644 --- a/tests/unit/core.test.ts +++ b/tests/unit/core.test.ts @@ -16,6 +16,11 @@ import { } from '../../src/core/PathGenerator.js'; import { SpatialHash } from '../../src/core/SpatialHash.js'; import { GaussianKernel } from '../../src/core/GaussianKernel.js'; +import { + BlobPhysics, + DEFAULT_BLOB_PHYSICS_CONFIG, + type BlobPhysicsConfig, +} from '../../src/core/BlobPhysics.js'; import type { RenderBlob } from '../../src/core/schema.js'; import type { ConvexBlob, ControlPoint } from '../../src/core/types.js'; @@ -93,6 +98,24 @@ function createTestConvexBlob(x: number, y: number, size: number = 25): ConvexBl }; } +function smoothTestControlRadii(radii: number[]): number[] { + const blob = createTestConvexBlob(50, 50, 20); + blob.controlPoints?.forEach((point, index) => { + point.radius = radii[index]; + point.baseRadius = radii[index]; + point.targetRadius = radii[index]; + }); + + const physics = new BlobPhysics(0); + ( + physics as unknown as { + smoothControlPoints(blob: ConvexBlob): void; + } + ).smoothControlPoints(blob); + + return blob.controlPoints?.map((point) => point.radius) ?? []; +} + @@ -223,6 +246,145 @@ describe('PathGenerator', () => { +describe('BlobPhysics', () => { + it('owns the TinyVectors default physics configuration', () => { + expect(DEFAULT_BLOB_PHYSICS_CONFIG).toEqual({ + antiClusteringStrength: 0.15, + bounceDamping: 0.7, + deformationSpeed: 0.5, + territoryStrength: 0.1, + viscosity: 0.3, + useSpatialHash: true, + useGaussianSmoothing: true, + useSpringSystem: true, + springConfig: {}, + }); + }); + + it('merges caller overrides on top of internal defaults', () => { + const physics = new BlobPhysics(2, { + antiClusteringStrength: 0.25, + useSpatialHash: false, + }); + const config = (physics as unknown as { config: BlobPhysicsConfig }).config; + + expect(config).toEqual({ + ...DEFAULT_BLOB_PHYSICS_CONFIG, + antiClusteringStrength: 0.25, + useSpatialHash: false, + }); + }); + + it('smooths control points without start-index directional bias', () => { + const radii = [20, 40, 20, 10, 30, 20, 35, 15]; + const rotatedRadii = [radii[radii.length - 1], ...radii.slice(0, -1)]; + const expected = smoothTestControlRadii(radii); + const rotatedResult = smoothTestControlRadii(rotatedRadii); + const rotatedBack = [...rotatedResult.slice(1), rotatedResult[0]]; + + expect(rotatedBack).toHaveLength(expected.length); + for (let i = 0; i < expected.length; i++) { + expect(rotatedBack[i]).toBeCloseTo(expected[i], 10); + } + }); + + it('smooths control points without winding-order directional bias', () => { + const radii = [20, 40, 20, 10, 30, 20, 35, 15]; + const mirroredRadii = [radii[0], ...radii.slice(1).reverse()]; + const expected = smoothTestControlRadii(radii); + const mirroredResult = smoothTestControlRadii(mirroredRadii); + const mirroredBack = [mirroredResult[0], ...mirroredResult.slice(1).reverse()]; + + expect(mirroredBack).toHaveLength(expected.length); + for (let i = 0; i < expected.length; i++) { + expect(mirroredBack[i]).toBeCloseTo(expected[i], 10); + } + }); + + it('applies device gravity as a bounded directional field', () => { + const physics = new BlobPhysics(0); + const blob = createTestConvexBlob(50, 50, 20); + const applyAccelerometerForces = ( + physics as unknown as { + applyAccelerometerForces(blob: ConvexBlob): void; + } + ).applyAccelerometerForces.bind(physics); + + physics.setGravity({ x: 3, y: 4 }); + applyAccelerometerForces(blob); + + const magnitude = Math.sqrt(blob.velocityX * blob.velocityX + blob.velocityY * blob.velocityY); + expect(magnitude).toBeCloseTo(0.003); + expect(blob.velocityX).toBeGreaterThan(0); + expect(blob.velocityY).toBeGreaterThan(0); + expect(blob.velocityX / blob.velocityY).toBeCloseTo(3 / 4); + }); + + it('tracks pointer position and velocity without applying distant pointer force', () => { + const physics = new BlobPhysics(0); + const blob = createTestConvexBlob(30, 50, 20); + const internals = physics as unknown as { + mouseX: number; + mouseY: number; + mouseVelX: number; + mouseVelY: number; + updateScreensaverPhysics(blob: ConvexBlob, deltaTime: number, time: number): void; + }; + + physics.updateMousePosition(75, 25); + + expect(internals.mouseX).toBe(75); + expect(internals.mouseY).toBe(25); + expect(internals.mouseVelX).toBe(25); + expect(internals.mouseVelY).toBe(-25); + + internals.updateScreensaverPhysics(blob, 0.016, 0); + + expect(blob.mouseDistance).toBeCloseTo(Math.sqrt((30 - 75) ** 2 + (50 - 25) ** 2)); + }); + + it('applies pointer influence as a local field after pointer input', () => { + const physics = new BlobPhysics(0); + const near = createTestConvexBlob(60, 50, 20); + const far = createTestConvexBlob(5, 50, 20); + const centered = createTestConvexBlob(60, 50, 20); + const internals = physics as unknown as { + applyPointerField(blob: ConvexBlob): void; + }; + + internals.applyPointerField(centered); + + expect(centered.velocityX).toBe(0); + expect(centered.velocityY).toBe(0); + + physics.updateMousePosition(75, 50); + internals.applyPointerField(near); + internals.applyPointerField(far); + + expect(near.velocityX).toBeGreaterThan(0); + expect(near.velocityY).toBe(0); + expect(far.velocityX).toBe(0); + expect(far.velocityY).toBe(0); + }); + + it('computes pointer velocity from the previous pointer anchor', () => { + const physics = new BlobPhysics(0); + const internals = physics as unknown as { + mouseVelX: number; + mouseVelY: number; + }; + + physics.updateMousePosition(75, 25); + physics.updateMousePosition(80, 20); + + expect(internals.mouseVelX).toBe(5); + expect(internals.mouseVelY).toBe(-5); + }); +}); + + + + describe('SpatialHash', () => { describe('constructor', () => { it('should create with default cell size', () => { diff --git a/tests/unit/device-motion.test.ts b/tests/unit/device-motion.test.ts new file mode 100644 index 0000000..256b4fa --- /dev/null +++ b/tests/unit/device-motion.test.ts @@ -0,0 +1,458 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + DeviceMotion, + getDeviceMotionCapabilityState, + isDeviceMotionPermissionRequired, +} from '../../src/motion/DeviceMotion.js'; + +type PermissionResponse = 'granted' | 'denied'; + +interface MockMotionWindow extends Partial { + DeviceOrientationEvent?: { requestPermission?: () => Promise }; +} + +let now = 0; + +function createMotionEnvironment(options: { + secure?: boolean; + orientation?: boolean; + permission?: () => Promise; + reducedMotion?: boolean; + angle?: number; + legacyReducedMotionListener?: boolean; +} = {}) { + const windowListeners = new Map>(); + const documentListeners = new Map>(); + const mqlListeners = new Set<() => void>(); + + const addWindowListener = vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { + const listeners = windowListeners.get(type) ?? new Set(); + listeners.add(listener); + windowListeners.set(type, listeners); + }); + const removeWindowListener = vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { + windowListeners.get(type)?.delete(listener); + }); + const addDocumentListener = vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { + const listeners = documentListeners.get(type) ?? new Set(); + listeners.add(listener); + documentListeners.set(type, listeners); + }); + const removeDocumentListener = vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { + documentListeners.get(type)?.delete(listener); + }); + + const mql = { + matches: options.reducedMotion ?? false, + ...(options.legacyReducedMotionListener + ? { + addListener: vi.fn((listener: () => void) => { + mqlListeners.add(listener); + }), + removeListener: vi.fn((listener: () => void) => { + mqlListeners.delete(listener); + }), + } + : { + addEventListener: vi.fn((_type: string, listener: () => void) => { + mqlListeners.add(listener); + }), + removeEventListener: vi.fn((_type: string, listener: () => void) => { + mqlListeners.delete(listener); + }), + }), + }; + + const motionWindow: MockMotionWindow = { + isSecureContext: options.secure ?? true, + addEventListener: addWindowListener, + removeEventListener: removeWindowListener, + matchMedia: vi.fn(() => mql as unknown as MediaQueryList), + }; + const motionDocument = { + hidden: false, + addEventListener: addDocumentListener, + removeEventListener: removeDocumentListener, + }; + + if (options.orientation ?? true) { + motionWindow.DeviceOrientationEvent = {}; + if (options.permission) { + motionWindow.DeviceOrientationEvent.requestPermission = options.permission; + } + } + + vi.stubGlobal('window', motionWindow); + vi.stubGlobal('document', motionDocument); + vi.stubGlobal('screen', { + orientation: { + angle: options.angle ?? 0, + }, + }); + + const dispatchWindow = (type: string, event: unknown) => { + for (const listener of windowListeners.get(type) ?? []) { + if (typeof listener === 'function') { + listener(event as Event); + } else { + listener.handleEvent(event as Event); + } + } + }; + + return { + addDocumentListener, + addWindowListener, + dispatchDocument(type: string) { + for (const listener of documentListeners.get(type) ?? []) { + if (typeof listener === 'function') { + listener({ type } as Event); + } else { + listener.handleEvent({ type } as Event); + } + } + }, + dispatchOrientation(beta: number, gamma: number, alpha: number | null = null) { + dispatchWindow('deviceorientation', { beta, gamma, alpha }); + }, + mql, + motionDocument, + motionWindow, + removeDocumentListener, + removeWindowListener, + dispatchReducedMotionChange() { + for (const listener of mqlListeners) { + listener(); + } + }, + }; +} + +beforeEach(() => { + now = 0; + vi.spyOn(performance, 'now').mockImplementation(() => now); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('DeviceMotion', () => { + it('reports capability and permission requirement without creating a listener', () => { + const requestPermission = vi.fn().mockResolvedValue('granted' as const); + createMotionEnvironment({ permission: requestPermission }); + + expect(getDeviceMotionCapabilityState()).toBe('unknown'); + expect(isDeviceMotionPermissionRequired()).toBe(true); + expect(requestPermission).not.toHaveBeenCalled(); + }); + + it('reports insecure and unsupported capability states', () => { + createMotionEnvironment({ secure: false }); + expect(getDeviceMotionCapabilityState()).toBe('insecure'); + + createMotionEnvironment({ orientation: false }); + expect(getDeviceMotionCapabilityState()).toBe('unsupported'); + expect(isDeviceMotionPermissionRequired()).toBe(false); + }); + + it('reports unsupported when initialized without a browser window', async () => { + const motion = new DeviceMotion(vi.fn()); + + await expect(motion.initialize()).resolves.toBe(false); + + expect(motion.getPermissionState()).toBe('unsupported'); + expect(motion.isActive()).toBe(false); + }); + + it('reports insecure contexts without starting listeners', async () => { + const env = createMotionEnvironment({ secure: false }); + const motion = new DeviceMotion(vi.fn()); + + await expect(motion.initialize()).resolves.toBe(false); + + expect(motion.getPermissionState()).toBe('insecure'); + expect(env.addWindowListener).not.toHaveBeenCalled(); + }); + + it('starts immediately when no permission API is required', async () => { + const env = createMotionEnvironment(); + const callback = vi.fn(); + const motion = new DeviceMotion(callback, { + baselineAlpha: 0, + deadZone: 0, + warmupMs: 250, + }); + + await expect(motion.initialize()).resolves.toBe(true); + now = 300; + env.dispatchOrientation(22.5, -45); + + expect(motion.getPermissionState()).toBe('granted'); + expect(motion.isActive()).toBe(true); + expect(callback).toHaveBeenCalledWith({ x: -1, y: 0.5, z: 0 }); + }); + + it('defers listener startup until explicit permission is granted', async () => { + const requestPermission = vi.fn().mockResolvedValue('granted' as const); + const env = createMotionEnvironment({ permission: requestPermission }); + const callback = vi.fn(); + const motion = new DeviceMotion(callback, { + baselineAlpha: 0, + deadZone: 0, + warmupMs: 250, + }); + + await expect(motion.initialize()).resolves.toBe(false); + + expect(motion.getPermissionState()).toBe('prompt'); + expect(env.addWindowListener).not.toHaveBeenCalled(); + + await expect(motion.requestPermission()).resolves.toBe(true); + now = 300; + env.dispatchOrientation(45, 0); + + expect(requestPermission).toHaveBeenCalledOnce(); + expect(motion.getPermissionState()).toBe('granted'); + expect(callback).toHaveBeenCalledWith({ x: 0, y: 1, z: 0 }); + }); + + it('calls the permission API with DeviceOrientationEvent as receiver', async () => { + let receiver: unknown; + const requestPermission = vi.fn(function (this: unknown) { + receiver = this; + return Promise.resolve('granted' as const); + }); + const env = createMotionEnvironment({ permission: requestPermission }); + const motion = new DeviceMotion(vi.fn()); + + await expect(motion.requestPermission()).resolves.toBe(true); + + expect(receiver).toBe(env.motionWindow.DeviceOrientationEvent); + }); + + it('does not restart listeners after cleanup resolves an in-flight permission request', async () => { + let resolvePermission: (value: PermissionResponse) => void = () => {}; + const permission = new Promise((resolve) => { + resolvePermission = resolve; + }); + const env = createMotionEnvironment({ permission: () => permission }); + const motion = new DeviceMotion(vi.fn()); + + const request = motion.requestPermission(); + motion.cleanup(); + resolvePermission('granted'); + + await expect(request).resolves.toBe(false); + expect(env.addWindowListener).not.toHaveBeenCalled(); + expect(motion.isActive()).toBe(false); + }); + + it('does not start listeners when reduced motion is enabled during a permission request', async () => { + let resolvePermission: (value: PermissionResponse) => void = () => {}; + const permission = new Promise((resolve) => { + resolvePermission = resolve; + }); + const env = createMotionEnvironment({ permission: () => permission }); + const callback = vi.fn(); + const motion = new DeviceMotion(callback); + + const request = motion.requestPermission(); + env.mql.matches = true; + env.dispatchReducedMotionChange(); + resolvePermission('granted'); + + await expect(request).resolves.toBe(false); + expect(motion.isActive()).toBe(false); + expect(env.addWindowListener).not.toHaveBeenCalled(); + expect(callback).toHaveBeenLastCalledWith({ x: 0, y: 0, z: 0 }); + + env.mql.matches = false; + env.dispatchReducedMotionChange(); + + expect(motion.getPermissionState()).toBe('granted'); + expect(motion.isActive()).toBe(true); + expect(env.addWindowListener).toHaveBeenCalledWith('deviceorientation', expect.any(Function), { + passive: true, + }); + }); + + it('calibrates against caller-requested samples', async () => { + const env = createMotionEnvironment(); + const callback = vi.fn(); + const motion = new DeviceMotion(callback, { + baselineAlpha: 0, + deadZone: 0, + warmupMs: 0, + }); + + await motion.initialize(); + motion.calibrate(1); + + now = 10; + env.dispatchOrientation(10, 20); + now = 20; + env.dispatchOrientation(20, 30); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith({ + x: (30 - 20) / 45, + y: (20 - 10) / 45, + z: 0, + }); + }); + + it('does not re-arm warmup after calibration completes', async () => { + const env = createMotionEnvironment(); + const callback = vi.fn(); + const motion = new DeviceMotion(callback, { + baselineAlpha: 0, + deadZone: 0, + warmupMs: 250, + }); + + await motion.initialize(); + now = 300; + motion.calibrate(1); + + env.dispatchOrientation(10, 20); + now = 301; + env.dispatchOrientation(20, 30); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith({ + x: (30 - 20) / 45, + y: (20 - 10) / 45, + z: 0, + }); + }); + + it('emits neutral motion when sensor events go idle', async () => { + vi.useFakeTimers(); + const env = createMotionEnvironment(); + const callback = vi.fn(); + const motion = new DeviceMotion(callback, { + baselineAlpha: 0, + deadZone: 0, + idleResetMs: 100, + warmupMs: 0, + }); + + await motion.initialize(); + now = 10; + env.dispatchOrientation(45, 0); + vi.advanceTimersByTime(99); + + expect(callback).toHaveBeenCalledOnce(); + + vi.advanceTimersByTime(1); + + expect(callback).toHaveBeenLastCalledWith({ x: 0, y: 0, z: 0 }); + motion.cleanup(); + }); + + it('neutralizes motion when the document is hidden', async () => { + const env = createMotionEnvironment(); + const callback = vi.fn(); + const motion = new DeviceMotion(callback, { + baselineAlpha: 0, + deadZone: 0, + warmupMs: 0, + }); + + await motion.initialize(); + now = 10; + env.dispatchOrientation(45, 0); + env.motionDocument.hidden = true; + env.dispatchDocument('visibilitychange'); + + expect(callback).toHaveBeenLastCalledWith({ x: 0, y: 0, z: 0 }); + }); + + it('honors reduced motion as a hard disable', async () => { + const env = createMotionEnvironment({ reducedMotion: true }); + const motion = new DeviceMotion(vi.fn()); + + await expect(motion.initialize()).resolves.toBe(false); + await expect(motion.requestPermission()).resolves.toBe(false); + + expect(motion.getPermissionState()).toBe('denied'); + expect(env.addWindowListener).not.toHaveBeenCalled(); + }); + + it('neutralizes active motion when reduced motion is enabled', async () => { + const env = createMotionEnvironment(); + const callback = vi.fn(); + const motion = new DeviceMotion(callback, { + baselineAlpha: 0, + deadZone: 0, + warmupMs: 0, + }); + + await expect(motion.initialize()).resolves.toBe(true); + now = 10; + env.dispatchOrientation(45, 0); + env.mql.matches = true; + env.dispatchReducedMotionChange(); + + expect(motion.isActive()).toBe(false); + expect(callback).toHaveBeenLastCalledWith({ x: 0, y: 0, z: 0 }); + }); + + it('supports legacy reduced-motion media query listeners', async () => { + const env = createMotionEnvironment({ legacyReducedMotionListener: true }); + const callback = vi.fn(); + const motion = new DeviceMotion(callback, { + baselineAlpha: 0, + deadZone: 0, + warmupMs: 0, + }); + + await expect(motion.initialize()).resolves.toBe(true); + expect(env.mql.addListener).toHaveBeenCalledWith(expect.any(Function)); + + now = 10; + env.dispatchOrientation(45, 0); + env.mql.matches = true; + env.dispatchReducedMotionChange(); + + expect(motion.isActive()).toBe(false); + expect(callback).toHaveBeenLastCalledWith({ x: 0, y: 0, z: 0 }); + + motion.cleanup(); + expect(env.mql.removeListener).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('restarts after reduced motion is disabled when no permission prompt is needed', async () => { + const env = createMotionEnvironment({ reducedMotion: true }); + const motion = new DeviceMotion(vi.fn()); + + await expect(motion.initialize()).resolves.toBe(false); + env.mql.matches = false; + env.dispatchReducedMotionChange(); + + expect(motion.getPermissionState()).toBe('granted'); + expect(motion.isActive()).toBe(true); + expect(env.addWindowListener).toHaveBeenCalledWith('deviceorientation', expect.any(Function), { + passive: true, + }); + }); + + it('returns to prompt after reduced motion is disabled when permission is gated', async () => { + const requestPermission = vi.fn().mockResolvedValue('granted' as const); + const env = createMotionEnvironment({ permission: requestPermission, reducedMotion: true }); + const motion = new DeviceMotion(vi.fn()); + + await expect(motion.initialize()).resolves.toBe(false); + env.mql.matches = false; + env.dispatchReducedMotionChange(); + + expect(motion.getPermissionState()).toBe('prompt'); + expect(motion.isActive()).toBe(false); + expect(env.addWindowListener).not.toHaveBeenCalled(); + expect(requestPermission).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/devicemotion.test.ts b/tests/unit/devicemotion.test.ts new file mode 100644 index 0000000..6ddb700 --- /dev/null +++ b/tests/unit/devicemotion.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { remapToScreen } from '../../src/motion/DeviceMotion.js'; + +describe('remapToScreen', () => { + it('portrait (angle=0): gamma → screen-X, beta → screen-Y', () => { + const [sx, sy] = remapToScreen(20, 30, 0); + expect(sx).toBe(30); // gamma + expect(sy).toBe(20); // beta + }); + + it('landscape-left (angle=90): beta → screen-X, -gamma → screen-Y', () => { + const [sx, sy] = remapToScreen(20, 30, 90); + expect(sx).toBe(20); // beta + expect(sy).toBe(-30); // -gamma + }); + + it('upside-down portrait (angle=180): -gamma → screen-X, -beta → screen-Y', () => { + const [sx, sy] = remapToScreen(20, 30, 180); + expect(sx).toBe(-30); + expect(sy).toBe(-20); + }); + + it('landscape-right (angle=270): -beta → screen-X, gamma → screen-Y', () => { + const [sx, sy] = remapToScreen(20, 30, 270); + expect(sx).toBe(-20); + expect(sy).toBe(30); + }); + + it('unknown angle falls back to portrait mapping', () => { + const [sx, sy] = remapToScreen(20, 30, 45); + expect(sx).toBe(30); + expect(sy).toBe(20); + }); + + it('preserves zero on flat-on-table input', () => { + // Negation can produce -0 in JS; numerically equivalent to 0. + for (const angle of [0, 90, 180, 270]) { + const [sx, sy] = remapToScreen(0, 0, angle); + expect(Math.abs(sx)).toBe(0); + expect(Math.abs(sy)).toBe(0); + } + }); +}); diff --git a/tests/unit/getblobs-stable.test.ts b/tests/unit/getblobs-stable.test.ts new file mode 100644 index 0000000..fa8b1b5 --- /dev/null +++ b/tests/unit/getblobs-stable.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { BlobPhysics } from '../../src/core/BlobPhysics.js'; + +describe('BlobPhysics.getBlobs reference stability', () => { + it('returns a fresh array reference each call (so Svelte signals fire)', async () => { + const physics = new BlobPhysics(5, {}); + await physics.init(); + const colors = ['red', 'green', 'blue', 'yellow', 'purple']; + const a = physics.getBlobs(colors); + const b = physics.getBlobs(colors); + expect(a).not.toBe(b); + expect(a).toEqual(b); + }); + + it('returns the same blob object references across calls (no per-frame spread)', async () => { + const physics = new BlobPhysics(3, {}); + await physics.init(); + const colors = ['red', 'green', 'blue']; + const a = physics.getBlobs(colors); + const b = physics.getBlobs(colors); + for (let i = 0; i < a.length; i++) { + expect(a[i]).toBe(b[i]); + } + }); + + it('applies themeColors via in-place mutation, cycling when shorter than blob count', async () => { + const physics = new BlobPhysics(5, {}); + await physics.init(); + const colors = ['red', 'green']; + const blobs = physics.getBlobs(colors); + expect(blobs[0].color).toBe('red'); + expect(blobs[1].color).toBe('green'); + expect(blobs[2].color).toBe('red'); + expect(blobs[3].color).toBe('green'); + expect(blobs[4].color).toBe('red'); + }); + + it('without themeColors still returns a fresh array of the same blobs', async () => { + const physics = new BlobPhysics(2, {}); + await physics.init(); + const blobs1 = physics.getBlobs(['#aaa', '#bbb']); + expect(blobs1[0].color).toBe('#aaa'); + // Calling without colors does not reset; previous theming remains. + const blobs2 = physics.getBlobs(); + expect(blobs2).not.toBe(blobs1); + expect(blobs2[0]).toBe(blobs1[0]); + expect(blobs2[0].color).toBe('#aaa'); + }); +}); diff --git a/tests/unit/interaction-field.test.ts b/tests/unit/interaction-field.test.ts new file mode 100644 index 0000000..d17cb04 --- /dev/null +++ b/tests/unit/interaction-field.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; + +import { + clampFieldVector, + combineFieldVectors, + directionalBiasField, + pointAttractorField, + smoothDistanceFalloff, +} from '../../src/core/InteractionField.js'; + +const vectorMagnitude = ({ x, y }: { x: number; y: number }) => + Math.sqrt(x * x + y * y); + +describe('InteractionField', () => { + it('clamps vectors without changing direction', () => { + const vector = clampFieldVector({ x: 3, y: 4 }, 2); + + expect(vectorMagnitude(vector)).toBeCloseTo(2); + expect(vector.x / vector.y).toBeCloseTo(3 / 4); + }); + + it('turns zero-sized clamp bounds into a zero vector', () => { + expect(clampFieldVector({ x: 1, y: 1 }, 0)).toEqual({ x: 0, y: 0 }); + expect(clampFieldVector({ x: 1, y: 1 }, -1)).toEqual({ x: 0, y: 0 }); + }); + + it('combines fields under a maximum magnitude', () => { + const vector = combineFieldVectors( + [ + { x: 0.8, y: 0 }, + { x: 0.8, y: 0 }, + ], + 1, + ); + + expect(vector).toEqual({ x: 1, y: 0 }); + }); + + it('INVARIANT: combines empty and opposing fields to neutral', () => { + expect(combineFieldVectors([])).toEqual({ x: 0, y: 0 }); + expect( + combineFieldVectors([ + { x: 0.35, y: -0.2 }, + { x: -0.35, y: 0.2 }, + ]), + ).toEqual({ x: 0, y: 0 }); + }); + + it('converts gravity-like input into a bounded directional bias', () => { + const vector = directionalBiasField({ x: 0.25, y: 1 }, 0.8, 0.5); + + expect(vector.y).toBeGreaterThan(0); + expect(vectorMagnitude(vector)).toBeLessThanOrEqual(0.5); + }); + + it('INVARIANT: directional bias preserves direction when clamped', () => { + const vector = directionalBiasField({ x: 3, y: 4 }, 1, 1); + + expect(vectorMagnitude(vector)).toBeCloseTo(1); + expect(vector.x / vector.y).toBeCloseTo(3 / 4); + }); + + it('uses smooth local falloff for point fields', () => { + const atCenter = smoothDistanceFalloff(-5, 50); + const near = smoothDistanceFalloff(10, 50); + const far = smoothDistanceFalloff(40, 50); + const outside = smoothDistanceFalloff(60, 50); + + expect(atCenter).toBe(1); + expect(near).toBeGreaterThan(far); + expect(far).toBeGreaterThan(0); + expect(outside).toBe(0); + }); + + it('samples a soft pointer-style attraction toward the target', () => { + const near = pointAttractorField({ + origin: { x: 40, y: 50 }, + target: { x: 50, y: 50 }, + radius: 30, + strength: 0.2, + }); + const far = pointAttractorField({ + origin: { x: 20, y: 50 }, + target: { x: 50, y: 50 }, + radius: 30, + strength: 0.2, + }); + + expect(near.x).toBeGreaterThan(0); + expect(Math.abs(near.y)).toBe(0); + expect(vectorMagnitude(near)).toBeGreaterThan(vectorMagnitude(far)); + expect(far).toEqual({ x: 0, y: 0 }); + }); + + it('INVARIANT: point fields stay bounded by strength and fall off with distance', () => { + const target = { x: 50, y: 50 }; + const strength = 0.4; + const close = pointAttractorField({ + origin: { x: 45, y: 50 }, + target, + radius: 40, + strength, + }); + const farther = pointAttractorField({ + origin: { x: 30, y: 50 }, + target, + radius: 40, + strength, + }); + + expect(vectorMagnitude(close)).toBeLessThanOrEqual(strength); + expect(vectorMagnitude(farther)).toBeLessThanOrEqual(strength); + expect(vectorMagnitude(close)).toBeGreaterThan(vectorMagnitude(farther)); + }); +}); diff --git a/tests/unit/oneeuro.test.ts b/tests/unit/oneeuro.test.ts new file mode 100644 index 0000000..2e52e57 --- /dev/null +++ b/tests/unit/oneeuro.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { OneEuro } from '../../src/motion/OneEuro.js'; + +describe('OneEuro', () => { + it('passes through the first sample', () => { + const f = new OneEuro({ minCutoff: 1, beta: 0, dCutoff: 1 }); + expect(f.filter(42, 0)).toBe(42); + }); + + it('with beta=0, output matches exact EMA formula at α(minCutoff, dt)', () => { + const minCutoff = 1; + const dtSec = 0.016; + const tau = 1 / (2 * Math.PI * minCutoff); + const expectedAlpha = 1 / (1 + tau / dtSec); + + const f = new OneEuro({ minCutoff, beta: 0, dCutoff: 1 }); + f.filter(0, 0); + const out1 = f.filter(1.0, 16); + expect(out1).toBeCloseTo(expectedAlpha, 5); + const out2 = f.filter(1.0, 32); + expect(out2).toBeCloseTo(expectedAlpha + (1 - expectedAlpha) * expectedAlpha, 5); + }); + + it('higher β responds faster to step inputs (adaptive cutoff)', () => { + const slow = new OneEuro({ minCutoff: 0.5, beta: 0.001, dCutoff: 1 }); + const fast = new OneEuro({ minCutoff: 0.5, beta: 0.5, dCutoff: 1 }); + slow.filter(0, 0); + fast.filter(0, 0); + let lastSlow = 0; + let lastFast = 0; + for (let t = 16; t <= 200; t += 16) { + lastSlow = slow.filter(100, t); + lastFast = fast.filter(100, t); + } + expect(lastFast).toBeGreaterThan(lastSlow); + }); + + it('reset() restores first-sample-passthrough state', () => { + const f = new OneEuro({ minCutoff: 1, beta: 0.1, dCutoff: 1 }); + f.filter(0, 0); + f.filter(10, 16); + f.filter(10, 32); + f.reset(); + expect(f.filter(99, 100)).toBe(99); + }); + + it('clamps tiny dt so identical timestamps do not blow up', () => { + const f = new OneEuro({ minCutoff: 1, beta: 0.01, dCutoff: 1 }); + f.filter(0, 100); + expect(() => f.filter(1, 100)).not.toThrow(); + expect(Number.isFinite(f.filter(2, 100))).toBe(true); + }); + + it('rejects non-positive cutoff values', () => { + expect(() => new OneEuro({ minCutoff: 0, beta: 0, dCutoff: 1 })).toThrow(RangeError); + expect(() => new OneEuro({ minCutoff: 1, beta: 0, dCutoff: -1 })).toThrow(RangeError); + }); +}); diff --git a/tests/unit/pointer-mapper.test.ts b/tests/unit/pointer-mapper.test.ts new file mode 100644 index 0000000..283a1a1 --- /dev/null +++ b/tests/unit/pointer-mapper.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { mapClientPointToPhysics } from '../../src/motion/PointerMapper.js'; + +describe('mapClientPointToPhysics', () => { + const bounds = { + left: 10, + top: 20, + width: 200, + height: 100, + }; + + it('maps the center of a bounds rectangle into the center of physics space', () => { + expect(mapClientPointToPhysics(110, 70, bounds)).toEqual({ x: 50, y: 50 }); + }); + + it('maps rectangle corners into the default 0..100 physics range', () => { + expect(mapClientPointToPhysics(10, 20, bounds)).toEqual({ x: 0, y: 0 }); + expect(mapClientPointToPhysics(210, 120, bounds)).toEqual({ x: 100, y: 100 }); + }); + + it('clamps points outside the rectangle', () => { + expect(mapClientPointToPhysics(-100, 200, bounds)).toEqual({ x: 0, y: 100 }); + }); + + it('supports custom physics ranges', () => { + expect(mapClientPointToPhysics(110, 70, bounds, { min: -40, max: 140 })).toEqual({ + x: 50, + y: 50, + }); + expect(mapClientPointToPhysics(210, 120, bounds, { min: -40, max: 140 })).toEqual({ + x: 140, + y: 140, + }); + }); + + it('falls back to the center when bounds have no area', () => { + expect(mapClientPointToPhysics(100, 100, { ...bounds, width: 0 })).toEqual({ + x: 50, + y: 50, + }); + expect( + mapClientPointToPhysics(100, 100, { ...bounds, height: 0 }, { min: -40, max: 140 }), + ).toEqual({ x: 50, y: 50 }); + }); +}); diff --git a/tests/unit/pointer-physics-controller.test.ts b/tests/unit/pointer-physics-controller.test.ts new file mode 100644 index 0000000..100feb7 --- /dev/null +++ b/tests/unit/pointer-physics-controller.test.ts @@ -0,0 +1,322 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createPointerPhysicsController, + detectPointerPhysicsCapability, + getLatestPointerEvent, + type PointerLifecycleEventName, + type PointerLikeEvent, +} from '../../src/motion/PointerPhysicsController.js'; + +type PointerTestEvent = Partial & { relatedTarget?: EventTarget | null }; + +function createTarget() { + const listeners = new Map(); + const addEventListener = vi.fn( + ( + type: PointerLifecycleEventName, + listener: EventListener, + _options?: AddEventListenerOptions, + ) => { + listeners.set(type, listener); + }, + ); + const removeEventListener = vi.fn((type: PointerLifecycleEventName, listener: EventListener) => { + if (listeners.get(type) === listener) { + listeners.delete(type); + } + }); + + return { + addEventListener, + dispatch(type: PointerLifecycleEventName, event: PointerTestEvent = {}) { + listeners.get(type)?.(event as unknown as Event); + }, + listeners, + removeEventListener, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('getLatestPointerEvent', () => { + it('uses the newest coalesced event when present', () => { + const latest = { clientX: 30, clientY: 40 }; + const event = { + clientX: 10, + clientY: 20, + getCoalescedEvents: () => [{ clientX: 20, clientY: 30 }, latest], + }; + + expect(getLatestPointerEvent(event)).toBe(latest); + }); + + it('falls back to the parent event without coalesced samples', () => { + const event = { clientX: 10, clientY: 20, getCoalescedEvents: () => [] }; + + expect(getLatestPointerEvent(event)).toBe(event); + }); +}); + +describe('detectPointerPhysicsCapability', () => { + it('accepts pointer events as direct pointer IO support', () => { + expect(detectPointerPhysicsCapability({ PointerEvent: function PointerEvent() {} })).toBe( + true, + ); + }); + + it('accepts touch points and pointer media queries', () => { + expect(detectPointerPhysicsCapability({ navigator: { maxTouchPoints: 1 } })).toBe(true); + expect( + detectPointerPhysicsCapability({ + matchMedia: (query) => ({ matches: query.includes('pointer') }), + }), + ).toBe(true); + }); + + it('falls back to mouse IO and rejects environments without pointer input', () => { + expect(detectPointerPhysicsCapability({ MouseEvent: function MouseEvent() {} })).toBe( + true, + ); + expect(detectPointerPhysicsCapability({})).toBe(false); + }); +}); + +describe('createPointerPhysicsController', () => { + const bounds = { + left: 10, + top: 20, + width: 200, + height: 100, + }; + + it('registers pointermove when pointer events are supported', () => { + const target = createTarget(); + const controller = createPointerPhysicsController({ + target, + getBounds: () => bounds, + supportsPointerEvents: true, + requestFrame: vi.fn(), + cancelFrame: vi.fn(), + updatePosition: vi.fn(), + }); + + expect(controller.eventName).toBe('pointermove'); + expect(controller.exitEventName).toBe('pointerout'); + expect(controller.cancelEventName).toBe('pointercancel'); + expect(target.addEventListener).toHaveBeenCalledWith( + 'pointermove', + expect.any(Function), + { passive: true }, + ); + expect(target.addEventListener).toHaveBeenCalledWith( + 'pointerout', + expect.any(Function), + { passive: true }, + ); + expect(target.addEventListener).toHaveBeenCalledWith('pointercancel', expect.any(Function)); + expect(target.addEventListener).toHaveBeenCalledWith('blur', expect.any(Function)); + + controller.dispose(); + }); + + it('falls back to mousemove when pointer events are unavailable', () => { + const target = createTarget(); + const controller = createPointerPhysicsController({ + target, + getBounds: () => bounds, + supportsPointerEvents: false, + requestFrame: vi.fn(), + cancelFrame: vi.fn(), + updatePosition: vi.fn(), + }); + + expect(controller.eventName).toBe('mousemove'); + expect(controller.exitEventName).toBe('mouseout'); + expect(controller.cancelEventName).toBeNull(); + expect(target.addEventListener).toHaveBeenCalledWith( + 'mousemove', + expect.any(Function), + { passive: true }, + ); + expect(target.addEventListener).toHaveBeenCalledWith( + 'mouseout', + expect.any(Function), + { passive: true }, + ); + + controller.dispose(); + }); + + it('rAF-throttles pointer updates and maps the latest position', () => { + const target = createTarget(); + const updatePosition = vi.fn(); + const frameCallbacks: FrameRequestCallback[] = []; + const requestFrame = vi.fn((callback: FrameRequestCallback) => { + frameCallbacks.push(callback); + return frameCallbacks.length; + }); + + createPointerPhysicsController({ + target, + getBounds: () => bounds, + supportsPointerEvents: true, + requestFrame, + cancelFrame: vi.fn(), + updatePosition, + }); + + target.dispatch('pointermove', { clientX: 60, clientY: 45 }); + target.dispatch('pointermove', { clientX: 110, clientY: 70 }); + + expect(requestFrame).toHaveBeenCalledOnce(); + expect(updatePosition).not.toHaveBeenCalled(); + + frameCallbacks[0](16); + + expect(updatePosition).toHaveBeenCalledOnce(); + expect(updatePosition).toHaveBeenCalledWith({ x: 50, y: 50 }); + }); + + it('uses the latest coalesced pointer sample before flushing', () => { + const target = createTarget(); + const updatePosition = vi.fn(); + let frameCallback: FrameRequestCallback | undefined; + + createPointerPhysicsController({ + target, + getBounds: () => bounds, + supportsPointerEvents: true, + requestFrame(callback) { + frameCallback = callback; + return 1; + }, + cancelFrame: vi.fn(), + updatePosition, + }); + + target.dispatch('pointermove', { + clientX: 0, + clientY: 0, + getCoalescedEvents: () => [ + { clientX: 60, clientY: 45 }, + { clientX: 210, clientY: 120 }, + ], + }); + frameCallback?.(16); + + expect(updatePosition).toHaveBeenCalledWith({ x: 100, y: 100 }); + }); + + it('resets stale pointer position when pointer IO leaves the viewport', () => { + const target = createTarget(); + const cancelFrame = vi.fn(); + const updatePosition = vi.fn(); + const controller = createPointerPhysicsController({ + target, + getBounds: () => bounds, + range: { min: -1, max: 1 }, + supportsPointerEvents: true, + requestFrame: vi.fn(() => 42), + cancelFrame, + updatePosition, + }); + + target.dispatch('pointermove', { clientX: 110, clientY: 70 }); + target.dispatch('pointerout', { + relatedTarget: null, + }); + + expect(cancelFrame).toHaveBeenCalledWith(42); + expect(updatePosition).toHaveBeenCalledWith({ x: 0, y: 0 }); + expect(controller.exitEventName).toBe('pointerout'); + }); + + it('resets stale pointer position when browser pointer IO is canceled', () => { + const target = createTarget(); + const cancelFrame = vi.fn(); + const updatePosition = vi.fn(); + createPointerPhysicsController({ + target, + getBounds: () => bounds, + range: { min: -1, max: 1 }, + supportsPointerEvents: true, + requestFrame: vi.fn(() => 42), + cancelFrame, + updatePosition, + }); + + target.dispatch('pointermove', { clientX: 110, clientY: 70 }); + target.dispatch('pointercancel'); + + expect(cancelFrame).toHaveBeenCalledWith(42); + expect(updatePosition).toHaveBeenCalledWith({ x: 0, y: 0 }); + }); + + it('ignores pointerout transitions that stay inside the document', () => { + const target = createTarget(); + const updatePosition = vi.fn(); + createPointerPhysicsController({ + target, + getBounds: () => bounds, + supportsPointerEvents: true, + requestFrame: vi.fn(), + cancelFrame: vi.fn(), + updatePosition, + }); + + target.dispatch('pointerout', { + relatedTarget: {} as EventTarget, + }); + + expect(updatePosition).not.toHaveBeenCalled(); + }); + + it('resets stale pointer position on window blur', () => { + const target = createTarget(); + const updatePosition = vi.fn(); + createPointerPhysicsController({ + target, + getBounds: () => bounds, + supportsPointerEvents: false, + requestFrame: vi.fn(), + cancelFrame: vi.fn(), + updatePosition, + }); + + target.dispatch('blur'); + + expect(updatePosition).toHaveBeenCalledWith({ x: 50, y: 50 }); + }); + + it('removes listeners and cancels pending work during cleanup', () => { + const target = createTarget(); + const cancelFrame = vi.fn(); + const updatePosition = vi.fn(); + let frameCallback: FrameRequestCallback | undefined; + const controller = createPointerPhysicsController({ + target, + getBounds: () => bounds, + supportsPointerEvents: true, + requestFrame(callback) { + frameCallback = callback; + return 42; + }, + cancelFrame, + updatePosition, + }); + + target.dispatch('pointermove', { clientX: 110, clientY: 70 }); + controller.dispose(); + frameCallback?.(16); + + expect(cancelFrame).toHaveBeenCalledWith(42); + expect(target.removeEventListener).toHaveBeenCalledWith('pointermove', expect.any(Function)); + expect(target.removeEventListener).toHaveBeenCalledWith('pointerout', expect.any(Function)); + expect(target.removeEventListener).toHaveBeenCalledWith('pointercancel', expect.any(Function)); + expect(target.removeEventListener).toHaveBeenCalledWith('blur', expect.any(Function)); + expect(updatePosition).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/scroll-handler.test.ts b/tests/unit/scroll-handler.test.ts new file mode 100644 index 0000000..cb96910 --- /dev/null +++ b/tests/unit/scroll-handler.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ScrollHandler } from '../../src/motion/ScrollHandler.js'; + +describe('ScrollHandler', () => { + const frameCallbacks: FrameRequestCallback[] = []; + + beforeEach(() => { + frameCallbacks.length = 0; + vi.useFakeTimers(); + vi.setSystemTime(1_000); + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + frameCallbacks.push(callback); + return frameCallbacks.length; + }), + ); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('converts wheel movement into stickiness and pull forces', () => { + const handler = new ScrollHandler(); + + handler.handleScroll({ deltaY: 240 } as WheelEvent); + + expect(handler.isActivelyScrolling()).toBe(true); + expect(handler.getScrollDirection()).toBe(1); + expect(handler.getTotalScrollDistance()).toBe(240); + expect(handler.getStickiness()).toBeGreaterThan(0); + expect(handler.getPullForces().length).toBeGreaterThan(0); + }); + + it('resets active scroll state after the quiet window', () => { + const handler = new ScrollHandler(); + + handler.handleScroll({ deltaY: 120 } as WheelEvent); + vi.setSystemTime(1_201); + vi.advanceTimersByTime(200); + + expect(handler.isActivelyScrolling()).toBe(false); + expect(handler.getTotalScrollDistance()).toBe(0); + expect(handler.getPeakVelocity()).toBe(0); + }); + + it('keeps a single decay loop active across repeated scroll events', () => { + const handler = new ScrollHandler(); + + handler.handleScroll({ deltaY: 120 } as WheelEvent); + handler.handleScroll({ deltaY: 140 } as WheelEvent); + + expect(requestAnimationFrame).toHaveBeenCalledOnce(); + + frameCallbacks[0](16); + + expect(requestAnimationFrame).toHaveBeenCalledTimes(2); + }); + + it('honors caller-configured pull-force caps', () => { + const handler = new ScrollHandler({ maxForces: 2 }); + + for (let i = 0; i < 5; i++) { + vi.setSystemTime(1_000 + i * 16); + handler.handleScroll({ deltaY: 240 } as WheelEvent); + } + + expect(handler.getPullForces()).toHaveLength(2); + }); + + it('allows callers to disable retained pull forces', () => { + const handler = new ScrollHandler({ maxForces: 0 }); + + handler.handleScroll({ deltaY: 240 } as WheelEvent); + + expect(handler.getStickiness()).toBeGreaterThan(0); + expect(handler.getPullForces()).toEqual([]); + }); + + it('cleans up scheduled decay and scroll-end work', () => { + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); + const handler = new ScrollHandler(); + + handler.handleScroll({ deltaY: 120 } as WheelEvent); + handler.dispose(); + frameCallbacks[0](16); + + expect(cancelAnimationFrame).toHaveBeenCalledWith(1); + expect(clearTimeoutSpy).toHaveBeenCalled(); + expect(handler.getStickiness()).toBe(0); + expect(handler.isActivelyScrolling()).toBe(false); + expect(handler.getPullForces()).toEqual([]); + }); + + it('ignores scroll events after disposal', () => { + const handler = new ScrollHandler(); + + handler.dispose(); + handler.handleScroll({ deltaY: 120 } as WheelEvent); + + expect(handler.getStickiness()).toBe(0); + expect(requestAnimationFrame).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/theme-presets.test.ts b/tests/unit/theme-presets.test.ts new file mode 100644 index 0000000..63b21cd --- /dev/null +++ b/tests/unit/theme-presets.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { + THEME_PRESETS, + type ThemePresetName, +} from '../../src/core/theme-presets.js'; +import { THEME_PRESET_COLORS } from '../../src/core/theme-colors.js'; +import { + THEME_PRESET_COLORS as SCHEMA_THEME_PRESET_COLORS, + THEME_PRESETS as SCHEMA_THEME_PRESETS, +} from '../../src/core/schema.js'; + +describe('theme presets', () => { + it('keeps lightweight color presets aligned with full theme presets', () => { + const names = Object.keys(THEME_PRESETS) as ThemePresetName[]; + + expect(Object.keys(THEME_PRESET_COLORS).sort()).toEqual([...names].sort()); + + for (const name of names) { + expect(THEME_PRESET_COLORS[name]).toEqual( + THEME_PRESETS[name].colors.map((color) => compactRgba(color.color)), + ); + } + }); + + it('preserves schema re-exports for the existing public surface', () => { + expect(SCHEMA_THEME_PRESETS).toBe(THEME_PRESETS); + expect(SCHEMA_THEME_PRESET_COLORS).toBe(THEME_PRESET_COLORS); + }); +}); + +function compactRgba(color: string): string { + return color.replaceAll(' ', '').replace(',0.', ',.'); +} diff --git a/vite.config.ts b/vite.config.ts index 737a69a..557e8c2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -61,10 +61,7 @@ export default defineConfig({ target: 'es2022', - // Don't minify — library consumers handle minification. - // esbuild minification uses `$` as a variable name which conflicts - // with Svelte's reserved `$` prefix in downstream builds. - minify: false, + minify: 'esbuild', }, diff --git a/vite.dev.config.ts b/vite.dev.config.ts index 6256622..514c272 100644 --- a/vite.dev.config.ts +++ b/vite.dev.config.ts @@ -2,6 +2,9 @@ import { defineConfig } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; import { resolve } from 'path'; +const devServerPort = Number(process.env.TINYVECTORS_VITE_PORT ?? 5175); +const hmrPort = Number(process.env.TINYVECTORS_VITE_HMR_PORT ?? 24679); + export default defineConfig({ root: resolve(__dirname, 'dev'), @@ -20,10 +23,10 @@ export default defineConfig({ }, server: { - port: 5175, - open: true, + port: devServerPort, + open: process.env.CI === 'true' ? false : true, hmr: { - port: 24679, + port: hmrPort, }, }, });