diff --git a/call/.gitignore b/call/.gitignore
new file mode 100644
index 00000000..f928cfcf
--- /dev/null
+++ b/call/.gitignore
@@ -0,0 +1,3 @@
+dist
+node_modules
+.pushwork
diff --git a/call/package.json b/call/package.json
new file mode 100644
index 00000000..1fd2a067
--- /dev/null
+++ b/call/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@tiny-patchwork/call",
+ "version": "0.1.0",
+ "type": "module",
+ "private": true,
+ "main": "./dist/index.js",
+ "scripts": {
+ "dev": "vite build --watch",
+ "build": "vite build",
+ "sync": "vite build && pushwork sync",
+ "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
+ "@automerge/automerge": "3.3.0-fragments.1",
+ "@automerge/automerge-repo": "^2.6.0-subduction.26",
+ "@inkandswitch/patchwork-elements": "^6.0.2",
+ "@inkandswitch/patchwork-filesystem": "^0.2.9",
+ "@inkandswitch/patchwork-plugins": "^1.2.5",
+ "@inkandswitch/patchwork-providers": "^0.5.2",
+ "solid-js": "^1.9.9"
+ },
+ "devDependencies": {
+ "@inkandswitch/patchwork-bootloader": "^0.7.2",
+ "happy-dom": "^15.11.7",
+ "vite": "^7.1.9",
+ "vite-plugin-css-injected-by-js": "^3.5.2",
+ "vitest": "^3.2.4"
+ }
+}
diff --git a/call/pnpm-lock.yaml b/call/pnpm-lock.yaml
new file mode 100644
index 00000000..6ebe19d7
--- /dev/null
+++ b/call/pnpm-lock.yaml
@@ -0,0 +1,1641 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@automerge/automerge':
+ specifier: 3.3.0-fragments.1
+ version: 3.3.0-fragments.1
+ '@automerge/automerge-repo':
+ specifier: ^2.6.0-subduction.26
+ version: 2.6.0-subduction.49
+ '@inkandswitch/patchwork-elements':
+ specifier: ^6.0.2
+ version: 6.2.1(@automerge/automerge-repo@2.6.0-subduction.49)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-plugins@1.2.7(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)))(@inkandswitch/patchwork-providers@0.5.5(@automerge/automerge-repo@2.6.0-subduction.49))(solid-js@1.9.19)
+ '@inkandswitch/patchwork-filesystem':
+ specifier: ^0.2.9
+ version: 0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)
+ '@inkandswitch/patchwork-plugins':
+ specifier: ^1.2.5
+ version: 1.2.7(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1))
+ '@inkandswitch/patchwork-providers':
+ specifier: ^0.5.2
+ version: 0.5.5(@automerge/automerge-repo@2.6.0-subduction.49)
+ solid-js:
+ specifier: ^1.9.9
+ version: 1.9.19
+ devDependencies:
+ '@inkandswitch/patchwork-bootloader':
+ specifier: ^0.7.2
+ version: 0.7.2(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)
+ happy-dom:
+ specifier: ^15.11.7
+ version: 15.11.7
+ vite:
+ specifier: ^7.1.9
+ version: 7.3.8(@types/node@20.19.43)
+ vite-plugin-css-injected-by-js:
+ specifier: ^3.5.2
+ version: 3.5.2(vite@7.3.8(@types/node@20.19.43))
+ vitest:
+ specifier: ^3.2.4
+ version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(happy-dom@15.11.7)
+
+packages:
+
+ '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.48':
+ resolution: {integrity: sha512-fyq/ZqWkrYOuNrQ917KeCaRSuY6fzqH6Q4KCb5czjwKPKO2zFIGKVfK5hZjYsHGWRKuh5BzViaONHn6bJ0Jk4w==}
+ engines: {node: '>=22.13'}
+
+ '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.48':
+ resolution: {integrity: sha512-pf+cmCi/TWQZpqrm+iinVpIM2Ecfzp8JpGEo28SPbE2Q8+eLyO2cz1Dssnuep1+KQ8k7Dfx7aUF9AD7/6jAvTQ==}
+ engines: {node: '>=22.13'}
+
+ '@automerge/automerge-repo-network-websocket@2.6.0-subduction.48':
+ resolution: {integrity: sha512-TRKAq4iTdrSpOQYxyKZMJDJ5Civa+vONrAHBZkpK+DGShzqBB6vCdTCsrBGBF2Q63H33qQM0XYBQEQKIa/zM+Q==}
+ engines: {node: '>=22.13'}
+
+ '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.48':
+ resolution: {integrity: sha512-7XIn/iGrAVS4ugva8m0bU1shhRtuy/fdCAgF6qaIEvbi7XQllLVn3miTTiusLaksNhnRtbMfDLUknazU+vVHew==}
+ engines: {node: '>=22.13'}
+
+ '@automerge/automerge-repo@2.6.0-subduction.48':
+ resolution: {integrity: sha512-HNS1YsD0XmQ0vtwIincF7NvEBrQuOMnk+mjCVluRiIpUFvcaVeNAFXCCyBVeXubmg88AHe+LkIQ96v/lyHZ7Wg==}
+ engines: {node: '>=22.13'}
+
+ '@automerge/automerge-repo@2.6.0-subduction.49':
+ resolution: {integrity: sha512-2RRd2rrgrAMIj4hXtDCvNtqqpurjkw9I8Hdv9W+CRhMIZPl8QaAVmG1Bs4+TKBELw6WLQA6ISlrpnIChGzhpDA==}
+ engines: {node: '>=22.13'}
+
+ '@automerge/automerge-subduction@0.16.1':
+ resolution: {integrity: sha512-alH7U4eYn0O7sT6hNLv7CJhNODJi+QD6qfRQTNfZCxL3Q5JZdJ8lFpdvXUkBf+I3bu0GLvVlTaYPWGrBihjpIw==}
+
+ '@automerge/automerge-subduction@0.20.1':
+ resolution: {integrity: sha512-lkRDaVr1I+C1N/IKSvn+TtT7WJnmxM0ntfUshG40Xi3INTL2//e8NcMJ8tEXh4GZYdCof37aG/diNuMLndHApQ==}
+
+ '@automerge/automerge@3.3.0-fragments.1':
+ resolution: {integrity: sha512-Im2n3qcYSuIsLDSjofTRo7CZyyvFn5pKLSCCO5sjWzESw+9q9G25ZIrL2mczDX43cXtyzi+w/4b9VUaiNdY56Q==}
+
+ '@automerge/automerge@3.3.2':
+ resolution: {integrity: sha512-9vCdCL7pdQwUra66SBxPVHr+/t9epXKni9KDeak2rNBFMzABVh2u6gSpcwxi3jkR5qr047jVqZv9DlrOfVxLFw==}
+
+ '@cbor-extract/cbor-extract-darwin-arm64@2.2.3':
+ resolution: {integrity: sha512-qpUy9Yoisi6a7QHOVa+AlnqfN7eVstSRhpLwiLnwi6i3SNLkM5D2Z7MXl4LC5nUGA77mZEt479cPH3u8GD163Q==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@cbor-extract/cbor-extract-darwin-x64@2.2.3':
+ resolution: {integrity: sha512-6bZbK7dyKn8BUNmG3UfzSnM4L/OzJ8xGAtBHYycprl+bgvtZmZoQC7CucWXuF/oHJP5skzqkqFr6BLSSGIkoWw==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@cbor-extract/cbor-extract-linux-arm64@2.2.3':
+ resolution: {integrity: sha512-QAuFuQkOw0cm4MNc+QwxH9aiMxmrczESnVEu33B8m2Du9fBuH/1AvJfmO8YajpeWLoHqt2iUG3d1/gIWAKY8IQ==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@cbor-extract/cbor-extract-linux-arm@2.2.3':
+ resolution: {integrity: sha512-joCovTc7uEYCL9XxdAJJpGIH7Vvc31Fi7vh3HnlY5zgkLSU/64WxYl5iUi/puE21qT3bmxMfOZEZUbnqVKeJTQ==}
+ cpu: [arm]
+ os: [linux]
+
+ '@cbor-extract/cbor-extract-linux-x64@2.2.3':
+ resolution: {integrity: sha512-IQMPVLulOvFv9Had7J1idJyfQzsFamDVdjH37qW7suHI7mgO4w1Oo3MisL7AjnWARxo0/ZmEEQ3i81WA7y34Ug==}
+ cpu: [x64]
+ os: [linux]
+
+ '@cbor-extract/cbor-extract-win32-x64@2.2.3':
+ resolution: {integrity: sha512-fGFfq6F3l0GCoKOZeyjjm3q+NHO7jo8ILtQ93U3rYJOgG2BGLS/QmBdoFLNEsoWPjyxAS8svWhTyo73MTDawog==}
+ cpu: [x64]
+ os: [win32]
+
+ '@codemirror/commands@6.11.1':
+ resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==}
+
+ '@codemirror/language@6.13.1':
+ resolution: {integrity: sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==}
+
+ '@codemirror/state@6.7.6':
+ resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==}
+
+ '@codemirror/streamparser@6.0.0':
+ resolution: {integrity: sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==}
+
+ '@codemirror/view@6.43.15':
+ resolution: {integrity: sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==}
+
+ '@esbuild/aix-ppc64@0.28.2':
+ resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.28.2':
+ resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.28.2':
+ resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.28.2':
+ resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.28.2':
+ resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.28.2':
+ resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.28.2':
+ resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.28.2':
+ resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.28.2':
+ resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.28.2':
+ resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.28.2':
+ resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.28.2':
+ resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.28.2':
+ resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.28.2':
+ resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.28.2':
+ resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.28.2':
+ resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.28.2':
+ resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.28.2':
+ resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.28.2':
+ resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.28.2':
+ resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.28.2':
+ resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.28.2':
+ resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.28.2':
+ resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.28.2':
+ resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.28.2':
+ resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.28.2':
+ resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@inkandswitch/patchwork-bootloader@0.7.2':
+ resolution: {integrity: sha512-b5ki9fodXB7uEzvBvpZ0uAkaM7CwwtAdD596XKZJY+TjaSnYsWnx0IP0ewz9rL6ASU282ItyCB8917PKxr8ckA==}
+ peerDependencies:
+ '@automerge/automerge': 3.5.0
+ '@automerge/automerge-repo': 2.6.0-subduction.48
+ '@automerge/automerge-repo-keyhive': 0.5.0-alpha.7
+ '@automerge/vanillajs': 2.6.0-subduction.48
+ peerDependenciesMeta:
+ '@automerge/automerge':
+ optional: true
+ '@automerge/automerge-repo':
+ optional: true
+ '@automerge/automerge-repo-keyhive':
+ optional: true
+ '@automerge/vanillajs':
+ optional: true
+
+ '@inkandswitch/patchwork-elements@6.2.1':
+ resolution: {integrity: sha512-10zdSD9A/mjT6fLqlHzomxxGhL62w5EC6pxClrOFTLxaci10G+uRNVU5vOuQRRg3A84kYSxLXXTTjKyo7VAvlA==}
+ peerDependencies:
+ '@automerge/automerge-repo': '*'
+ '@automerge/automerge-repo-keyhive': '*'
+ '@inkandswitch/patchwork-filesystem': ^0.2.19
+ '@inkandswitch/patchwork-plugins': ^1.2.7
+ '@inkandswitch/patchwork-providers': ^0.5.5
+ '@types/react': '*'
+ react: '*'
+ solid-js: '*'
+ peerDependenciesMeta:
+ '@automerge/automerge-repo-keyhive':
+ optional: true
+ '@types/react':
+ optional: true
+ react:
+ optional: true
+ solid-js:
+ optional: true
+
+ '@inkandswitch/patchwork-filesystem@0.2.19':
+ resolution: {integrity: sha512-2+ImuE8G0kp3ILQxyDXjoibidMsXxPSHuOfZjgNB4FoohKXIr377ooUaJt4LEXsNdsyF5e5ccr7wGPBIBnHlgA==}
+ peerDependencies:
+ '@automerge/automerge': '*'
+ '@automerge/automerge-repo': '*'
+
+ '@inkandswitch/patchwork-plugins@1.2.7':
+ resolution: {integrity: sha512-i5mOHa/P296lOw381aEc1lzqr/0aXfdSokH7B1UyoFvBtrJIr5txP0Ttd90/4sIFzeIcV1rABeIW7BoN4InckQ==}
+ peerDependencies:
+ '@automerge/automerge': '*'
+ '@automerge/automerge-repo': '*'
+ '@inkandswitch/patchwork-filesystem': ^0.2.19
+
+ '@inkandswitch/patchwork-providers@0.5.5':
+ resolution: {integrity: sha512-yD7vCwDeHbUKDcD2szthqx77RzUWEp3S3aMoN7IPPwYyCWJSeq+9/hlJ4WGD4d6NUP2P6mIkdjmdcVHOQVmuAA==}
+ peerDependencies:
+ '@automerge/automerge-repo': '*'
+
+ '@jridgewell/sourcemap-codec@1.6.0':
+ resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
+
+ '@keyhive/keyhive@0.1.0-alpha.8':
+ resolution: {integrity: sha512-juyDs15N3xyKl9mtGHoghdxiCGiXVReS4GufjUMATE3TsgASA/fznISCic+sKc9mMg+aAOXWFAbNELfBe3GyvA==}
+
+ '@lezer/common@1.5.5':
+ resolution: {integrity: sha512-Pw7PVCndruF1pb3ebCxpSVpkMHSDjwRCLcTRw+RizUKco5QbWylwq2g4lnG/RYN7yTTQDXE9F77MwtkAyphrjg==}
+
+ '@lezer/highlight@1.2.5':
+ resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==}
+
+ '@lezer/lr@1.4.12':
+ resolution: {integrity: sha512-On9HU4oMMiHPMkQDgoTnGahjgG5n/MkN38CgG77sMraKLL+3JBbeQNi1FcCXV9QRqwF9/BUXP3caHyJC1fBOTQ==}
+
+ '@marijn/find-cluster-break@1.0.4':
+ resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
+
+ '@noble/hashes@1.8.0':
+ resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
+ engines: {node: ^14.21.3 || >=16}
+
+ '@rollup/rollup-android-arm-eabi@4.68.2':
+ resolution: {integrity: sha512-ItKrNMkEL3hy85kLBXwB0+M6CWWVI8vr7RV+Eb7oDlQIMXLaiEhGfdaj5jAUadYn2v3nwzAhIRl/nLyjAqV9vQ==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.68.2':
+ resolution: {integrity: sha512-dCkT8BMPLo6X6GCFVtuzrnr981PpJSeK4ZO/d/mZdJ/ao61Qa1Usd/OHIQ69MQgw4qQclR/X6uRRUONiWwD8cw==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-android-x64@4.68.2':
+ resolution: {integrity: sha512-p2jkKiscqQOGmyL6qu+KvJctvBc3SQTd+Xi2lQYn5QvFLEWz+QI8Z9iI+xcIjez6vKRsSnWP56Y3Ep7g3Stiug==}
+ cpu: [x64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.68.2':
+ resolution: {integrity: sha512-aVIfOteo5zoX8aL742H/S3QsyN6aokNYjdzrLnQBKxgRVoh4cibTErpi61RzSmrv2hBzDB1i751hwwdc8QK0vQ==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.68.2':
+ resolution: {integrity: sha512-+Wuf4TFTHiE/WnjKgH1SkNf/9D5ddoed1UR0p3jxjNAGemdeeQRL+r1bJ1LlZyha0VKDBYLB2U/a9a9KlSw4Kw==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.68.2':
+ resolution: {integrity: sha512-feKK2dc13j/6D9mAp7XKhBM/VUqn0N/xRgP6rT7/mdRVVJUaeZ4lsJ9hN90aTi0k3OD+mkjlcN01vQ96Ac9VzQ==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.68.2':
+ resolution: {integrity: sha512-s1oGXPGX1PNqdi9TLWD+RwtPEYf/BOTbCcq4anJBqNbzyjucti86ldYH37eKvsyTP7Bl5/Hf8XmbIxCz/iafTA==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.68.2':
+ resolution: {integrity: sha512-ksR3ZmzHrm6HOUP8UlDBy5hNTcG9cEIa3EyGnxn5BjorLRjBh4ucxhwjwwVEm0dqcIN4Egc/XjDUiPQwN1ic9A==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.68.2':
+ resolution: {integrity: sha512-STZVVBM35wULVsQah0xKfCBQMPpHliOfjnRaUNT35jrbpfapUrD8ix6Yhl7tLHW0xQSWotXSSghjUi/DclwRSw==}
+ cpu: [arm]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-arm64-gnu@4.68.2':
+ resolution: {integrity: sha512-Fm/GPC2R0zlJYQIp9aY9L1lO2z+P+UrcvXG6fhubfo/BbMOLZlLXshnbECOTqr3pxfZFb6nNn0QgDETDtrX7TQ==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm64-musl@4.68.2':
+ resolution: {integrity: sha512-VoqlckWcwNF3WqsUkLZg91zYFgBvrC37Lc5e8AU+BKdCUFDMPoN0Rsin/LvF+rBVF527gWo5KT0oHkaAZaL1GA==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-loong64-gnu@4.68.2':
+ resolution: {integrity: sha512-Zr8X/SUf7Jjwxt99BqPDF/Wq+sz4oUBIP6HBaFQWwb8wO1nV6LBIpD6jB9cMybLho93Z+B8rrN0uXEL6oNOUJQ==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-loong64-musl@4.68.2':
+ resolution: {integrity: sha512-2EhrYEJNXIyhSZESwm/kCke1tiH7QAPOU3v3MsLM9J+Nfn2QCDe/pGROUAvYFK0BL2MEbzNgfrqrbnE5CFXkLw==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.68.2':
+ resolution: {integrity: sha512-IC0dYuEQknw0m2FYBNBKlbrgBJBH5OMRiaRfCc4CFSpYnmDnqTpNSv+TgX+loezGTdRjBNZuYZi3t+Oc5NxNYg==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-ppc64-musl@4.68.2':
+ resolution: {integrity: sha512-Zlzm6fGuDvtAqMEl071/g17vxq6u2Y2v/rON/gDY4NvzWLJ41BGAlwhF3QuAzwq05zIPo4LYzdqwhx3oisYCUw==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.68.2':
+ resolution: {integrity: sha512-KhEpYRtHZ3Kj4shavjXeH+K1bQlA6UbP6MJ45rMPF/11JlZb1SRNLeuWCdigUEoMMaFO4+LwwcS+ew1SoLJu/g==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-riscv64-musl@4.68.2':
+ resolution: {integrity: sha512-LPn9qvPsM/Ofd6Ekq3im6nB8Tk1ruJhTd3x/2bJkTR8m/XTsfxA+EU0RJ23WJqgJg123kitHAqbEK7B4cL1uPg==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-s390x-gnu@4.68.2':
+ resolution: {integrity: sha512-Q1+E+pzSP+FiXuIZtxKR+pK7l2KQ9gsM+VaFPv1LR277kp7l61crG46WGLKLLay3Js5cvyU9Keqdzxa1EcFO+g==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-gnu@4.68.2':
+ resolution: {integrity: sha512-3Btp4Lca/WzsmbaKu9Lh9d/XwyRDIegd/5khRBusHGw/Ll6SoF5Shn63OrjPdkLcpNi2PGDApXzNmxriRxGYjA==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-musl@4.68.2':
+ resolution: {integrity: sha512-5SKVaUiVs18+DGBnpg0mORGxgq/0udczngGxV/vf2nvK1djqpOKIxY/+8rBWxAbWGRE4RnnwxWabDwJN3pCwIQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-openbsd-x64@4.68.2':
+ resolution: {integrity: sha512-KBpm48/lESsHgWd+dRlcy18APBYhXU4Jb6VlJoqbpWNwfpztFXwP22qVv9aHd3k1quSrmLeJwYLc7rpQ+BFZrw==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.68.2':
+ resolution: {integrity: sha512-o0D0bfRW8QGA1Ph6OzqVHMq5LRu1xkr1iM7qQIXUSFuR60ddXeDhN9UR4bUBPLYsrT46sZwwgQa51TWtBV3r5g==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.68.2':
+ resolution: {integrity: sha512-r17kcu5kmd6WQwokYmfBnfwM/icureCWCPjpJVALcrSv5Lfy6Ggk9azxXor6hILhCHbc/4IfEiv+L5RXaOHinA==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.68.2':
+ resolution: {integrity: sha512-yUtOqq7ZrQoZ3WiHSR6QGTdOl4YgMHqXIYSBYU6siIcC0YF0pEwx7aGsPTm2mRwrmW6SMJkxzEdFm6wqXpmbNw==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.68.2':
+ resolution: {integrity: sha512-5IYoWxQGo5WECbJbImIf2w33uCHJNLv4o/FQtSyeYDed7AC6zx84NsEcGYccoI2geDEYkrxUXcsYK/6EhwVSIg==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.68.2':
+ resolution: {integrity: sha512-ee+c14ggUiM7lg4PtCjSBYU9O9dHp4FFespRnd5yj7SilUBJCL3x3aHlB+sIqbGjcPo3pEyODASnfKJuZ26YBw==}
+ cpu: [x64]
+ os: [win32]
+
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
+ '@types/debug@4.1.13':
+ resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
+
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
+ '@types/estree@1.0.10':
+ resolution: {integrity: sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==}
+
+ '@types/ms@2.1.0':
+ resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+
+ '@types/node@20.19.43':
+ resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
+
+ '@types/serviceworker@0.0.153':
+ resolution: {integrity: sha512-/cg6dFEkNchJLyRCGo4Gb8mF200qr3xskM5dCPgbtK0OzXxcFcXa6BEBdyG7JksRsTrvCR+V6aFPncoOYAwYhQ==}
+
+ '@vitest/expect@3.2.7':
+ resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==}
+
+ '@vitest/mocker@3.2.7':
+ resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/pretty-format@3.2.7':
+ resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==}
+
+ '@vitest/runner@3.2.7':
+ resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==}
+
+ '@vitest/snapshot@3.2.7':
+ resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==}
+
+ '@vitest/spy@3.2.7':
+ resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==}
+
+ '@vitest/utils@3.2.7':
+ resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==}
+
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
+ base-x@5.0.1:
+ resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==}
+
+ bs58@6.0.0:
+ resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==}
+
+ bs58check@4.0.0:
+ resolution: {integrity: sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==}
+
+ cac@6.7.14:
+ resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
+ engines: {node: '>=8'}
+
+ cbor-extract@2.2.3:
+ resolution: {integrity: sha512-zTOCEYwiQDChj5DV/sUqccv3jhdZqbhymztwcrW2LncIG2fPPkb1ceK5rmr0bdtxzJCXoWzZ95shA+uaSH8cKw==}
+ hasBin: true
+
+ cbor-x@1.6.9:
+ resolution: {integrity: sha512-TCT9XHLb4iPGQ99fpQjcTRncbus6cXHvSD/RY+bPrDACBCJKiQLmAM6r7/BgQcI5i+8h4gwp4vjfb6S6T8bPGw==}
+
+ chai@5.3.3:
+ resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
+ engines: {node: '>=18'}
+
+ check-error@2.1.3:
+ resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
+ engines: {node: '>= 16'}
+
+ crelt@1.0.7:
+ resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ deep-eql@5.0.2:
+ resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
+ engines: {node: '>=6'}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
+
+ es-module-lexer@1.7.0:
+ resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+
+ esbuild@0.28.2:
+ resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
+ eventemitter3@5.0.4:
+ resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
+
+ expect-type@1.4.0:
+ resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
+ engines: {node: '>=12.0.0'}
+
+ fast-sha256@1.3.0:
+ resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ happy-dom@15.11.7:
+ resolution: {integrity: sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==}
+ engines: {node: '>=18.0.0'}
+
+ isomorphic-ws@5.0.0:
+ resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==}
+ peerDependencies:
+ ws: '*'
+
+ js-tokens@9.0.1:
+ resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
+
+ loupe@3.2.1:
+ resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ nanoid@3.3.20:
+ resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ node-gyp-build-optional-packages@5.1.1:
+ resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==}
+ hasBin: true
+
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
+ pathval@2.0.1:
+ resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
+ engines: {node: '>= 14.16'}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.7:
+ resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
+ engines: {node: '>=12'}
+
+ postcss@8.5.32:
+ resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ resolve.exports@2.0.4:
+ resolution: {integrity: sha512-zepuGL0ulf+nt+XaFrlKeaiTJeyPFPLLY62+ncq1/VTHBdt3HsfXOH60priZ6tnXQozdGcDeIF5JVTanvIcYWQ==}
+ engines: {node: '>=10'}
+
+ rollup@4.68.2:
+ resolution: {integrity: sha512-xzt45rKOxzEuqjTdkGTIEaePcq9xRELlFI1uoazlKap8T6bENJkONSCfhK51P6XFTixa4rosPcDHihSdduXndQ==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ seroval-plugins@1.6.12:
+ resolution: {integrity: sha512-+reiwp/lYxiyctSrdvX7OQ1YLrcbWYgBiZrhSFL+V43b0/DGRLNVSDyZINbQe+tWplPOI2fw2dPrdv6hcc09kQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ seroval: ^1.0
+
+ seroval@1.6.12:
+ resolution: {integrity: sha512-CqbIVfk/7GL3ITwZI55lcPbpQbjHZ2DD5cUj4iH7E9YsCfHNJV1mx7yNp86Z84w5IpmkZZS/2s5vPOWmVWSdQg==}
+ engines: {node: '>=10'}
+
+ siginfo@2.0.0:
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
+ solid-js@1.9.19:
+ resolution: {integrity: sha512-NIrV/6o1FtmrA9iuAAYvnQJZaHrGcaH3LKSVj4kCiZfwqjCtJNDgIgR9XcqYl6KR/Lb3oRx1HrlyswPWR8SRvw==}
+
+ source-map-js@1.2.2:
+ resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
+ engines: {node: '>=0.10.0'}
+
+ stackback@0.0.2:
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
+ std-env@3.10.0:
+ resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
+
+ strip-literal@3.1.0:
+ resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
+
+ style-mod@4.1.4:
+ resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==}
+
+ tinyargs@0.1.4:
+ resolution: {integrity: sha512-5OpdhMRRE70j0zT7mrBpx12FJI+y0EGx8zMe8Vl2/aiwGgpW3X70OVHlsHmHeeG0ncnnWdq1o+k/S5d9ONgzGA==}
+ engines: {node: '>=14'}
+
+ tinybench@2.9.0:
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+ tinyexec@0.3.2:
+ resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
+
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+ engines: {node: '>=12.0.0'}
+
+ tinypool@1.1.1:
+ resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+
+ tinyrainbow@2.0.0:
+ resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
+ engines: {node: '>=14.0.0'}
+
+ tinyspy@4.0.6:
+ resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==}
+ engines: {node: '>=14.0.0'}
+
+ undici-types@6.21.0:
+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
+ uuid@14.0.3:
+ resolution: {integrity: sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==}
+ hasBin: true
+
+ vite-node@3.2.4:
+ resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+
+ vite-plugin-css-injected-by-js@3.5.2:
+ resolution: {integrity: sha512-2MpU/Y+SCZyWUB6ua3HbJCrgnF0KACAsmzOQt1UvRVJCGF6S8xdA3ZUhWcWdM9ivG4I5az8PnQmwwrkC2CAQrQ==}
+ peerDependencies:
+ vite: '>2.0.0-0'
+
+ vite@7.3.8:
+ resolution: {integrity: sha512-3tTi78MAKhVk9h+YZVXMQ8POmneJuE97QUPAWtJghHzhH7BAyiqQOC2KZh1DWOsu/USB2cRXhznPByaabvYXfw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ lightningcss: ^1.21.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ vitest@3.2.7:
+ resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@types/debug': ^4.1.12
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ '@vitest/browser': 3.2.7
+ '@vitest/ui': 3.2.7
+ happy-dom: '*'
+ jsdom: '*'
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@types/debug':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
+ w3c-keyname@2.2.8:
+ resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
+
+ webidl-conversions@7.0.0:
+ resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
+ engines: {node: '>=12'}
+
+ whatwg-mimetype@3.0.0:
+ resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
+ engines: {node: '>=12'}
+
+ why-is-node-running@2.3.0:
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+ engines: {node: '>=8'}
+ hasBin: true
+
+ ws@8.23.0:
+ resolution: {integrity: sha512-wCkmX8QMOrKjgO8HW2WyDbTSGeqkocxgDzS9iR79rEAexty6/2qzilx3Ag8Tqf9ykop3irLKle0MtVn5qjVnoQ==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
+ xstate@5.33.4:
+ resolution: {integrity: sha512-eGNMu3BNqU0Z85P+ouYG5OCZqIABU+QsW6vUieFt9p9gOGs3C+BL10Exg7XdRPSXPQKlP6XoYpYf93r9VmW8mQ==}
+
+snapshots:
+
+ '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.48':
+ dependencies:
+ '@automerge/automerge-repo': 2.6.0-subduction.48
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
+ '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.48':
+ dependencies:
+ '@automerge/automerge-repo': 2.6.0-subduction.48
+ eventemitter3: 5.0.4
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
+ '@automerge/automerge-repo-network-websocket@2.6.0-subduction.48':
+ dependencies:
+ '@automerge/automerge-repo': 2.6.0-subduction.48
+ cbor-x: 1.6.9
+ debug: 4.4.3
+ eventemitter3: 5.0.4
+ ws: 8.23.0
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
+ '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.48':
+ dependencies:
+ '@automerge/automerge-repo': 2.6.0-subduction.48
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
+ '@automerge/automerge-repo@2.6.0-subduction.48':
+ dependencies:
+ '@automerge/automerge': 3.3.2
+ '@automerge/automerge-subduction': 0.16.1
+ bs58check: 4.0.0
+ cbor-x: 1.6.9
+ debug: 4.4.3
+ eventemitter3: 5.0.4
+ fast-sha256: 1.3.0
+ isomorphic-ws: 5.0.0(ws@8.23.0)
+ uuid: 14.0.3
+ ws: 8.23.0
+ xstate: 5.33.4
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
+ '@automerge/automerge-repo@2.6.0-subduction.49':
+ dependencies:
+ '@automerge/automerge': 3.3.2
+ '@automerge/automerge-subduction': 0.20.1
+ bs58check: 4.0.0
+ cbor-x: 1.6.9
+ debug: 4.4.3
+ eventemitter3: 5.0.4
+ fast-sha256: 1.3.0
+ isomorphic-ws: 5.0.0(ws@8.23.0)
+ uuid: 14.0.3
+ ws: 8.23.0
+ xstate: 5.33.4
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
+ '@automerge/automerge-subduction@0.16.1': {}
+
+ '@automerge/automerge-subduction@0.20.1': {}
+
+ '@automerge/automerge@3.3.0-fragments.1': {}
+
+ '@automerge/automerge@3.3.2': {}
+
+ '@cbor-extract/cbor-extract-darwin-arm64@2.2.3':
+ optional: true
+
+ '@cbor-extract/cbor-extract-darwin-x64@2.2.3':
+ optional: true
+
+ '@cbor-extract/cbor-extract-linux-arm64@2.2.3':
+ optional: true
+
+ '@cbor-extract/cbor-extract-linux-arm@2.2.3':
+ optional: true
+
+ '@cbor-extract/cbor-extract-linux-x64@2.2.3':
+ optional: true
+
+ '@cbor-extract/cbor-extract-win32-x64@2.2.3':
+ optional: true
+
+ '@codemirror/commands@6.11.1':
+ dependencies:
+ '@codemirror/language': 6.13.1
+ '@codemirror/state': 6.7.6
+ '@codemirror/view': 6.43.15
+ '@lezer/common': 1.5.5
+
+ '@codemirror/language@6.13.1':
+ dependencies:
+ '@codemirror/state': 6.7.6
+ '@codemirror/streamparser': 6.0.0
+ '@codemirror/view': 6.43.15
+ '@lezer/common': 1.5.5
+ '@lezer/highlight': 1.2.5
+ '@lezer/lr': 1.4.12
+ style-mod: 4.1.4
+
+ '@codemirror/state@6.7.6':
+ dependencies:
+ '@marijn/find-cluster-break': 1.0.4
+
+ '@codemirror/streamparser@6.0.0': {}
+
+ '@codemirror/view@6.43.15':
+ dependencies:
+ '@codemirror/state': 6.7.6
+ crelt: 1.0.7
+ style-mod: 4.1.4
+ w3c-keyname: 2.2.8
+
+ '@esbuild/aix-ppc64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm@0.28.2':
+ optional: true
+
+ '@esbuild/android-x64@0.28.2':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/darwin-x64@0.28.2':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-arm@0.28.2':
+ optional: true
+
+ '@esbuild/linux-ia32@0.28.2':
+ optional: true
+
+ '@esbuild/linux-loong64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.28.2':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-s390x@0.28.2':
+ optional: true
+
+ '@esbuild/linux-x64@0.28.2':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/sunos-x64@0.28.2':
+ optional: true
+
+ '@esbuild/win32-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/win32-ia32@0.28.2':
+ optional: true
+
+ '@esbuild/win32-x64@0.28.2':
+ optional: true
+
+ '@inkandswitch/patchwork-bootloader@0.7.2(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)':
+ dependencies:
+ '@automerge/automerge-repo-network-broadcastchannel': 2.6.0-subduction.48
+ '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.48
+ '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.48
+ '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.48
+ '@automerge/automerge-subduction': 0.16.1
+ '@codemirror/commands': 6.11.1
+ '@codemirror/language': 6.13.1
+ '@codemirror/state': 6.7.6
+ '@codemirror/view': 6.43.15
+ '@inkandswitch/patchwork-elements': 6.2.1(@automerge/automerge-repo@2.6.0-subduction.49)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-plugins@1.2.7(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)))(@inkandswitch/patchwork-providers@0.5.5(@automerge/automerge-repo@2.6.0-subduction.49))(solid-js@1.9.19)
+ '@inkandswitch/patchwork-filesystem': 0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)
+ '@inkandswitch/patchwork-plugins': 1.2.7(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1))
+ '@inkandswitch/patchwork-providers': 0.5.5(@automerge/automerge-repo@2.6.0-subduction.49)
+ '@keyhive/keyhive': 0.1.0-alpha.8
+ '@types/debug': 4.1.13
+ debug: 4.4.3
+ resolve.exports: 2.0.4
+ service-worker-types: '@types/serviceworker@0.0.153'
+ solid-js: 1.9.19
+ tinyargs: 0.1.4
+ optionalDependencies:
+ '@automerge/automerge': 3.3.0-fragments.1
+ '@automerge/automerge-repo': 2.6.0-subduction.49
+ transitivePeerDependencies:
+ - '@types/react'
+ - bufferutil
+ - react
+ - supports-color
+ - utf-8-validate
+
+ '@inkandswitch/patchwork-elements@6.2.1(@automerge/automerge-repo@2.6.0-subduction.49)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-plugins@1.2.7(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)))(@inkandswitch/patchwork-providers@0.5.5(@automerge/automerge-repo@2.6.0-subduction.49))(solid-js@1.9.19)':
+ dependencies:
+ '@automerge/automerge-repo': 2.6.0-subduction.49
+ '@inkandswitch/patchwork-filesystem': 0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)
+ '@inkandswitch/patchwork-plugins': 1.2.7(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1))
+ '@inkandswitch/patchwork-providers': 0.5.5(@automerge/automerge-repo@2.6.0-subduction.49)
+ debug: 4.4.3
+ optionalDependencies:
+ solid-js: 1.9.19
+ transitivePeerDependencies:
+ - supports-color
+
+ '@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)':
+ dependencies:
+ '@automerge/automerge': 3.3.0-fragments.1
+ '@automerge/automerge-repo': 2.6.0-subduction.49
+ debug: 4.4.3
+ resolve.exports: 2.0.4
+ transitivePeerDependencies:
+ - supports-color
+
+ '@inkandswitch/patchwork-plugins@1.2.7(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1))':
+ dependencies:
+ '@automerge/automerge': 3.3.0-fragments.1
+ '@automerge/automerge-repo': 2.6.0-subduction.49
+ '@inkandswitch/patchwork-filesystem': 0.2.19(@automerge/automerge-repo@2.6.0-subduction.49)(@automerge/automerge@3.3.0-fragments.1)
+ '@types/debug': 4.1.13
+ '@types/node': 20.19.43
+ debug: 4.4.3
+ eventemitter3: 5.0.4
+ resolve.exports: 2.0.4
+ transitivePeerDependencies:
+ - supports-color
+
+ '@inkandswitch/patchwork-providers@0.5.5(@automerge/automerge-repo@2.6.0-subduction.49)':
+ dependencies:
+ '@automerge/automerge-repo': 2.6.0-subduction.49
+
+ '@jridgewell/sourcemap-codec@1.6.0': {}
+
+ '@keyhive/keyhive@0.1.0-alpha.8': {}
+
+ '@lezer/common@1.5.5': {}
+
+ '@lezer/highlight@1.2.5':
+ dependencies:
+ '@lezer/common': 1.5.5
+
+ '@lezer/lr@1.4.12':
+ dependencies:
+ '@lezer/common': 1.5.5
+
+ '@marijn/find-cluster-break@1.0.4': {}
+
+ '@noble/hashes@1.8.0': {}
+
+ '@rollup/rollup-android-arm-eabi@4.68.2':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.68.2':
+ optional: true
+
+ '@rollup/rollup-android-x64@4.68.2':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.68.2':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.68.2':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.68.2':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.68.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.68.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.68.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.68.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.68.2':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.68.2':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.68.2':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.68.2':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.68.2':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.68.2':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.68.2':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.68.2':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.68.2':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.68.2':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.68.2':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.68.2':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.68.2':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.68.2':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.68.2':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.68.2':
+ optional: true
+
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
+ '@types/debug@4.1.13':
+ dependencies:
+ '@types/ms': 2.1.0
+
+ '@types/deep-eql@4.0.2': {}
+
+ '@types/estree@1.0.10': {}
+
+ '@types/ms@2.1.0': {}
+
+ '@types/node@20.19.43':
+ dependencies:
+ undici-types: 6.21.0
+
+ '@types/serviceworker@0.0.153': {}
+
+ '@vitest/expect@3.2.7':
+ dependencies:
+ '@types/chai': 5.2.3
+ '@vitest/spy': 3.2.7
+ '@vitest/utils': 3.2.7
+ chai: 5.3.3
+ tinyrainbow: 2.0.0
+
+ '@vitest/mocker@3.2.7(vite@7.3.8(@types/node@20.19.43))':
+ dependencies:
+ '@vitest/spy': 3.2.7
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 7.3.8(@types/node@20.19.43)
+
+ '@vitest/pretty-format@3.2.7':
+ dependencies:
+ tinyrainbow: 2.0.0
+
+ '@vitest/runner@3.2.7':
+ dependencies:
+ '@vitest/utils': 3.2.7
+ pathe: 2.0.3
+ strip-literal: 3.1.0
+
+ '@vitest/snapshot@3.2.7':
+ dependencies:
+ '@vitest/pretty-format': 3.2.7
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
+ '@vitest/spy@3.2.7':
+ dependencies:
+ tinyspy: 4.0.6
+
+ '@vitest/utils@3.2.7':
+ dependencies:
+ '@vitest/pretty-format': 3.2.7
+ loupe: 3.2.1
+ tinyrainbow: 2.0.0
+
+ assertion-error@2.0.1: {}
+
+ base-x@5.0.1: {}
+
+ bs58@6.0.0:
+ dependencies:
+ base-x: 5.0.1
+
+ bs58check@4.0.0:
+ dependencies:
+ '@noble/hashes': 1.8.0
+ bs58: 6.0.0
+
+ cac@6.7.14: {}
+
+ cbor-extract@2.2.3:
+ dependencies:
+ node-gyp-build-optional-packages: 5.1.1
+ optionalDependencies:
+ '@cbor-extract/cbor-extract-darwin-arm64': 2.2.3
+ '@cbor-extract/cbor-extract-darwin-x64': 2.2.3
+ '@cbor-extract/cbor-extract-linux-arm': 2.2.3
+ '@cbor-extract/cbor-extract-linux-arm64': 2.2.3
+ '@cbor-extract/cbor-extract-linux-x64': 2.2.3
+ '@cbor-extract/cbor-extract-win32-x64': 2.2.3
+ optional: true
+
+ cbor-x@1.6.9:
+ optionalDependencies:
+ cbor-extract: 2.2.3
+
+ chai@5.3.3:
+ dependencies:
+ assertion-error: 2.0.1
+ check-error: 2.1.3
+ deep-eql: 5.0.2
+ loupe: 3.2.1
+ pathval: 2.0.1
+
+ check-error@2.1.3: {}
+
+ crelt@1.0.7: {}
+
+ csstype@3.2.3: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ deep-eql@5.0.2: {}
+
+ detect-libc@2.1.2:
+ optional: true
+
+ entities@4.5.0: {}
+
+ es-module-lexer@1.7.0: {}
+
+ esbuild@0.28.2:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.28.2
+ '@esbuild/android-arm': 0.28.2
+ '@esbuild/android-arm64': 0.28.2
+ '@esbuild/android-x64': 0.28.2
+ '@esbuild/darwin-arm64': 0.28.2
+ '@esbuild/darwin-x64': 0.28.2
+ '@esbuild/freebsd-arm64': 0.28.2
+ '@esbuild/freebsd-x64': 0.28.2
+ '@esbuild/linux-arm': 0.28.2
+ '@esbuild/linux-arm64': 0.28.2
+ '@esbuild/linux-ia32': 0.28.2
+ '@esbuild/linux-loong64': 0.28.2
+ '@esbuild/linux-mips64el': 0.28.2
+ '@esbuild/linux-ppc64': 0.28.2
+ '@esbuild/linux-riscv64': 0.28.2
+ '@esbuild/linux-s390x': 0.28.2
+ '@esbuild/linux-x64': 0.28.2
+ '@esbuild/netbsd-arm64': 0.28.2
+ '@esbuild/netbsd-x64': 0.28.2
+ '@esbuild/openbsd-arm64': 0.28.2
+ '@esbuild/openbsd-x64': 0.28.2
+ '@esbuild/openharmony-arm64': 0.28.2
+ '@esbuild/sunos-x64': 0.28.2
+ '@esbuild/win32-arm64': 0.28.2
+ '@esbuild/win32-ia32': 0.28.2
+ '@esbuild/win32-x64': 0.28.2
+
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.10
+
+ eventemitter3@5.0.4: {}
+
+ expect-type@1.4.0: {}
+
+ fast-sha256@1.3.0: {}
+
+ fdir@6.5.0(picomatch@4.0.7):
+ optionalDependencies:
+ picomatch: 4.0.7
+
+ fsevents@2.3.3:
+ optional: true
+
+ happy-dom@15.11.7:
+ dependencies:
+ entities: 4.5.0
+ webidl-conversions: 7.0.0
+ whatwg-mimetype: 3.0.0
+
+ isomorphic-ws@5.0.0(ws@8.23.0):
+ dependencies:
+ ws: 8.23.0
+
+ js-tokens@9.0.1: {}
+
+ loupe@3.2.1: {}
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ ms@2.1.3: {}
+
+ nanoid@3.3.20: {}
+
+ node-gyp-build-optional-packages@5.1.1:
+ dependencies:
+ detect-libc: 2.1.2
+ optional: true
+
+ pathe@2.0.3: {}
+
+ pathval@2.0.1: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.7: {}
+
+ postcss@8.5.32:
+ dependencies:
+ nanoid: 3.3.20
+ picocolors: 1.1.1
+ source-map-js: 1.2.2
+
+ resolve.exports@2.0.4: {}
+
+ rollup@4.68.2:
+ dependencies:
+ '@types/estree': 1.0.10
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.68.2
+ '@rollup/rollup-android-arm64': 4.68.2
+ '@rollup/rollup-android-x64': 4.68.2
+ '@rollup/rollup-darwin-arm64': 4.68.2
+ '@rollup/rollup-darwin-x64': 4.68.2
+ '@rollup/rollup-freebsd-arm64': 4.68.2
+ '@rollup/rollup-freebsd-x64': 4.68.2
+ '@rollup/rollup-linux-arm-gnueabihf': 4.68.2
+ '@rollup/rollup-linux-arm-musleabihf': 4.68.2
+ '@rollup/rollup-linux-arm64-gnu': 4.68.2
+ '@rollup/rollup-linux-arm64-musl': 4.68.2
+ '@rollup/rollup-linux-loong64-gnu': 4.68.2
+ '@rollup/rollup-linux-loong64-musl': 4.68.2
+ '@rollup/rollup-linux-ppc64-gnu': 4.68.2
+ '@rollup/rollup-linux-ppc64-musl': 4.68.2
+ '@rollup/rollup-linux-riscv64-gnu': 4.68.2
+ '@rollup/rollup-linux-riscv64-musl': 4.68.2
+ '@rollup/rollup-linux-s390x-gnu': 4.68.2
+ '@rollup/rollup-linux-x64-gnu': 4.68.2
+ '@rollup/rollup-linux-x64-musl': 4.68.2
+ '@rollup/rollup-openbsd-x64': 4.68.2
+ '@rollup/rollup-openharmony-arm64': 4.68.2
+ '@rollup/rollup-win32-arm64-msvc': 4.68.2
+ '@rollup/rollup-win32-ia32-msvc': 4.68.2
+ '@rollup/rollup-win32-x64-gnu': 4.68.2
+ '@rollup/rollup-win32-x64-msvc': 4.68.2
+ fsevents: 2.3.3
+
+ seroval-plugins@1.6.12(seroval@1.6.12):
+ dependencies:
+ seroval: 1.6.12
+
+ seroval@1.6.12: {}
+
+ siginfo@2.0.0: {}
+
+ solid-js@1.9.19:
+ dependencies:
+ csstype: 3.2.3
+ seroval: 1.6.12
+ seroval-plugins: 1.6.12(seroval@1.6.12)
+
+ source-map-js@1.2.2: {}
+
+ stackback@0.0.2: {}
+
+ std-env@3.10.0: {}
+
+ strip-literal@3.1.0:
+ dependencies:
+ js-tokens: 9.0.1
+
+ style-mod@4.1.4: {}
+
+ tinyargs@0.1.4: {}
+
+ tinybench@2.9.0: {}
+
+ tinyexec@0.3.2: {}
+
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.7)
+ picomatch: 4.0.7
+
+ tinypool@1.1.1: {}
+
+ tinyrainbow@2.0.0: {}
+
+ tinyspy@4.0.6: {}
+
+ undici-types@6.21.0: {}
+
+ uuid@14.0.3: {}
+
+ vite-node@3.2.4(@types/node@20.19.43):
+ dependencies:
+ cac: 6.7.14
+ debug: 4.4.3
+ es-module-lexer: 1.7.0
+ pathe: 2.0.3
+ vite: 7.3.8(@types/node@20.19.43)
+ transitivePeerDependencies:
+ - '@types/node'
+ - jiti
+ - less
+ - lightningcss
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+ - tsx
+ - yaml
+
+ vite-plugin-css-injected-by-js@3.5.2(vite@7.3.8(@types/node@20.19.43)):
+ dependencies:
+ vite: 7.3.8(@types/node@20.19.43)
+
+ vite@7.3.8(@types/node@20.19.43):
+ dependencies:
+ esbuild: 0.28.2
+ fdir: 6.5.0(picomatch@4.0.7)
+ picomatch: 4.0.7
+ postcss: 8.5.32
+ rollup: 4.68.2
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ '@types/node': 20.19.43
+ fsevents: 2.3.3
+
+ vitest@3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(happy-dom@15.11.7):
+ dependencies:
+ '@types/chai': 5.2.3
+ '@vitest/expect': 3.2.7
+ '@vitest/mocker': 3.2.7(vite@7.3.8(@types/node@20.19.43))
+ '@vitest/pretty-format': 3.2.7
+ '@vitest/runner': 3.2.7
+ '@vitest/snapshot': 3.2.7
+ '@vitest/spy': 3.2.7
+ '@vitest/utils': 3.2.7
+ chai: 5.3.3
+ debug: 4.4.3
+ expect-type: 1.4.0
+ magic-string: 0.30.21
+ pathe: 2.0.3
+ picomatch: 4.0.7
+ std-env: 3.10.0
+ tinybench: 2.9.0
+ tinyexec: 0.3.2
+ tinyglobby: 0.2.17
+ tinypool: 1.1.1
+ tinyrainbow: 2.0.0
+ vite: 7.3.8(@types/node@20.19.43)
+ vite-node: 3.2.4(@types/node@20.19.43)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/debug': 4.1.13
+ '@types/node': 20.19.43
+ happy-dom: 15.11.7
+ transitivePeerDependencies:
+ - jiti
+ - less
+ - lightningcss
+ - msw
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+ - tsx
+ - yaml
+
+ w3c-keyname@2.2.8: {}
+
+ webidl-conversions@7.0.0: {}
+
+ whatwg-mimetype@3.0.0: {}
+
+ why-is-node-running@2.3.0:
+ dependencies:
+ siginfo: 2.0.0
+ stackback: 0.0.2
+
+ ws@8.23.0: {}
+
+ xstate@5.33.4: {}
diff --git a/call/pnpm-workspace.yaml b/call/pnpm-workspace.yaml
new file mode 100644
index 00000000..07535357
--- /dev/null
+++ b/call/pnpm-workspace.yaml
@@ -0,0 +1,9 @@
+# pnpm 11 reads its settings from this file. There is no root workspace, so
+# every package carries the settings it needs rather than inheriting them.
+minimumReleaseAge: 0
+verifyDepsBeforeRun: false
+allowBuilds:
+ "@swc/core": true
+ cbor-extract: true
+ core-js: true
+ esbuild: true
diff --git a/call/src/button.js b/call/src/button.js
new file mode 100644
index 00000000..e848f5b0
--- /dev/null
+++ b/call/src/button.js
@@ -0,0 +1,65 @@
+// the call button, for any doc's title bar: call everyone who has it open,
+// join the call that's on it, or hang up. it knows the call only by asking
+// the provider (protocol.js), so it works wherever its title bar is. with
+// no provider to ask, it's nothing
+import { createSignal, Show } from "solid-js";
+import { render } from "solid-js/web";
+import html from "solid-js/html";
+import { subscribe } from "@inkandswitch/patchwork-providers";
+import { CALL, CALL_EVENT } from "./protocol-names.js";
+import { icon } from "./ui/icons.js";
+import "./ui/styles.css";
+
+const PROVIDER = "call-provider";
+const LABELS = { call: "call everyone here", join: "join the call", here: "hang up" };
+
+export default function CallDoctitleButton(handle, element) {
+ // the doc the frame has open: the view's own url (the handle may be a
+ // draft's, presenting it)
+ const url = element.closest?.("patchwork-view")?.getAttribute("doc-url") ?? handle.url;
+ const [room, setRoom] = createSignal(null);
+ let unsubscribe = subscribe(element, { type: CALL, url }, setRoom);
+ // a provider that turns up after this asked (loaded late, or swapped in
+ // anew) is asked again
+ function again(event) {
+ if (event.detail?.componentId !== PROVIDER) return;
+ unsubscribe();
+ unsubscribe = subscribe(element, { type: CALL, url }, setRoom);
+ }
+ addEventListener("patchwork:mounted", again);
+
+ const state = () => (room()?.here ? "here" : room()?.others > 0 ? "join" : "call");
+ const send = (detail) => element.dispatchEvent(new CustomEvent(CALL_EVENT, { bubbles: true, composed: true, detail }));
+
+ function press(event) {
+ if (state() === "here") return send({ action: "hang-up" });
+ const box = event.currentTarget.getBoundingClientRect();
+ send({ action: "ask", url, at: { x: box.left, y: box.bottom + 6 } });
+ }
+
+ const dispose = render(
+ () => html`<${Show} when=${room}>
+
+ LABELS[state()]}
+ title=${() => LABELS[state()]}
+ onClick=${press}
+ >
+ ${() => icon(state() === "here" ? "hang-up" : "phone", 14)}
+ <${Show} when=${() => state() === "join"}>
+ ${() => room().others}
+ />
+
+
+ />`,
+ element,
+ );
+ return () => {
+ removeEventListener("patchwork:mounted", again);
+ unsubscribe();
+ dispose();
+ };
+}
diff --git a/call/src/call-face.js b/call/src/call-face.js
new file mode 100644
index 00000000..b6a12d0e
--- /dev/null
+++ b/call/src/call-face.js
@@ -0,0 +1,41 @@
+// one person's face in your call, for anywhere: lattice puts one on each
+// cursor. who it's of is said on its element, as attributes: `peer` (their
+// tab, as presence knows it) and/or `contact` (who they are), `size` (px),
+// and `no-title` to leave their name off it (a cursor says it already).
+// nobody in your call by those, and it's nothing
+import { createSignal, Show } from "solid-js";
+import { render } from "solid-js/web";
+import html from "solid-js/html";
+import { call } from "./engine/call.js";
+import { Face } from "./ui/ui.js";
+import "./ui/styles.css";
+
+const ASKS = ["peer", "contact", "size", "no-title"];
+
+export function CallFace(element) {
+ const read = () => ({
+ peer: element.getAttribute("peer") ?? "",
+ contact: element.getAttribute("contact") ?? "",
+ size: Number(element.getAttribute("size")) || undefined,
+ title: !element.hasAttribute("no-title"),
+ });
+ const [asked, setAsked] = createSignal(read());
+ const watching = new MutationObserver(() => setAsked(read()));
+ watching.observe(element, { attributes: true, attributeFilter: ASKS });
+
+ const id = () => {
+ if (!call.get()) return null;
+ const { peer, contact } = asked();
+ return call.find({ peer, contact })?.id ?? null;
+ };
+ const dispose = render(
+ () => html`<${Show} when=${id} keyed>
+ ${(them) => html`<${Face} id=${them} size=${() => asked().size} title=${() => asked().title} />`}
+ />`,
+ element,
+ );
+ return () => {
+ watching.disconnect();
+ dispose();
+ };
+}
diff --git a/call/src/control-panel.js b/call/src/control-panel.js
new file mode 100644
index 00000000..b5e6b48f
--- /dev/null
+++ b/call/src/control-panel.js
@@ -0,0 +1,251 @@
+// the call's control panel, for a sidebar (threepane's left one, at the
+// bottom; lattice's call bar, where this came from). the calls on screen
+// you're not in, to join, each by where it is (which takes you there); and
+// the one you're in: where it is, everyone in it, and your mic, camera,
+// screen (where there's one to share) and hanging up. its menu says where
+// faces go (floating, or in the sidebar where a frame can show them there),
+// which mic and camera, and what's done about noise. with nothing to show,
+// it takes no room (data-sidebar-size: none)
+import { createEffect, createSignal, For, Show } from "solid-js";
+import { render } from "solid-js/web";
+import html from "solid-js/html";
+import { openDocument } from "@inkandswitch/patchwork-elements";
+import {
+ FACES,
+ call,
+ canShare,
+ devices,
+ hangUp,
+ join,
+ setCamera,
+ setDevice,
+ setFaces,
+ setMic,
+ setNoise,
+ startScreen,
+ stopScreen,
+} from "./engine/call.js";
+import { download, DOWNLOAD_BYTES, NOISE } from "./engine/noise.js";
+import { DragOut, Face, StageButton, names } from "./ui/ui.js";
+import { scrollsAlong } from "./ui/along.js";
+import { arrivals } from "./ui/arriving.js";
+import { icon } from "./ui/icons.js";
+import "./ui/styles.css";
+
+export function CallControlPanel(element) {
+ const showing = () => !!call.get() || call.rooms().length > 0 || !!call.problem();
+ const dispose = render(() => {
+ createEffect(() => (element.dataset.sidebarSize = showing() ? "fit" : "none"));
+ return html`<${Panel} element=${element} />`;
+ }, element);
+ return () => {
+ dispose();
+ element.dataset.sidebarSize = "none";
+ };
+}
+
+function Panel(props) {
+ let menu;
+ const [found, setFound] = createSignal({ audio: [], video: [] });
+ const go = (url) => openDocument(props.element, url);
+
+ async function openMenu(event) {
+ if (menu.matches(":popover-open")) return menu.hidePopover();
+ const box = event.currentTarget.getBoundingClientRect();
+ menu.style.left = `${Math.max(8, box.left)}px`;
+ menu.style.bottom = `${innerHeight - box.top + 4}px`;
+ menu.style.top = "auto";
+ // it grows up out of its button
+ menu.style.transformOrigin = "0 100%";
+ menu.showPopover();
+ setFound(await devices());
+ }
+
+ // (choose takes the click, so it's a function, not a getter)
+ function Choice(props) {
+ return html``;
+ }
+
+ function Devices(props) {
+ const chosen = () => (props.kind === "audio" ? call.settings.mic : call.settings.camera);
+ return html`<${Show} when=${() => found()[props.kind].length > 1}>
+
+ <${For} each=${() => found()[props.kind]}>
+ ${(device, at) =>
+ html`<${Choice}
+ label=${device.label}
+ checked=${() => chosen() === device.id || (!chosen() && at() === 0)}
+ choose=${(event) => setDevice(props.kind, device.id)}
+ />`}
+ />
+ />`;
+ }
+
+ const In = () => {
+ // someone joining while you're in it comes in (ui/arriving.js): not
+ // whoever was there when you joined
+ const arriving = arrivals();
+ return html`
+
+ ${icon("phone", 12)}
+ go(call.get()?.room)}>
+ ${() => (call.get()?.joining ? "joining…" : call.nameOf(call.get()?.room) || "a doc")}
+
+ {
+ event.currentTarget.dataset.callMenuWasOpen = String(menu.matches(":popover-open"));
+ }}
+ onClick=${(event) => {
+ const button = event.currentTarget;
+ const dismiss = event.detail > 0 && button.dataset.callMenuWasOpen === "true";
+ delete button.dataset.callMenuWasOpen;
+ if (dismiss) menu.hidePopover();
+ else openMenu(event);
+ }}
+ >
+ ${icon("chevron-down", 12)}
+
+
+
+
+ <${DragOut} id="me"><${Face} id="me" size=${26} />/>
+ <${For} each=${call.peers}>
+ ${(peer) => html`<${StageButton} id=${peer.id} class="patchwork-call-panel-face-button" arriving=${arriving(peer.id)}>
+ <${Face} id=${peer.id} size=${26} />
+ />`}
+ />
+
+
+ (call.get()?.mic ? undefined : "")}
+ title=${() => (call.get()?.mic ? "mute" : "unmute")}
+ aria-label=${() => (call.get()?.mic ? "mute" : "unmute")}
+ onClick=${() => setMic(!call.get()?.mic)}
+ >
+ ${() => icon(call.get()?.mic ? "mic" : "mic-off", 14)}
+
+ (call.get()?.camera ? undefined : "")}
+ title=${() => (call.get()?.camera ? "camera off" : "camera on")}
+ aria-label=${() => (call.get()?.camera ? "camera off" : "camera on")}
+ onClick=${() => setCamera(!call.get()?.camera)}
+ >
+ ${() => icon(call.get()?.camera ? "video" : "video-off", 14)}
+
+ <${Show} when=${canShare}>
+ (call.get()?.screen ? "" : undefined)}
+ aria-pressed=${() => String(!!call.get()?.screen)}
+ title=${() => (call.get()?.screen ? "stop sharing your screen" : "share your screen")}
+ aria-label=${() => (call.get()?.screen ? "stop sharing your screen" : "share your screen")}
+ onClick=${() => (call.get()?.screen ? stopScreen() : startScreen())}
+ >
+ ${icon("screen", 14)}
+
+ />
+
+ ${icon("hang-up", 14)}
+
+
+
+ <${Show} when=${() => call.get()?.problem}>
+
${() => call.get()?.problem}
+ />
+ <${Show} when=${() => call.settings.noise === "deepfilternet" && download().state === "loading"}>
+
+ getting DeepFilterNet ${() => Math.floor((download().got / download().total) * 100)}% · the browser's till then
+
+ />
+ <${Show} when=${() => call.settings.noise === "deepfilternet" && download().state === "failed"}>
+
couldn't get DeepFilterNet (${() => download().problem}): the browser's for now
+ />
+
+
`;
+ };
+
+ // DeepFilterNet's download, in its line of the menu and under the call
+ // while it's going: what it is till it's here, how far it's got, or why not
+ const megabytes = (bytes) => `${Math.round(bytes / 1024 / 1024)}MB`;
+ function deepFilterNote() {
+ const { state, got, total } = download();
+ if (state === "loading") return `· downloading ${Math.floor((got / total) * 100)}%`;
+ if (state === "failed") return "· couldn't download it";
+ if (state === "ready") return "";
+ return `· AI, ${megabytes(DOWNLOAD_BYTES)} once`;
+ }
+
+ // a call on screen that you're not in: where (which takes you there), who,
+ // and joining it, leaving the one you're in if you are
+ // (by url, so a row stays as it is while who's on it is heard again)
+ const On = (url) => {
+ const room = () => call.rooms().find((room) => room.url === url) ?? { url, name: "", others: [] };
+ return html`
+
+ ${icon("phone", 12)}
+ go(url)}>
+ ${() => room().name || "a doc"}
+
+
+
+ ${() => `${names([...new Set(room().others.map((person) => person.who.name))])} on a call`}
+
+
+ join(url, { video: true })}>
+ ${icon("video", 12)} video on
+
+ join(url, { video: false })}>
+ ${icon("mic", 12)} just voice
+
+
+ <${Show} when=${() => call.get()}>
+
leaves the call in ${() => call.nameOf(call.get()?.room) || "another doc"}
+ />
+
`;
+ };
+
+ return html`
+ <${For} each=${() => call.rooms().map((room) => room.url)}>${On}/>
+ <${Show} when=${() => !call.get() && call.problem()}>
+ ${() => call.problem()}
+ />
+ <${Show} when=${() => !!call.get()}>${In}/>
+ `;
+}
diff --git a/call/src/engine/call.js b/call/src/engine/call.js
new file mode 100644
index 00000000..d43e52b7
--- /dev/null
+++ b/call/src/engine/call.js
@@ -0,0 +1,1161 @@
+// a call, in a room: everyone in it sees and hears everyone else, over
+// webrtc. a room is a doc (or, one day, a key: room.js), and what's needed to
+// connect (offers, answers, candidates) goes over its channel (channel.js),
+// the doc's ephemeral messages. made from lattice's call (its wm/call), made
+// from patchwork's call experiment (its call-session.js)
+//
+// there's one call at a time in a tab, and it carries on as you go from doc
+// to doc, until you hang up. whoever's in one says so every few seconds, and
+// straight away when anyone arrives (their hello), so everyone in the room
+// knows there's a call on, in it or not
+//
+// each pair connects once: the one whose id sorts later offers, with a
+// transceiver each for sound, for pictures and for a screen being shared
+// (SLOTS), and the other answers. turning a camera on or off, changing mic,
+// or sharing your screen swaps the track in those, so nothing's asked again.
+// when a connection fails it's dropped, and made again
+import { createRoot, createSignal } from "solid-js";
+import { createStore } from "solid-js/store";
+import { isValidAutomergeUrl } from "@automerge/automerge-repo/slim";
+import { faceFrom, watchFace } from "./face.js";
+import { lasting } from "./lasting.js";
+import { isKeyRoom, pageRepo, roomUrl } from "./room.js";
+import { openChannel } from "./channel.js";
+import { callToRejoin, forgetCall, keepCall, leavingCall } from "./rejoin.js";
+import { listen } from "./speaking.js";
+import { haveModel, isNoise, loadModel, suppressed } from "./noise.js";
+import { videoFor } from "./sizes.js";
+
+// (lattice's name for it: its calls and these hear each other)
+export const KEY = "lattice-wm:call";
+// lattice's presence says this arriving, and so is answered too
+const LATTICE_HELLO = "lattice-wm:hello?";
+const ICE_SERVERS = [
+ { urls: "stun:stun.l.google.com:19302" },
+ { urls: "stun:stun1.l.google.com:19302" },
+ // over tcp too: some networks (schools) let no udp out at all
+ {
+ urls: ["turn:sync3.automerge.org:3478", "turn:sync3.automerge.org:3478?transport=tcp"],
+ username: "inkandswitch",
+ credential: "insecure",
+ },
+];
+// saying you're in it
+const HERE_EVERY = 3000;
+// and forgotten after this long without
+const GONE_AFTER = 12000;
+// a connection that's only interrupted gets this long to come back
+const INTERRUPTED_FOR = 8000;
+
+// what each of a connection's transceivers carries, in the order they're made
+const SLOTS = ["audio", "video", "screen"];
+const slotOf = (pc, transceiver) => SLOTS[pc.getTransceivers().indexOf(transceiver)];
+// a connection's screen's transceiver, or null (one made before there was one)
+const screenOf = (pc) => {
+ const transceiver = pc.getTransceivers()[SLOTS.indexOf("screen")];
+ return transceiver && !transceiver.stopped ? transceiver : null;
+};
+
+const string = (value) => (value == null ? "" : String(value));
+const isObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
+
+// ── what's remembered, in this browser ──────────────────────────────────
+
+// where faces are: floating over everything, or in the sidebar (call-video,
+// where a frame can show it)
+export const FACES = [
+ ["floating", "floating"],
+ ["sidebar", "in the sidebar"],
+];
+// its own, not lattice's (lattice-wm:call): the same choice can mean a
+// different place in another frame, and lattice may share the origin
+const SETTINGS_KEY = "patchwork-call:settings";
+function readSettings() {
+ try {
+ const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? "{}");
+ return isObject(saved) ? saved : {};
+ } catch {
+ return {};
+ }
+}
+function writeSettings(settings) {
+ try {
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
+ } catch {}
+}
+
+// ── the state, for anything to show ─────────────────────────────────────
+//
+// it's the page's (../lasting.js), as the session is: a dev server's changes
+// are swapped in by importing this again (lattice/dev-swap.js), and the new
+// version shows, and hangs up, the call the old one's in. what an old
+// version's set going (its listeners, its timers) carries on running its own
+// code till the call ends: changes here are for the next one
+
+const {
+ state,
+ setState,
+ settings,
+ setSettings,
+ heard,
+ setHeard,
+ version,
+ bump,
+ asking,
+ setAsking,
+ rooms,
+ setRooms,
+ titles,
+ setTitles,
+ problem,
+ setProblem,
+ videoSpots,
+ setVideoSpots,
+ videoShown,
+ setVideoShown,
+} = lasting("patchwork-call:state", () => createRoot(() => {
+ const saved = readSettings();
+ const [state, setState] = createStore({
+ // the call you're in: {room, name, id, mic, camera, joining, problem}
+ call: null,
+ // everyone else in it, by their id: {id, peer, contact, name, colour,
+ // avatar, mic, camera, connection}
+ peers: {},
+ });
+ const [settings, setSettings] = createStore({
+ faces: saved.faces === "sidebar" ? "sidebar" : "floating",
+ camera: string(saved.camera),
+ mic: string(saved.mic),
+ // what's done about the noise round your voice (noise.js)
+ noise: isNoise(saved.noise) ? saved.noise : "browser",
+ floating: isObject(saved.floating) ? saved.floating : {},
+ });
+ // who's heard to be on a call, in each room heard: {[room]: {[id]: {who, at}}}
+ const [heard, setHeard] = createStore({});
+ // streams change in place: this says they have
+ const [version, setVersion] = createSignal(0);
+ // a call to ask about, because something was pressed: {url, at: {x, y}}
+ const [asking, setAsking] = createSignal(null);
+ // the rooms heard, by id: {onScreen: how many say it's on screen, names:
+ // what they call it, latest last}
+ const [rooms, setRooms] = createStore({});
+ // what each doc room's doc is called, by id, once it's known
+ const [titles, setTitles] = createStore({});
+ // what went wrong outside a call (joining one that can't be reached)
+ const [problem, setProblem] = createSignal(null);
+ // how many places can show the video (call-video), and how many are
+ // showing it now
+ const [videoSpots, setVideoSpots] = createSignal(0);
+ const [videoShown, setVideoShown] = createSignal(0);
+ return {
+ state,
+ setState,
+ settings,
+ setSettings,
+ heard,
+ setHeard,
+ version,
+ bump: () => setVersion((n) => n + 1),
+ asking,
+ setAsking,
+ rooms,
+ setRooms,
+ titles,
+ setTitles,
+ problem,
+ setProblem,
+ videoSpots,
+ setVideoSpots,
+ videoShown,
+ setVideoShown,
+ };
+ }));
+
+function saveSettings() {
+ writeSettings({
+ faces: settings.faces,
+ camera: settings.camera,
+ mic: settings.mic,
+ noise: settings.noise,
+ floating: settings.floating,
+ });
+}
+
+// the session: what isn't for showing. {session} so every version of this
+// has the same one
+const live = lasting("patchwork-call:session", () => ({ session: null }));
+
+// whose face fills the panes (their id, or null), and whether it fills the
+// screen. a lasting of its own, not more of the state's above: a page that
+// swaps in a new version keeps the state it made, with nothing added since
+const view = lasting("patchwork-call:view", () =>
+ createRoot(() => {
+ const [stage, setStage] = createSignal(null);
+ const [full, setFull] = createSignal(false);
+ return { stage, setStage, full, setFull };
+ }),
+);
+
+// ── who you are ─────────────────────────────────────────────────────────
+
+const myContact = () => {
+ const account = globalThis.accountDocHandle ?? globalThis.patchwork?.account;
+ const url = string(account?.doc()?.contactUrl);
+ return isValidAutomergeUrl(url) ? url : "";
+};
+
+async function whoAmI(repo) {
+ const contactUrl = myContact();
+ const who = { contact: contactUrl, name: "anonymous", colour: "", avatar: "" };
+ if (!contactUrl || !repo) return who;
+ try {
+ const doc = (await repo.find(contactUrl)).doc();
+ const registered = doc?.type === "registered";
+ who.name = (registered && string(doc.name)) || "anonymous";
+ who.colour = string(doc?.color);
+ who.avatar = (registered && string(doc.avatarUrl)) || "";
+ } catch {}
+ return who;
+}
+
+// what someone said of themselves
+function whoFrom(value) {
+ if (!isObject(value)) return null;
+ return {
+ contact: isValidAutomergeUrl(string(value.contact)) ? string(value.contact) : "",
+ name: string(value.name).slice(0, 200) || "anonymous",
+ colour: string(value.colour).slice(0, 100),
+ avatar: isValidAutomergeUrl(string(value.avatar)) ? string(value.avatar) : "",
+ };
+}
+
+// ── hearing who's on a call in a room, in it or not ─────────────────────
+
+const listening = new Map(); // room id → how many are hearing it
+let pruning = null;
+
+function prune() {
+ const now = Date.now();
+ for (const [room, people] of Object.entries(heard)) {
+ for (const [id, person] of Object.entries(people ?? {})) {
+ if (now - person.at > GONE_AFTER) setHeard(room, id, undefined);
+ }
+ }
+}
+
+// hear the calls on `channel` (channel.js). returns a way to stop
+function hearCalls(channel) {
+ const url = channel.id;
+ function onMessage(event) {
+ const message = event.message;
+ if (!isObject(message) || !(KEY in message)) return;
+ const from = string(message.from);
+ if (!from || from === live.session?.id) return;
+ if (message[KEY] === "bye") {
+ // (someone never heard here: nothing to forget)
+ if (heard[url]?.[from]) setHeard(url, from, undefined);
+ return;
+ }
+ if (message[KEY] !== "here") return;
+ const who = whoFrom(message.who);
+ if (!who) return;
+ if (!heard[url]) setHeard(url, {});
+ setHeard(url, from, { who, peer: string(event.senderId), mic: !!message.mic, camera: !!message.camera, at: Date.now() });
+ }
+ channel.on(onMessage);
+ pruning ??= setInterval(prune, 2000);
+ listening.set(url, (listening.get(url) ?? 0) + 1);
+ return () => {
+ channel.off(onMessage);
+ const count = listening.get(url) - 1;
+ if (count > 0) listening.set(url, count);
+ else listening.delete(url);
+ if (!listening.size) {
+ clearInterval(pruning);
+ pruning = null;
+ }
+ };
+}
+
+/** who's on a call in the room at `url`, apart from you: [{id, who, peer, mic, camera}] */
+export function onCallIn(url) {
+ return Object.entries(heard[url] ?? {})
+ .filter(([id, person]) => person && id !== state.call?.id)
+ .map(([id, { who, peer, mic, camera }]) => ({ id, who, peer, mic, camera }));
+}
+
+// ── the rooms heard ─────────────────────────────────────────────────────
+//
+// each place that wants a room heard holds it (hearRoom), and lets go: the
+// room's heard while anything holds it. some say it's on screen (a doc
+// that's mounted, a room a frame says is where you are): those are the
+// rooms you're asked about (call.rooms), and the ones rejoined after a
+// reload. some give it a name
+
+const hearing = new Map(); // room id → {holds, stop}
+
+/**
+ * hear the calls in the room `url` names (a doc's url, any version of it;
+ * or a key room, which hears nothing yet) until the function this returns
+ * is called. `onScreen`: it's where you are. `name`: what it's called, while
+ * this holds it
+ */
+export function hearRoom(url, { onScreen = false, name = "" } = {}) {
+ const id = roomUrl(url);
+ if (!id) return () => {};
+ let entry = hearing.get(id);
+ if (!entry) {
+ entry = { holds: 0, stop: null };
+ hearing.set(id, entry);
+ openChannel(id).then((channel) => {
+ if (!channel || hearing.get(id) !== entry) return;
+ entry.stop = hearCalls(channel);
+ // anyone in a call here: say so now, not in a few seconds
+ channel.send({ [KEY]: "hello" });
+ if (!(id in titles)) {
+ channel.title().then((title) => setTitles(id, title), () => {});
+ }
+ });
+ }
+ entry.holds++;
+ name = string(name);
+ setRooms(id, (was) => ({
+ onScreen: (was?.onScreen ?? 0) + (onScreen ? 1 : 0),
+ names: name ? [...(was?.names ?? []), name] : (was?.names ?? []),
+ }));
+ if (onScreen && rooms[id].onScreen === 1) rejoin(id);
+ let held = true;
+ return () => {
+ if (!held) return;
+ held = false;
+ setRooms(id, (was) => {
+ const names = [...was.names];
+ if (name) names.splice(names.lastIndexOf(name), 1);
+ return { onScreen: was.onScreen - (onScreen ? 1 : 0), names };
+ });
+ entry.holds--;
+ if (entry.holds > 0) return;
+ hearing.delete(id);
+ entry.stop?.();
+ setRooms(id, undefined);
+ };
+}
+
+// the page was reloaded in a call in `id`: back in it, as you were
+function rejoin(id) {
+ if (live.session) return;
+ const back = callToRejoin(id);
+ if (!back) return;
+ forgetCall();
+ join(id, { video: back.camera }).then(() => {
+ if (!back.mic) setMic(false);
+ });
+}
+
+/**
+ * ask whether to call (or join the one on) in the room `url` names: `at` is
+ * where the question goes ({x, y}, its top left). only for what's pressed
+ */
+export function ask(url, { at = null } = {}) {
+ const id = roomUrl(url);
+ if (id) setAsking({ url: id, at });
+}
+
+/**
+ * a place that can show the video (call-video) is there, till the function
+ * this returns is called: faces can be "in the sidebar" while one is.
+ * `showing`: it's on screen now (a tab that's open). faces in the sidebar
+ * float meanwhile, wherever nothing's showing them
+ */
+export function holdVideoSpot({ showing = false } = {}) {
+ setVideoSpots((n) => n + 1);
+ if (showing) setVideoShown((n) => n + 1);
+ let held = true;
+ return () => {
+ if (!held) return;
+ held = false;
+ setVideoSpots((n) => n - 1);
+ if (showing) setVideoShown((n) => n - 1);
+ };
+}
+
+// ── media ───────────────────────────────────────────────────────────────
+
+// the mic's processing, said outright rather than left to the browser:
+// echo cancelling and levelling always, its noise suppression only when
+// it's what's chosen (DeepFilterNet wants the mic as it is)
+const constraint = (kind, device, noiseSuppression) => {
+ const picked = device ? { deviceId: { exact: device } } : {};
+ if (kind !== "audio") return device ? picked : true;
+ return { ...picked, echoCancellation: true, autoGainControl: true, noiseSuppression };
+};
+
+async function getTrack(kind, { noiseSuppression = true } = {}) {
+ const device = kind === "audio" ? settings.mic : settings.camera;
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ [kind]: constraint(kind, device, noiseSuppression) });
+ return stream.getTracks()[0] ?? null;
+ } catch (error) {
+ // the one picked isn't there any more: any will do
+ if (device && error?.name === "OverconstrainedError") {
+ try {
+ const any = constraint(kind, "", noiseSuppression);
+ return (await navigator.mediaDevices.getUserMedia({ [kind]: any })).getTracks()[0] ?? null;
+ } catch {}
+ }
+ console.warn(`[call] no ${kind}`, error);
+ return null;
+ }
+}
+
+// your mic, the noise round it dealt with as settings.noise says. with
+// DeepFilterNet not downloaded yet, it's the browser's suppression till it
+// is: it's downloaded meanwhile, and then the mic's taken again (refreshMic)
+async function getAudio() {
+ const mode = settings.noise;
+ if (mode !== "deepfilternet") return getTrack("audio", { noiseSuppression: mode === "browser" });
+ if (!(await haveModel())) {
+ loadModel().then(
+ () => settings.noise === "deepfilternet" && refreshMic(),
+ (error) => console.warn("[call] couldn't get DeepFilterNet", error),
+ );
+ return getTrack("audio", { noiseSuppression: true });
+ }
+ const raw = await getTrack("audio", { noiseSuppression: false });
+ if (!raw) return null;
+ try {
+ return await suppressed(raw, {
+ // this computer can't keep up: the browser's, then
+ onOverload: () => {
+ if (!live.session) return;
+ setState("call", "problem", "DeepFilterNet's too much for this computer: using the browser's");
+ setNoise("browser");
+ },
+ });
+ } catch (error) {
+ console.warn("[call] couldn't start DeepFilterNet", error);
+ raw.stop();
+ if (live.session) setState("call", "problem", "couldn't start DeepFilterNet: using the browser's");
+ return getTrack("audio", { noiseSuppression: true });
+ }
+}
+
+// your mic taken again (a different one, or dealt with differently), in
+// place of the one you had, muted or not as that was
+async function refreshMic() {
+ const now = live.session;
+ if (!now || state.call?.joining) return;
+ // the old one goes first: chrome gives a second track on a mic the first
+ // one's processing, whatever it asks for, so the new one would come with
+ // the browser's noise suppression still on (or still off). there's a
+ // moment's quiet, as it's swapped
+ trackOf("audio")?.stop();
+ const track = await getAudio();
+ if (live.session !== now || !track) return track?.stop();
+ track.enabled = state.call.mic;
+ useTrack("audio", track);
+}
+
+/** what's done about the noise round your voice: "browser", "deepfilternet" or "none" (noise.js) */
+export function setNoise(mode) {
+ if (!isNoise(mode) || settings.noise === mode) return;
+ setSettings("noise", mode);
+ saveSettings();
+ // downloaded now, chosen out of a call, so it's ready for the next
+ if (mode === "deepfilternet" && !live.session) loadModel().catch(() => {});
+ refreshMic();
+}
+
+const trackOf = (kind) => live.session?.local.getTracks().find((track) => track.kind === kind) ?? null;
+
+// put `track` (or none) in as yours of its kind, everywhere it goes
+function useTrack(kind, track) {
+ const old = trackOf(kind);
+ if (old === track) return;
+ if (old) {
+ live.session.local.removeTrack(old);
+ old.stop();
+ }
+ if (track) live.session.local.addTrack(track);
+ if (kind === "video") watchMine(track);
+ if (kind === "audio") listenMine(track);
+ for (const peer of live.session.peers.values()) {
+ for (const transceiver of peer.pc.getTransceivers()) {
+ if (slotOf(peer.pc, transceiver) === kind && !transceiver.stopped) {
+ transceiver.sender.replaceTrack(track).catch((error) => console.warn("[call] couldn't swap a track", error));
+ }
+ }
+ }
+ bump();
+}
+
+// whether you're speaking, from your mic (speaking.js)
+function listenMine(track) {
+ const now = live.session;
+ now.stopLevel?.();
+ now.stopLevel = null;
+ setState("call", "speaking", false);
+ if (!track) return;
+ now.stopLevel = listen(new MediaStream([track]), (speaking) => {
+ if (live.session === now) setState("call", "speaking", speaking);
+ }, "me");
+}
+
+// your face, in your camera's pictures (face.js), while it's on: said to
+// everyone as it moves
+function watchMine(track) {
+ live.session.stopFace?.();
+ live.session.stopFace = null;
+ setState("call", "face", null);
+ if (!track) return;
+ live.session.stopFace = watchFace(new MediaStream([track]), (face) => {
+ if (!live.session) return;
+ setState("call", "face", face);
+ send({ [KEY]: "face", face });
+ });
+}
+
+// ── joining, and hanging up ─────────────────────────────────────────────
+
+/**
+ * join the call in the room `url` names, or start one: `video` says
+ * whether with your camera. one that can't be reached says so
+ * (call.problem), and you're left as you were
+ */
+export async function join(url, { video = false } = {}) {
+ setProblem(null);
+ const roomId = roomUrl(url);
+ if (live.session && live.session.room.id === roomId) return;
+ const room = roomId ? await openChannel(roomId) : null;
+ if (!room) {
+ setProblem(isKeyRoom(roomId) ? "calls over a key aren't available yet" : "couldn't reach that doc");
+ return;
+ }
+ if (live.session?.room.id === roomId) return;
+ hangUp();
+ const repo = pageRepo();
+ const id = crypto.randomUUID();
+ const now = {
+ id,
+ room,
+ local: new MediaStream(),
+ peers: new Map(),
+ pendingIce: new Map(),
+ who: { contact: myContact(), name: "anonymous", colour: "", avatar: "" },
+ timers: [],
+ dead: false,
+ };
+ live.session = now;
+ setState({ call: { room: roomId, name: call.nameOf(roomId), id, mic: true, camera: !!video, joining: true, problem: null }, peers: {} });
+
+ // your name and face, when your contact's found: it's said again then,
+ // if it wasn't in time to say at first
+ const who = whoAmI(repo).then((who) => {
+ if (now.dead) return;
+ now.who = who;
+ bump();
+ sayHere();
+ });
+ const [audio, camera] = await Promise.all([
+ getAudio(),
+ video ? getTrack("video") : null,
+ Promise.race([who, new Promise((resolve) => setTimeout(resolve, 800))]),
+ ]);
+ if (now.dead) {
+ audio?.stop();
+ camera?.stop();
+ return;
+ }
+ if (audio) now.local.addTrack(audio);
+ if (camera) now.local.addTrack(camera);
+ if (camera) watchMine(camera);
+ if (audio) listenMine(audio);
+ const problem = !audio ? (video && !camera ? "no mic or camera" : "no mic") : video && !camera ? "no camera" : null;
+ setState("call", { joining: false, camera: !!camera, mic: !!audio, problem });
+ bump();
+
+ // what's set going is kept with the session, so any version of this can
+ // stop it (see the state, above)
+ now.onMessage = onMessage;
+ room.on(now.onMessage);
+ sayHere();
+ // whoever's been heard on it already: no waiting till they say so again
+ for (const person of onCallIn(roomId)) onHere(person.id, person.peer, person);
+ now.timers.push(setInterval(sayHere, HERE_EVERY));
+ now.timers.push(setInterval(forgetGone, GONE_AFTER / 3));
+ // the page going (a reload, say): you're back in it if it's back soon
+ now.onPageHide = () => hangUp({ rejoin: true });
+ addEventListener("pagehide", now.onPageHide);
+ rememberCall();
+}
+
+/**
+ * leave the call you're in. `rejoin`: it's the page going, so it's to be
+ * joined again if the page comes back soon (rejoin.js)
+ */
+export function hangUp({ rejoin = false } = {}) {
+ const now = live.session;
+ if (!now) return;
+ now.dead = true;
+ live.session = null;
+ if (rejoin === true) leavingCall();
+ else forgetCall();
+ removeEventListener("pagehide", now.onPageHide);
+ now.timers.forEach(clearInterval);
+ now.room.off(now.onMessage);
+ try {
+ now.room.send({ [KEY]: "bye", from: now.id });
+ } catch {}
+ for (const id of [...now.peers.keys()]) drop(id, now);
+ now.stopFace?.();
+ now.stopLevel?.();
+ for (const track of now.local.getTracks()) track.stop();
+ now.screen?.stop();
+ closeStage();
+ setState({ call: null, peers: {} });
+ bump();
+}
+
+// the call you're in, as it is now, kept in the tab for after a reload
+function rememberCall() {
+ const call = state.call;
+ if (!live.session || !call || call.joining) return;
+ keepCall({ room: call.room, camera: call.camera, mic: call.mic });
+}
+
+/** your mic, on or off */
+export function setMic(on) {
+ if (!live.session || state.call?.joining) return;
+ const track = trackOf("audio");
+ if (on && !track) {
+ getAudio().then((track) => {
+ if (!live.session || !track) return setState("call", "problem", "no mic");
+ useTrack("audio", track);
+ setState("call", { mic: true, problem: null });
+ sayHere();
+ rememberCall();
+ });
+ return;
+ }
+ if (track) track.enabled = on;
+ setState("call", "mic", on && !!track);
+ sayHere();
+ rememberCall();
+}
+
+/** your camera, on or off: off, it's let go of (its light goes out) */
+export async function setCamera(on) {
+ if (!live.session || state.call?.joining) return;
+ if (!on) {
+ useTrack("video", null);
+ setState("call", "camera", false);
+ sayHere();
+ rememberCall();
+ return;
+ }
+ const now = live.session;
+ const track = await getTrack("video");
+ if (live.session !== now) return track?.stop();
+ if (!track) return setState("call", "problem", "no camera");
+ useTrack("video", track);
+ setState("call", { camera: true, problem: null });
+ sayHere();
+ rememberCall();
+}
+
+/** which mic or camera ("audio" or "video") to use: a device id, or "" for any */
+export async function setDevice(kind, device) {
+ setSettings(kind === "audio" ? "mic" : "camera", string(device));
+ saveSettings();
+ if (!live.session) return;
+ if (kind === "video" && !state.call?.camera) return;
+ if (kind === "audio") return refreshMic();
+ const now = live.session;
+ const track = await getTrack(kind);
+ if (live.session !== now || !track) return track?.stop();
+ useTrack(kind, track);
+}
+
+/** the mics and cameras there are: {audio: [{id, label}], video: [...]} */
+export async function devices() {
+ const all = await navigator.mediaDevices.enumerateDevices().catch(() => []);
+ const of = (kind) =>
+ all
+ .filter((device) => device.kind === kind && device.deviceId)
+ .map((device, at) => ({ id: device.deviceId, label: device.label || `${kind === "audioinput" ? "mic" : "camera"} ${at + 1}` }));
+ return { audio: of("audioinput"), video: of("videoinput") };
+}
+
+/**
+ * where faces are shown: "floating", or "sidebar" (the video, call-video),
+ * which is only for while something can show it there
+ */
+export function setFaces(faces) {
+ if (faces !== "floating" && faces !== "sidebar") return;
+ if (faces === "sidebar" && !call.canShowVideo()) return;
+ setSettings("faces", faces);
+ saveSettings();
+}
+
+/** where someone's floating face is (`key`: call.floatKey's): fractions of the window */
+export function setFloating(key, at) {
+ setSettings("floating", key, at);
+ saveSettings();
+}
+
+/** someone's floating face (`key`) put away, or (`at`: where) back */
+export function hideFloating(key) {
+ setFloating(key, { ...call.settings.floating[key], hidden: true });
+}
+export function showFloating(key, at) {
+ setFloating(key, { ...call.settings.floating[key], ...at, hidden: false });
+}
+
+// ── reading it ──────────────────────────────────────────────────────────
+
+export const call = {
+ /** the call you're in, or null: {room, name, id, mic, camera, face, joining, problem} */
+ get: () => state.call,
+ /** everyone else in it: [{id, peer, contact, name, colour, avatar, mic, camera, face, connection}] */
+ peers: () => Object.values(state.peers).filter(Boolean),
+ /** someone's pictures and sound (read in an effect: it says when they change) */
+ stream(id) {
+ version();
+ return live.session?.peers.get(id)?.stream ?? null;
+ },
+ /** someone's screen, while they're sharing it (`id`, or "me"), or null */
+ screen(id) {
+ version();
+ const now = live.session;
+ if (!now) return null;
+ if (id === "me") {
+ if (!now.screen) return null;
+ if (now.screenView?.getTracks()[0] !== now.screen) now.screenView = new MediaStream([now.screen]);
+ return now.screenView;
+ }
+ return state.peers[id]?.screen ? (now.peers.get(id)?.screen ?? null) : null;
+ },
+ /** yours */
+ local() {
+ version();
+ return live.session && state.call?.camera ? live.session.local : null;
+ },
+ /** who you are in it */
+ me: () => (live.session ? { ...live.session.who } : null),
+ settings,
+ /** where faces are: "sidebar" while that's chosen and something can show it, else "floating" */
+ mode: () => (settings.faces === "sidebar" && call.canShowVideo() ? "sidebar" : "floating"),
+ /** whether something can show the video (call-video): holdVideoSpot */
+ canShowVideo: () => videoSpots() > 0,
+ /** whether something's showing the video right now */
+ videoShowing: () => videoShown() > 0,
+ /** whether the video's to be shown: you're in a call, with faces in the sidebar */
+ videoWanted: () => !!state.call && call.mode() === "sidebar",
+ /** what went wrong outside a call, or null */
+ problem,
+ /**
+ * the calls on screen you're not in: [{url, name, others}], others as
+ * onCallIn's. the rooms held as on screen (hearRoom), with someone in a call
+ */
+ rooms: () =>
+ Object.entries(rooms)
+ .filter(([url, room]) => room?.onScreen > 0 && url !== state.call?.room)
+ .map(([url]) => ({ url, name: call.nameOf(url), others: onCallIn(url) }))
+ .filter((room) => room.others.length > 0),
+ /** what the room `url` is called: the latest name it's held by, else its doc's title, else "" */
+ nameOf(url) {
+ const id = roomUrl(url);
+ if (!id) return "";
+ const names = rooms[id]?.names;
+ return names?.length ? names[names.length - 1] : (titles[id] ?? "");
+ },
+ /** what someone's floating face is kept by: who they are, so it's theirs next call too */
+ floatKey: (id) => (id === "me" ? "me" : state.peers[id]?.contact || id),
+ asking,
+ stopAsking: () => setAsking(null),
+ /** whose face fills the panes (one still in the call), or null */
+ stage: () => {
+ const id = view.stage();
+ return id && state.peers[id] ? id : null;
+ },
+ /** whether it fills the screen */
+ full: () => view.full() && !!call.stage(),
+ /**
+ * the one in your call who's `person` from presence ({peer, contact}): by
+ * the tab they're in, or else by who they are
+ */
+ find(person) {
+ const peers = call.peers();
+ return (
+ peers.find((peer) => person.peer && peer.peer === person.peer) ??
+ peers.find((peer) => person.contact && peer.contact === person.contact) ??
+ null
+ );
+ },
+};
+
+// ── sharing your screen ─────────────────────────────────────────────────
+
+/** whether there's a screen to share, here */
+export const canShare = () => !!navigator.mediaDevices?.getDisplayMedia;
+
+/** share a screen (the browser asks which), to everyone in the call */
+export async function startScreen() {
+ const now = live.session;
+ if (!now || now.screen || state.call?.joining) return;
+ let stream;
+ try {
+ stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 15 } }, audio: false });
+ } catch {
+ // not picked
+ return;
+ }
+ const track = stream.getVideoTracks()[0];
+ if (live.session !== now || !track) return stream.getTracks().forEach((track) => track.stop());
+ // it's to be read: kept sharp rather than smooth
+ track.contentHint = "detail";
+ // the browser's own "stop sharing"
+ track.addEventListener("ended", () => now.screen === track && stopScreen());
+ now.screen = track;
+ for (const [id, peer] of now.peers) {
+ const transceiver = screenOf(peer.pc);
+ if (transceiver) transceiver.sender.replaceTrack(track).catch((error) => console.warn("[call] couldn't share", error));
+ // a connection from before there was room for a screen: made again
+ else lost(id);
+ }
+ setState("call", "screen", true);
+ bump();
+ sayHere();
+}
+
+/** stop sharing your screen */
+export function stopScreen() {
+ const now = live.session;
+ const track = now?.screen;
+ if (!track) return;
+ now.screen = null;
+ for (const peer of now.peers.values()) screenOf(peer.pc)?.sender.replaceTrack(null).catch(() => {});
+ track.stop();
+ setState("call", "screen", false);
+ bump();
+ sayHere();
+}
+
+// ── who's big ───────────────────────────────────────────────────────────
+
+/** fill the panes with `id`'s face, or, theirs already, put the panes back */
+export function toggleStage(id) {
+ const now = view.stage() === id ? null : id;
+ view.setStage(now);
+ if (!now) view.setFull(false);
+}
+
+/** the face that's big, filling the screen or not */
+export function setFull(full) {
+ view.setFull(!!full && !!view.stage());
+}
+
+/** nobody big */
+export function closeStage() {
+ view.setStage(null);
+ view.setFull(false);
+}
+
+// how much of your camera's sent, for how many are in the call (sizes.js):
+// past a handful, smaller and slower, so it goes to all of them
+function tuneVideo() {
+ const now = live.session;
+ if (!now) return;
+ const wanted = videoFor(now.peers.size + 1);
+ for (const peer of now.peers.values()) {
+ for (const transceiver of peer.pc.getTransceivers()) {
+ // (a screen's sent as it is: it's to be read)
+ if (slotOf(peer.pc, transceiver) !== "video" || transceiver.stopped) continue;
+ const sender = transceiver.sender;
+ const parameters = sender.getParameters();
+ // none till it's been negotiated: it's tuned when it's connected
+ const encoding = parameters.encodings?.[0];
+ if (!encoding) continue;
+ for (const key of ["maxBitrate", "scaleResolutionDownBy", "maxFramerate"]) {
+ if (wanted) encoding[key] = wanted[key];
+ else delete encoding[key];
+ }
+ sender.setParameters(parameters).catch((error) => console.warn("[call] couldn't tune video", error));
+ }
+ }
+}
+
+// ── talking ─────────────────────────────────────────────────────────────
+
+function send(message) {
+ if (!live.session || live.session.dead) return;
+ try {
+ live.session.room.send({ ...message, from: live.session.id });
+ } catch (error) {
+ console.warn("[call] couldn't say", message[KEY], error);
+ }
+}
+
+// who you are, and your mic and camera: in what you say
+const about = () => ({
+ who: live.session.who,
+ mic: !!state.call?.mic,
+ camera: !!state.call?.camera,
+ screen: !!live.session?.screen,
+ face: state.call?.camera ? (state.call.face ?? null) : null,
+});
+
+function sayHere() {
+ if (!live.session || state.call?.joining) return;
+ send({ [KEY]: "here", ...about() });
+}
+
+function forgetGone() {
+ const now = Date.now();
+ for (const [id, peer] of live.session?.peers ?? []) {
+ if (now - peer.heard > GONE_AFTER) drop(id);
+ }
+}
+
+function onMessage(event) {
+ const message = event.message;
+ if (!live.session || !isObject(message)) return;
+ // someone's arriving: say you're here, so they know there's a call on
+ if (message[LATTICE_HELLO] || message[KEY] === "hello") {
+ setTimeout(sayHere, Math.random() * 200);
+ return;
+ }
+ if (!(KEY in message)) return;
+ // the tab they're in, as presence knows it
+ const peer = string(event.senderId);
+ const from = string(message.from);
+ if (!from || from === live.session.id) return;
+ if (message.to && message.to !== live.session.id) return;
+ switch (message[KEY]) {
+ case "here":
+ return onHere(from, peer, message);
+ case "bye":
+ return drop(from);
+ case "face":
+ return showPeer(from, { face: faceFrom(message.face) });
+ case "again":
+ // they lost us: start again
+ drop(from);
+ return onHere(from, peer, message);
+ case "offer":
+ onOffer(from, message);
+ // what they said of themselves with it
+ return onHere(from, peer, message);
+ case "answer":
+ return onAnswer(from, message);
+ case "ice":
+ return onIce(from, message);
+ }
+}
+
+// ── connections ─────────────────────────────────────────────────────────
+
+const offers = (theirs) => live.session.id > theirs;
+
+function showPeer(id, fields) {
+ if (!live.session?.peers.has(id)) return;
+ setState("peers", id, (had) => ({ ...(had ?? { id }), ...fields }));
+}
+
+function onHere(from, tab, message) {
+ const who = whoFrom(message.who);
+ if (!who) return;
+ let peer = live.session.peers.get(from);
+ if (!peer && offers(from)) peer = connect(from, crypto.randomUUID());
+ const fields = { ...who, peer: tab, mic: !!message.mic, camera: !!message.camera, face: faceFrom(message.face), screen: !!message.screen };
+ if (peer) {
+ peer.heard = Date.now();
+ const was = !!state.peers[from]?.screen;
+ showPeer(from, fields);
+ if (fields.screen !== was) onSharing(from, fields.screen);
+ } else {
+ // they'll offer: until they do, they're on the call, connecting
+ live.session.waiting ??= new Map();
+ live.session.waiting.set(from, fields);
+ }
+}
+
+// someone's started sharing their screen: it fills the panes, unless
+// someone's there already. stopped, it's put back, if that's what put it there
+function onSharing(id, sharing) {
+ const now = live.session;
+ if (sharing && !view.stage()) {
+ view.setStage(id);
+ now.shown = id;
+ } else if (!sharing && now.shown === id) {
+ now.shown = null;
+ if (view.stage() === id) closeStage();
+ }
+ bump();
+}
+
+// a connection to `id`, made fresh: `conn` is which one (the offerer's
+// pick), so what's said about an old one is left alone
+function connect(id, conn) {
+ drop(id);
+ const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
+ const stream = new MediaStream();
+ const audio = new Audio();
+ audio.autoplay = true;
+ audio.srcObject = stream;
+ const peer = { id, conn, pc, stream, audio, heard: Date.now(), interrupted: null };
+ live.session.peers.set(id, peer);
+ setState("peers", id, { id, connection: "connecting", ...(live.session.waiting?.get(id) ?? {}) });
+ live.session.waiting?.delete(id);
+
+ pc.ontrack = (event) => {
+ // their screen's, when they share it: a stream of its own
+ if (slotOf(pc, event.transceiver) === "screen") {
+ peer.screen ??= new MediaStream();
+ if (!peer.screen.getTrackById(event.track.id)) peer.screen.addTrack(event.track);
+ event.track.onunmute = bump;
+ bump();
+ return;
+ }
+ if (!stream.getTrackById(event.track.id)) stream.addTrack(event.track);
+ // who's speaking: theirs, once there's sound to hear
+ if (event.track.kind === "audio" && !peer.stopLevel) {
+ peer.stopLevel = listen(stream, (speaking) =>
+ showPeer(id, speaking ? { speaking, spoke: Date.now() } : { speaking }),
+ id,
+ );
+ }
+ event.track.onunmute = bump;
+ audio.play().catch(() => {});
+ bump();
+ };
+ pc.onicecandidate = (event) => {
+ if (event.candidate) send({ [KEY]: "ice", to: id, conn, candidate: event.candidate.toJSON() });
+ };
+ pc.onconnectionstatechange = () => {
+ const now = pc.connectionState;
+ clearTimeout(peer.interrupted);
+ if (now === "connected") {
+ showPeer(id, { connection: "connected" });
+ tuneVideo();
+ }
+ else if (now === "disconnected") {
+ showPeer(id, { connection: "interrupted" });
+ peer.interrupted = setTimeout(() => pc.connectionState === "disconnected" && lost(id), INTERRUPTED_FOR);
+ } else if (now === "failed") lost(id);
+ };
+
+ if (offers(id)) {
+ for (const slot of SLOTS) {
+ const transceiver = pc.addTransceiver(slot === "audio" ? "audio" : "video", { direction: "sendrecv" });
+ const track = slot === "screen" ? live.session.screen : trackOf(slot);
+ if (track) transceiver.sender.replaceTrack(track);
+ }
+ pc.onnegotiationneeded = async () => {
+ try {
+ if (pc.signalingState !== "stable") return;
+ await pc.setLocalDescription();
+ send({ [KEY]: "offer", to: id, conn, sdp: pc.localDescription.toJSON(), ...about() });
+ } catch (error) {
+ console.warn("[call] couldn't offer", error);
+ }
+ };
+ }
+ return peer;
+}
+
+// a connection that's failed: let it go, and start again (the one who
+// offers does that on hearing them next; the other asks them to)
+function lost(id) {
+ const peer = live.session?.peers.get(id);
+ if (!peer) return;
+ const offering = offers(id);
+ drop(id);
+ if (!offering) send({ [KEY]: "again", to: id, ...about() });
+}
+
+function drop(id, from = live.session) {
+ const peer = from?.peers.get(id);
+ from?.pendingIce.delete(id);
+ if (!peer) {
+ if (from === live.session) setState("peers", id, undefined);
+ return;
+ }
+ from.peers.delete(id);
+ clearTimeout(peer.interrupted);
+ try {
+ peer.pc.close();
+ } catch {}
+ peer.audio.srcObject = null;
+ peer.stopLevel?.();
+ if (from === live.session) {
+ setState("peers", id, undefined);
+ if (view.stage() === id) closeStage();
+ bump();
+ tuneVideo();
+ }
+}
+
+async function onOffer(from, { conn, sdp }) {
+ if (offers(from) || !isObject(sdp) || sdp.type !== "offer" || typeof sdp.sdp !== "string") return;
+ let peer = live.session.peers.get(from);
+ if (!peer || peer.conn !== conn) peer = connect(from, string(conn));
+ const { pc } = peer;
+ try {
+ await pc.setRemoteDescription(sdp);
+ for (const transceiver of pc.getTransceivers()) {
+ const kind = transceiver.receiver.track?.kind;
+ if (!kind) continue;
+ transceiver.direction = "sendrecv";
+ const slot = slotOf(pc, transceiver) ?? kind;
+ await transceiver.sender.replaceTrack(slot === "screen" ? (live.session.screen ?? null) : trackOf(kind));
+ }
+ await pc.setLocalDescription();
+ send({ [KEY]: "answer", to: from, conn, sdp: pc.localDescription.toJSON() });
+ await addPendingIce(peer);
+ } catch (error) {
+ console.warn("[call] couldn't answer", error);
+ }
+}
+
+async function onAnswer(from, { conn, sdp }) {
+ const peer = live.session.peers.get(from);
+ if (!peer || peer.conn !== conn || !isObject(sdp) || sdp.type !== "answer") return;
+ if (peer.pc.signalingState !== "have-local-offer") return;
+ try {
+ await peer.pc.setRemoteDescription(sdp);
+ await addPendingIce(peer);
+ } catch (error) {
+ console.warn("[call] couldn't take their answer", error);
+ }
+}
+
+// candidates can come before what they're for: kept till it's in
+async function onIce(from, { conn, candidate }) {
+ if (!isObject(candidate)) return;
+ const peer = live.session.peers.get(from);
+ if (!peer || peer.conn !== conn || !peer.pc.remoteDescription) {
+ const pending = live.session.pendingIce.get(from) ?? [];
+ pending.push({ conn, candidate });
+ live.session.pendingIce.set(from, pending.slice(-50));
+ return;
+ }
+ try {
+ await peer.pc.addIceCandidate(candidate);
+ } catch (error) {
+ console.warn("[call] couldn't take a candidate", error);
+ }
+}
+
+async function addPendingIce(peer) {
+ const pending = live.session?.pendingIce.get(peer.id) ?? [];
+ live.session?.pendingIce.set(
+ peer.id,
+ pending.filter((ice) => ice.conn !== peer.conn),
+ );
+ for (const { candidate } of pending.filter((ice) => ice.conn === peer.conn)) {
+ try {
+ await peer.pc.addIceCandidate(candidate);
+ } catch {}
+ }
+}
diff --git a/call/src/engine/channel.js b/call/src/engine/channel.js
new file mode 100644
index 00000000..add7b56b
--- /dev/null
+++ b/call/src/engine/channel.js
@@ -0,0 +1,52 @@
+// what a call's said over, in a room: a doc's ephemeral messages. a key's
+// would be subduction's ephemeral topics, which the repo can't hand us yet
+// (it routes every inbound ephemeral by the doc id inside it), so a key
+// room has no channel, for now
+import { isKeyRoom, pageRepo, titleOf } from "./room.js";
+
+const found = new Map(); // room id → Promise
+
+/**
+ * the channel for room `id`: {id, on(listener), off(listener), send(message),
+ * title()}, listeners hearing {message, senderId}, title() what the room's
+ * called (a promise). null when there isn't one
+ */
+export function openChannel(id) {
+ if (isKeyRoom(id)) return Promise.resolve(null);
+ const repo = pageRepo();
+ if (!repo) return Promise.resolve(null);
+ if (!found.has(id)) {
+ const finding = Promise.resolve()
+ .then(() => repo.find(id))
+ .then((handle) => docChannel(handle, id), (error) => {
+ console.warn(`[call] couldn't find ${id}`, error);
+ found.delete(id);
+ return null;
+ });
+ found.set(id, finding);
+ }
+ return found.get(id);
+}
+
+function docChannel(handle, id) {
+ const wrapped = new Map();
+ return {
+ id,
+ on(listener) {
+ if (wrapped.has(listener)) return;
+ const wrapper = (event) => listener({ message: event.message, senderId: String(event.senderId) });
+ wrapped.set(listener, wrapper);
+ handle.on("ephemeral-message", wrapper);
+ },
+ off(listener) {
+ const wrapper = wrapped.get(listener);
+ if (!wrapper) return;
+ wrapped.delete(listener);
+ handle.off("ephemeral-message", wrapper);
+ },
+ send(message) {
+ handle.broadcast(message);
+ },
+ title: () => titleOf(handle),
+ };
+}
diff --git a/call/src/engine/face.js b/call/src/engine/face.js
new file mode 100644
index 00000000..8106ced9
--- /dev/null
+++ b/call/src/engine/face.js
@@ -0,0 +1,138 @@
+// where your face is in your camera's picture, so faces can be shown centred
+// on it. it's found in your own camera, four times a second while it's
+// on, and goes to everyone else in the call with the rest of what you say
+// (call.js), so nobody finds anyone else's.
+//
+// the browser's own FaceDetector, where there is one (shape detection: only
+// behind a flag, mostly); else google's mediapipe face detector, its short
+// range model, fetched the first time it's wanted
+const MEDIAPIPE = "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14";
+const MODEL =
+ "https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite";
+const EVERY = 250;
+// how far it has to move (a fraction of the picture) to be said again: a
+// face held still holds still
+const MOVED = 0.01;
+// how much of the way to where it's just been found each look goes: the
+// rest's where it was, so a jittery find comes out steady
+const EASE = 0.4;
+
+const clamp01 = (n) => Math.max(0, Math.min(1, n));
+
+// {find(video, now)}: the biggest face in `video`, {x, y, width, height} in its
+// pixels, or null. null if neither can be had
+let detector = null;
+function load() {
+ detector ??= (async () => {
+ if (typeof globalThis.FaceDetector === "function") {
+ try {
+ const native = new globalThis.FaceDetector({ fastMode: true, maxDetectedFaces: 1 });
+ return {
+ async find(video) {
+ const [face] = await native.detect(video);
+ return face?.boundingBox ?? null;
+ },
+ };
+ } catch {}
+ }
+ const vision = await import(`${MEDIAPIPE}/vision_bundle.mjs`);
+ const files = await vision.FilesetResolver.forVisionTasks(`${MEDIAPIPE}/wasm`);
+ const found = await vision.FaceDetector.createFromOptions(files, {
+ baseOptions: { modelAssetPath: MODEL },
+ runningMode: "VIDEO",
+ minDetectionConfidence: 0.5,
+ });
+ return {
+ async find(video, now) {
+ const boxes = found
+ .detectForVideo(video, now)
+ .detections.map((detection) => detection.boundingBox)
+ .filter(Boolean);
+ const biggest = boxes.sort((a, b) => b.width * b.height - a.width * a.height)[0];
+ return biggest ? { x: biggest.originX, y: biggest.originY, width: biggest.width, height: biggest.height } : null;
+ },
+ };
+ })().catch((error) => {
+ console.warn("[call] can't find faces", error);
+ return null;
+ });
+ return detector;
+}
+
+/**
+ * find the face in `stream`'s pictures till stopped. `onFace` hears where it
+ * is, as fractions of the picture ({x, y}: its middle, and size: its height),
+ * when that moves. returns a way to stop
+ */
+export function watchFace(stream, onFace) {
+ let stopped = false;
+ let timer = null;
+ let said = null;
+ const video = document.createElement("video");
+ video.muted = true;
+ video.playsInline = true;
+ video.srcObject = stream;
+ video.play().catch(() => {});
+
+ async function look() {
+ timer = null;
+ if (stopped) return;
+ const found = await load();
+ if (!found || stopped) return;
+ if (video.readyState >= 2 && video.videoWidth) {
+ try {
+ const box = await found.find(video, performance.now());
+ if (box && !stopped) {
+ const face = {
+ x: clamp01((box.x + box.width / 2) / video.videoWidth),
+ y: clamp01((box.y + box.height / 2) / video.videoHeight),
+ size: clamp01(box.height / video.videoHeight),
+ };
+ // eased (and everyone's picture glides there: styles.css)
+ const toward = (from, to) => from + (to - from) * EASE;
+ const next = said
+ ? { x: toward(said.x, face.x), y: toward(said.y, face.y), size: toward(said.size, face.size) }
+ : face;
+ if (!said || Math.abs(next.x - said.x) > MOVED || Math.abs(next.y - said.y) > MOVED) {
+ said = next;
+ onFace({ x: round(next.x), y: round(next.y), size: round(next.size) });
+ }
+ }
+ } catch {}
+ }
+ if (!stopped) timer = setTimeout(look, EVERY);
+ }
+ look();
+
+ return () => {
+ stopped = true;
+ clearTimeout(timer);
+ video.srcObject = null;
+ };
+}
+
+const round = (n) => Math.round(n * 1000) / 1000;
+
+/** a face from what someone said, or null */
+export function faceFrom(value) {
+ if (!value || typeof value !== "object") return null;
+ const { x, y, size } = value;
+ if (![x, y, size].every((n) => typeof n === "number" && n >= 0 && n <= 1)) return null;
+ return { x, y, size };
+}
+
+/**
+ * show `video` (object-fit: cover) in its box with `face` ({x, y}, fractions
+ * of the picture) as near its middle as the picture allows
+ */
+export function centreOn(video, face) {
+ const { videoWidth: vw, videoHeight: vh, clientWidth: bw, clientHeight: bh } = video;
+ if (!face || !vw || !vh || !bw || !bh) {
+ video.style.objectPosition = "";
+ return;
+ }
+ const scale = Math.max(bw / vw, bh / vh);
+ const along = (fraction, box, picture) =>
+ Math.abs(picture - box) < 0.5 ? 0.5 : clamp01((box / 2 - fraction * picture) / (box - picture));
+ video.style.objectPosition = `${along(face.x, bw, vw * scale) * 100}% ${along(face.y, bh, vh * scale) * 100}%`;
+}
diff --git a/call/src/engine/kept.js b/call/src/engine/kept.js
new file mode 100644
index 00000000..5878dc66
--- /dev/null
+++ b/call/src/engine/kept.js
@@ -0,0 +1,31 @@
+// what's kept in this tab (its sessionStorage, as json): still there after a
+// reload, and after a dev server's changes are swapped in (lasting.js), and
+// gone with the tab. nothing's kept, and the fallback's what comes back,
+// wherever storage can't be had
+
+function tabStorage() {
+ try {
+ return globalThis.sessionStorage ?? null;
+ } catch {
+ return null;
+ }
+}
+
+/** what's kept as `key`, or `fallback` */
+export function kept(key, fallback = null, storage = tabStorage()) {
+ try {
+ const value = storage?.getItem(key);
+ return value == null ? fallback : JSON.parse(value);
+ } catch {
+ return fallback;
+ }
+}
+
+/** keep `value` as `key` (null or undefined: keep nothing) */
+export function keep(key, value, storage = tabStorage()) {
+ try {
+ if (value == null) storage?.removeItem(key);
+ else storage?.setItem(key, JSON.stringify(value));
+ } catch {}
+}
+
diff --git a/call/src/engine/lasting.js b/call/src/engine/lasting.js
new file mode 100644
index 00000000..8dee57c2
--- /dev/null
+++ b/call/src/engine/lasting.js
@@ -0,0 +1,15 @@
+// what has to outlast the module it's made in. a dev server's changes are
+// swapped in where you are (as lattice's dev-swap.js does): every module's imported
+// again, from a new url, so each starts with its variables empty, while the
+// page carries on, and a call in it. what's made here is made once a page:
+// every version of a module that asks for `key` gets the same one back. for
+// what's live (a call's connections, its streams), which can't be kept in
+// storage; what can be, kept.js keeps, and that outlasts a reload too
+const ALL = Symbol.for("patchwork-call:lasting");
+
+/** the page's one `key`, made by `make` the first time it's asked for */
+export function lasting(key, make) {
+ const all = (globalThis[ALL] ??= new Map());
+ if (!all.has(key)) all.set(key, make());
+ return all.get(key);
+}
diff --git a/call/src/engine/noise.js b/call/src/engine/noise.js
new file mode 100644
index 00000000..9467804b
--- /dev/null
+++ b/call/src/engine/noise.js
@@ -0,0 +1,210 @@
+// what's done about the noise round your voice, before it goes to the call:
+// browser the browser's own noise suppression (webrtc's): good on
+// a steady hum (a fan), not on keys or a dog
+// deepfilternet DeepFilterNet3, a small neural net, run here in an
+// AudioWorklet (../vendor/deepfilternet): takes out typing
+// and chatter too, and keeps the whole voice. its wasm and
+// model (23MB) are downloaded the first time it's wanted,
+// checked, and kept in indexeddb (not the cache storage:
+// the service worker clears what isn't its own there)
+// none nothing: the mic as it is (echo cancelling still)
+import { createRoot, createSignal } from "solid-js";
+import { lasting } from "./lasting.js";
+import { createDeepFilterNetAudioWorklet, DEEPFILTERNET_SAMPLE_RATE } from "../vendor/deepfilternet/deepfilternet.js";
+
+export const NOISE = [
+ ["browser", "the browser's"],
+ ["deepfilternet", "DeepFilterNet"],
+ ["none", "none"],
+];
+export const isNoise = (value) => NOISE.some(([id]) => id === value);
+
+// where the wasm and model come from, pinned, and what they must hash to
+// (sha-256, from the npm tarball): anything else is refused
+const VERSION = "0.2.0";
+const FROM = `https://cdn.jsdelivr.net/npm/@workadventure/noise-suppression@${VERSION}/dist/assets/deepfilternet/`;
+const FILES = {
+ wasm: {
+ name: "df_bg.wasm",
+ type: "application/wasm",
+ size: 14643565,
+ sha256: "8ebf2b217b80fc128505b993c8d7d7d892df5a845ab7a2516fed74b76e7fe708",
+ },
+ model: {
+ name: "DeepFilterNet3_onnx.tar.gz",
+ type: "application/gzip",
+ size: 7983136,
+ sha256: "c94d91f70911001c946e0fabb4aa9adc37045f45a03b56008cb0c8244cb63616",
+ },
+};
+export const DOWNLOAD_BYTES = FILES.wasm.size + FILES.model.size;
+
+// ── keeping them: indexeddb, by name at its hash ────────────────────────
+
+const DB = "lattice-wm:deepfilternet";
+const STORE = "files";
+
+function openDb() {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(DB, 1);
+ request.onupgradeneeded = () => request.result.createObjectStore(STORE);
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error);
+ });
+}
+
+async function kept(key) {
+ try {
+ const db = await openDb();
+ return await new Promise((resolve, reject) => {
+ const request = db.transaction(STORE).objectStore(STORE).get(key);
+ request.onsuccess = () => resolve(request.result ?? null);
+ request.onerror = () => reject(request.error);
+ }).finally(() => db.close());
+ } catch {
+ return null;
+ }
+}
+
+async function keep(key, blob) {
+ try {
+ const db = await openDb();
+ await new Promise((resolve, reject) => {
+ const transaction = db.transaction(STORE, "readwrite");
+ transaction.objectStore(STORE).put(blob, key);
+ transaction.oncomplete = resolve;
+ transaction.onerror = () => reject(transaction.error);
+ }).finally(() => db.close());
+ } catch (error) {
+ // not kept (private browsing, say): it's downloaded again next time
+ console.warn("[call] couldn't keep DeepFilterNet", error);
+ }
+}
+
+const keyOf = (file) => `${file.name}@${file.sha256}`;
+
+async function hex(bytes) {
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
+ return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");
+}
+
+// a file, from indexeddb, or downloaded (telling `got` how far it's got)
+// and checked and kept
+async function file(spec, got) {
+ const had = await kept(keyOf(spec));
+ if (had) {
+ got(spec.size);
+ return had;
+ }
+ const response = await fetch(FROM + spec.name);
+ if (!response.ok || !response.body) throw new Error(`couldn't download ${spec.name} (${response.status})`);
+ const chunks = [];
+ let length = 0;
+ const reader = response.body.getReader();
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ chunks.push(value);
+ length += value.length;
+ got(length);
+ }
+ const bytes = new Uint8Array(length);
+ let at = 0;
+ for (const chunk of chunks) {
+ bytes.set(chunk, at);
+ at += chunk.length;
+ }
+ if ((await hex(bytes)) !== spec.sha256) throw new Error(`${spec.name} isn't the one it should be: not using it`);
+ const blob = new Blob([bytes], { type: spec.type });
+ await keep(keyOf(spec), blob);
+ return blob;
+}
+
+// ── getting them, once a page ───────────────────────────────────────────
+
+// how it's going, to show: {state: "none" | "loading" | "ready" | "failed",
+// got, total, problem}
+const status = lasting("patchwork-call:noise:status", () =>
+ createRoot(() => createSignal({ state: "none", got: 0, total: DOWNLOAD_BYTES, problem: null })),
+);
+export const download = status[0];
+const setDownload = status[1];
+
+const loading = lasting("patchwork-call:noise:loading", () => ({ promise: null, urls: null }));
+
+/** whether the wasm and model are here already (no download needed) */
+export async function haveModel() {
+ if (loading.urls) return true;
+ return !!(await kept(keyOf(FILES.wasm))) && !!(await kept(keyOf(FILES.model)));
+}
+
+// kept from before (another visit): it's here, not a download, to show
+haveModel().then((have) => {
+ if (have && download().state === "none") {
+ setDownload({ state: "ready", got: DOWNLOAD_BYTES, total: DOWNLOAD_BYTES, problem: null });
+ }
+});
+
+/** the wasm's and model's urls (object urls), downloading them the first time */
+export function loadModel() {
+ let active = true;
+ loading.promise ??= (async () => {
+ const got = { wasm: 0, model: 0 };
+ const tell = () => {
+ if (active) setDownload({ state: "loading", got: got.wasm + got.model, total: DOWNLOAD_BYTES, problem: null });
+ };
+ tell();
+ const [wasm, model] = await Promise.all(
+ ["wasm", "model"].map((which) =>
+ file(FILES[which], (bytes) => {
+ got[which] = bytes;
+ tell();
+ }),
+ ),
+ );
+ loading.urls = { wasmUrl: URL.createObjectURL(wasm), modelUrl: URL.createObjectURL(model) };
+ setDownload({ state: "ready", got: DOWNLOAD_BYTES, total: DOWNLOAD_BYTES, problem: null });
+ return loading.urls;
+ })().catch((error) => {
+ active = false;
+ loading.promise = null;
+ setDownload({ state: "failed", got: 0, total: DOWNLOAD_BYTES, problem: String(error?.message ?? error) });
+ throw error;
+ });
+ return loading.promise;
+}
+
+// ── using it ────────────────────────────────────────────────────────────
+
+/**
+ * `raw` (a mic track, taken with the browser's noise suppression off), with
+ * DeepFilterNet's taken out of it: a new track. stopping it stops `raw` too,
+ * and lets go of everything in between. `onOverload` is told if this
+ * computer can't keep up with it
+ */
+export async function suppressed(raw, { onOverload } = {}) {
+ const { wasmUrl, modelUrl } = await loadModel();
+ const context = new AudioContext({ sampleRate: DEEPFILTERNET_SAMPLE_RATE });
+ try {
+ const source = context.createMediaStreamSource(new MediaStream([raw]));
+ const destination = context.createMediaStreamDestination();
+ const worklet = await createDeepFilterNetAudioWorklet(context, { wasmUrl, modelUrl, onOverload });
+ source.connect(worklet.node).connect(destination);
+ await worklet.ready;
+ await context.resume();
+ const [track] = destination.stream.getAudioTracks();
+ if (!track) throw new Error("DeepFilterNet made no track");
+ const stop = track.stop.bind(track);
+ track.stop = () => {
+ stop();
+ worklet.dispose();
+ source.disconnect();
+ raw.stop();
+ context.close().catch(() => {});
+ };
+ return track;
+ } catch (error) {
+ context.close().catch(() => {});
+ throw error;
+ }
+}
diff --git a/call/src/engine/rejoin.js b/call/src/engine/rejoin.js
new file mode 100644
index 00000000..9a944325
--- /dev/null
+++ b/call/src/engine/rejoin.js
@@ -0,0 +1,36 @@
+// the call you're in, kept in the tab (kept.js), so a reload puts you back
+// in it. it's kept while you're in it, and when the page goes it's stamped
+// with when: a page that comes back soon after, to the same room, joins
+// again, with your camera and mic as they were. hanging up forgets it, and
+// so does too long (reopening a closed tab tomorrow isn't arriving back)
+import { keep, kept } from "./kept.js";
+
+export const REJOIN_KEY = "patchwork-call:rejoin";
+export const REJOIN_WITHIN = 2 * 60_000;
+
+const isObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
+
+/** you're in the call in `room`, with your camera and mic so */
+export function keepCall({ room, camera, mic }, { storage } = {}) {
+ keep(REJOIN_KEY, { room: String(room), camera: !!camera, mic: !!mic, at: null }, storage);
+}
+
+/** the page is going (a reload, say), while you're in it */
+export function leavingCall({ at = Date.now(), storage } = {}) {
+ const call = kept(REJOIN_KEY, null, storage);
+ if (isObject(call)) keep(REJOIN_KEY, { ...call, at }, storage);
+}
+
+/** you've hung up */
+export function forgetCall({ storage } = {}) {
+ keep(REJOIN_KEY, null, storage);
+}
+
+/** the call in `room` to join again ({camera, mic}), if there's one */
+export function callToRejoin(room, { now = Date.now(), storage } = {}) {
+ const call = kept(REJOIN_KEY, null, storage);
+ if (!isObject(call) || call.room !== room || typeof call.at !== "number") return null;
+ const ago = now - call.at;
+ if (ago < 0 || ago > REJOIN_WITHIN) return null;
+ return { camera: !!call.camera, mic: !!call.mic };
+}
diff --git a/call/src/engine/room.js b/call/src/engine/room.js
new file mode 100644
index 00000000..107b9832
--- /dev/null
+++ b/call/src/engine/room.js
@@ -0,0 +1,48 @@
+// a room is where a call is: a doc (its url, bare: no heads, no path, so
+// every draft and version of it is the same room) or a key (32 bytes, as
+// hex: for meeting where there's no doc, which isn't built yet, see
+// channel.js)
+import { isValidAutomergeUrl } from "@automerge/automerge-repo/slim";
+
+const KEY = /^key:([0-9a-f]{64})$/i;
+
+/** the room `url` names (a doc url, or "key:"), as a room id, or null */
+export function roomUrl(url) {
+ if (typeof url !== "string") return null;
+ const key = KEY.exec(url);
+ if (key) return `key:${key[1].toLowerCase()}`;
+ if (!url.startsWith("automerge:")) return null;
+ const bare = url.split(/[#/]/, 1)[0];
+ return isValidAutomergeUrl(bare) ? bare : null;
+}
+
+/** the room for 32 bytes of key */
+export function keyRoom(bytes) {
+ if (!(bytes instanceof Uint8Array) || bytes.length !== 32) {
+ throw new RangeError("a key room is 32 bytes");
+ }
+ return `key:${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
+}
+
+export const isKeyRoom = (id) => typeof id === "string" && KEY.test(id);
+
+/** the page's repo, wherever this host put it (not a draft overlay's) */
+export function pageRepo() {
+ return globalThis.repo ?? globalThis.patchwork?.repo ?? null;
+}
+
+/** what a doc's called, by its datatype, or its own title, or nothing */
+export async function titleOf(handle) {
+ const doc = handle?.doc?.();
+ if (!doc) return "";
+ const type = doc["@patchwork"]?.type;
+ if (type) {
+ try {
+ const { getRegistry } = await import("@inkandswitch/patchwork-plugins");
+ const datatype = await getRegistry("patchwork:datatype").load(type);
+ const title = datatype?.module?.getTitle?.(doc);
+ if (typeof title === "string" && title) return title;
+ } catch {}
+ }
+ return typeof doc.title === "string" ? doc.title : "";
+}
diff --git a/call/src/engine/sizes.js b/call/src/engine/sizes.js
new file mode 100644
index 00000000..259c90da
--- /dev/null
+++ b/call/src/engine/sizes.js
@@ -0,0 +1,58 @@
+// how a call's shown, and sent, for how many are in it. nothing imported:
+// node's tests read it too
+//
+// everyone in a call connects to everyone else (call.js), so each camera's
+// sent once for each of the others: twenty people is nineteen copies out of
+// every browser. past a handful it's sent smaller, slower and fewer frames a
+// second, so it still goes. (any more than that wants a server in the middle,
+// which this isn't)
+
+/** the sidebar's faces, for `count` people (you too): "tiles", "grid" or "chips" */
+export function tilesFor(count) {
+ if (count <= 4) return "tiles";
+ if (count <= 9) return "grid";
+ return "chips";
+}
+
+/**
+ * a video sender's encoding for a call of `count` people (you too), or null
+ * for as it comes
+ */
+export function videoFor(count) {
+ if (count <= 5) return null;
+ if (count <= 9) return { maxBitrate: 300_000, scaleResolutionDownBy: 2, maxFramerate: 20 };
+ return { maxBitrate: 120_000, scaleResolutionDownBy: 4, maxFramerate: 12 };
+}
+
+// ── who's speaking ────────────────────────────────────────────────────────
+
+// how loud's speaking (0 to 1, the sound's root mean square)
+export const SPEAKING_LEVEL = 0.04;
+// it's speaking once it's been that loud this long (not a click or a cough)
+export const SPEAKING_AFTER = 150;
+// and stops once it's been quiet this long (not a breath between words)
+export const QUIET_AFTER = 700;
+
+/** how loud `samples` are (a byte time-domain buffer, 128 is silence) */
+export function levelOf(samples) {
+ if (!samples.length) return 0;
+ let sum = 0;
+ for (const sample of samples) {
+ const x = (sample - 128) / 128;
+ sum += x * x;
+ }
+ return Math.sqrt(sum / samples.length);
+}
+
+/**
+ * speaking or not, after hearing `level` at `now`, from `state`
+ * ({speaking, loudSince, quietSince})
+ */
+export function speakingStep(state, level, now) {
+ if (level >= SPEAKING_LEVEL) {
+ const loudSince = state.loudSince ?? now;
+ return { speaking: state.speaking || now - loudSince >= SPEAKING_AFTER, loudSince, quietSince: null };
+ }
+ const quietSince = state.quietSince ?? now;
+ return { speaking: state.speaking && now - quietSince < QUIET_AFTER, loudSince: null, quietSince };
+}
diff --git a/call/src/engine/speaking.js b/call/src/engine/speaking.js
new file mode 100644
index 00000000..ae50c11e
--- /dev/null
+++ b/call/src/engine/speaking.js
@@ -0,0 +1,58 @@
+// who's speaking: how loud each stream's sound is, a few times a second,
+// and whether that's speaking (sizes.js says when it is). one AudioContext
+// for the page; a stream from a peer is heard here while its plays
+// it too (chrome only lets it through to one then)
+import { levelOf, speakingStep } from "./sizes.js";
+import { lasting } from "./lasting.js";
+
+const EVERY = 100;
+// the page's (../lasting.js): a dev server's changes swap in a new copy of
+// this, while the call it's hearing carries on. the context, and what's
+// heard, by who: "me", or a peer's id (for the wiggle round a floating face)
+const page = lasting("patchwork-call:hearing", () => ({ context: null, heard: new Map() }));
+const heard = page.heard;
+
+/** the analyser hearing `id` ("me" for you), or null */
+export const hearing = (id) => heard.get(id) ?? null;
+
+/**
+ * hear `stream`'s sound: `onChange(speaking)` as it starts and stops. `id`
+ * keeps its analyser for hearing(id). returns a way to stop
+ */
+export function listen(stream, onChange, id) {
+ if (!stream?.getAudioTracks().length) return () => {};
+ let source;
+ let analyser;
+ try {
+ const context = (page.context ??= new AudioContext());
+ if (context.state === "suspended") context.resume().catch(() => {});
+ source = context.createMediaStreamSource(stream);
+ analyser = context.createAnalyser();
+ // enough of their sound to draw round a face (wiggle.js), from a point
+ // where it goes up through silence
+ analyser.fftSize = 2048;
+ source.connect(analyser);
+ if (id != null) heard.set(id, analyser);
+ } catch (error) {
+ console.warn("[call] can't hear who's speaking", error);
+ return () => {};
+ }
+ const samples = new Uint8Array(analyser.fftSize);
+ let state = { speaking: false, loudSince: null, quietSince: null };
+ const timer = setInterval(() => {
+ // a page that's not been touched yet has its sound held back
+ const context = page.context;
+ if (context.state === "suspended") return void context.resume().catch(() => {});
+ analyser.getByteTimeDomainData(samples);
+ const was = state.speaking;
+ state = speakingStep(state, levelOf(samples), Date.now());
+ if (state.speaking !== was) onChange(state.speaking);
+ }, EVERY);
+ return () => {
+ clearInterval(timer);
+ if (id != null && heard.get(id) === analyser) heard.delete(id);
+ try {
+ source.disconnect();
+ } catch {}
+ };
+}
diff --git a/call/src/engine/wiggle.js b/call/src/engine/wiggle.js
new file mode 100644
index 00000000..9218fdc1
--- /dev/null
+++ b/call/src/engine/wiggle.js
@@ -0,0 +1,113 @@
+// the wiggle round a floating face while they're speaking: their sound's
+// waveform, all the way round, as an oscilloscope draws it. half of it up
+// the left from the bottom, and the same again up the right, so the two ends
+// meet. made as loud as the loudest they've been lately, so talking quietly
+// shows as much as shouting. nothing imported: node's tests read it too
+
+/**
+ * `count` points round the ring of `samples` (a float time-domain buffer,
+ * -1 to 1): `span` of them, from the first time they go up through silence
+ * (so it holds still, rather than running round), each point the middle of
+ * its stretch. point 0 is the bottom middle, and they go round
+ */
+export function ringWave(samples, count, span) {
+ span = Math.min(span, samples.length);
+ let from = 0;
+ for (let i = 1; i < samples.length - span; i++) {
+ if (samples[i - 1] < 0 && samples[i] >= 0) {
+ from = i;
+ break;
+ }
+ }
+ const half = Math.floor(count / 2);
+ const along = new Array(half + 1);
+ for (let k = 0; k <= half; k++) {
+ const a = from + Math.floor((k * span) / (half + 1));
+ const b = Math.max(a + 1, from + Math.floor(((k + 1) * span) / (half + 1)));
+ let sum = 0;
+ for (let i = a; i < b; i++) sum += samples[i];
+ along[k] = sum / (b - a);
+ }
+ const values = new Array(count);
+ for (let i = 0; i < count; i++) values[i] = along[Math.min(i, count - i)];
+ return values;
+}
+
+// lately: the loudest falls this much each frame (about a second, at 60 a second)
+export const LOUDER_FALL = 0.96;
+// the quietest that's made as loud as it goes (below that's hum, not them):
+// a whisper's peaks, through a call's noise suppression
+export const LOUDER_FLOOR = 0.008;
+// how loud (the sound's root mean square) there's something to show: a
+// whisper, well under what's speaking (sizes.js)
+export const WIGGLE_HEARD = 0.004;
+// and it's shown this long after (ms), so a whisper's gaps don't flicker it
+export const WIGGLE_HOLD = 400;
+
+/**
+ * `values` (-1 to 1) made as loud as `loudest` (the loudest lately, from
+ * before), and the loudest lately now: {values, loudest}
+ */
+export function louder(values, loudest = 0) {
+ let peak = 0;
+ for (const value of values) peak = Math.max(peak, Math.abs(value));
+ loudest = Math.max(peak, loudest * LOUDER_FALL);
+ const gain = 1 / Math.max(loudest, LOUDER_FLOOR);
+ return { values: values.map((value) => Math.max(-1, Math.min(1, value * gain))), loudest };
+}
+
+/**
+ * the point `along` (0 to 1) the edge of a `w` by `h` box with corners
+ * `r` round (centred on 0, y down), from its bottom middle round by the
+ * left, and which way's out from there: {x, y, nx, ny}
+ */
+export function edgeAt(w, h, r, along) {
+ r = Math.max(0, Math.min(r, w / 2, h / 2));
+ const sx = w / 2 - r;
+ const sy = h / 2 - r;
+ const arc = (Math.PI * r) / 2;
+ // [length, (t from 0 to its length) => point]
+ const line = (x0, y0, x1, y1, nx, ny) => [
+ Math.hypot(x1 - x0, y1 - y0),
+ (t, length) => ({ x: x0 + ((x1 - x0) * t) / (length || 1), y: y0 + ((y1 - y0) * t) / (length || 1), nx, ny }),
+ ];
+ const corner = (cx, cy, from) => [
+ arc,
+ (t) => {
+ const angle = from + (r ? t / r : 0);
+ const nx = Math.cos(angle);
+ const ny = Math.sin(angle);
+ return { x: cx + r * nx, y: cy + r * ny, nx, ny };
+ },
+ ];
+ const parts = [
+ line(0, h / 2, -sx, h / 2, 0, 1),
+ corner(-sx, sy, Math.PI / 2),
+ line(-w / 2, sy, -w / 2, -sy, -1, 0),
+ corner(-sx, -sy, Math.PI),
+ line(-sx, -h / 2, sx, -h / 2, 0, -1),
+ corner(sx, -sy, (Math.PI * 3) / 2),
+ line(w / 2, -sy, w / 2, sy, 1, 0),
+ corner(sx, sy, 0),
+ line(sx, h / 2, 0, h / 2, 0, 1),
+ ];
+ const total = parts.reduce((sum, [length]) => sum + length, 0);
+ let left = (((along % 1) + 1) % 1) * total;
+ for (const [length, at] of parts) {
+ if (left <= length) return at(left, length);
+ left -= length;
+ }
+ return parts.at(-1)[1](parts.at(-1)[0], parts.at(-1)[0]);
+}
+
+/**
+ * the wiggle's points round a `w` by `h` box with corners `r` round, each
+ * `out` from its edge, and its value (-1 to 1) of `swing` either way
+ */
+export function wiggle(w, h, r, values, out, swing) {
+ return values.map((value, i) => {
+ const at = edgeAt(w, h, r, i / values.length);
+ const away = out + value * swing;
+ return [at.x + at.nx * away, at.y + at.ny * away];
+ });
+}
diff --git a/call/src/index.js b/call/src/index.js
new file mode 100644
index 00000000..4b21eb0c
--- /dev/null
+++ b/call/src/index.js
@@ -0,0 +1,50 @@
+// the call: one at a time, in a tab, on any doc. this module is read in a
+// worker, so it's only what each plugin is, with what it does behind load()
+export const plugins = [
+ {
+ type: "patchwork:component",
+ id: "call-provider",
+ name: "Call",
+ async load() {
+ return (await import("./provider.js")).CallProvider;
+ },
+ },
+ {
+ type: "patchwork:component",
+ id: "call-face",
+ name: "Call face",
+ async load() {
+ return (await import("./call-face.js")).CallFace;
+ },
+ },
+ {
+ type: "patchwork:component",
+ id: "call-control-panel",
+ name: "Call",
+ async load() {
+ return (await import("./control-panel.js")).CallControlPanel;
+ },
+ },
+ {
+ type: "patchwork:component",
+ id: "call-video",
+ tags: ["context-tool"],
+ // (tabs go by name, and with nothing chosen the first is shown: not this)
+ name: "Video",
+ icon: "Video",
+ async load() {
+ return (await import("./video.js")).CallVideo;
+ },
+ },
+ {
+ type: "patchwork:tool",
+ id: "call-doctitle-button",
+ tags: ["titlebar-tool"],
+ name: "Call button",
+ icon: "Phone",
+ supportedDatatypes: "*",
+ unlisted: true,
+ forTitleBar: true,
+ load: () => import("./button.js").then((module) => module.default),
+ },
+];
diff --git a/call/src/protocol-names.js b/call/src/protocol-names.js
new file mode 100644
index 00000000..7348a9f2
--- /dev/null
+++ b/call/src/protocol-names.js
@@ -0,0 +1,6 @@
+// the protocol's names (protocol.js), on their own: what's outside the
+// call's realm (the doc-title button) needs these and nothing of the call
+export const CALL_EVENT = "patchwork:call";
+export const CALL = "patchwork:call";
+export const CALL_ROOM = "patchwork:call-room";
+export const CALL_VIDEO = "patchwork:call-video";
diff --git a/call/src/protocol.js b/call/src/protocol.js
new file mode 100644
index 00000000..589bf1da
--- /dev/null
+++ b/call/src/protocol.js
@@ -0,0 +1,24 @@
+// how anything outside the call's own parts talks to it: through the
+// provider, by events and subscriptions (patchwork-providers), in plain
+// data, so it works from anywhere a frame puts it
+//
+// patchwork:call (an event) {action: "ask", url, at?} | {action: "hang-up"}
+// patchwork:call {url} → {here, others, elsewhere}
+// patchwork:call-room {url, name} this room is where you are, called name
+// → {here, others, elsewhere}
+// patchwork:call-video {showing} this frame can show the video, and
+// whether it is now → {wanted}
+//
+// a url is any room id (room.js): a doc's url, or "key:"
+import { call, onCallIn } from "./engine/call.js";
+import { roomUrl } from "./engine/room.js";
+
+export { CALL, CALL_EVENT, CALL_ROOM, CALL_VIDEO } from "./protocol-names.js";
+
+/** {here, others, elsewhere} for the room `url`: you're in its call; how many others are; you're in one somewhere else */
+export function roomState(url) {
+ const id = roomUrl(url);
+ const now = call.get()?.room ?? null;
+ const here = !!id && now === id;
+ return { here, others: id ? onCallIn(id).length : 0, elsewhere: !!now && !here };
+}
diff --git a/call/src/provider.js b/call/src/provider.js
new file mode 100644
index 00000000..34239c2d
--- /dev/null
+++ b/call/src/provider.js
@@ -0,0 +1,107 @@
+// the call provider: put round a frame (threepane mounts it round everything)
+// it hears every doc mounted inside it, answers the protocol (protocol.js),
+// and draws what floats
+import { createEffect, createRoot } from "solid-js";
+import { render } from "solid-js/web";
+import html from "solid-js/html";
+import { accept } from "@inkandswitch/patchwork-providers";
+import { ask, call, hangUp, hearRoom, holdVideoSpot } from "./engine/call.js";
+import { roomUrl } from "./engine/room.js";
+import { CALL, CALL_EVENT, CALL_ROOM, CALL_VIDEO, roomState } from "./protocol.js";
+import { CallFloating, CallQuestion, CallStage } from "./ui/ui.js";
+import { stageArea } from "./ui/area.js";
+import "./ui/styles.css";
+
+/** answer `event` with `read()` now and whenever it changes, till it's unsubscribed */
+function answer(event, read, hold) {
+ accept(event, (respond) => {
+ const release = hold();
+ const dispose = createRoot((dispose) => {
+ createEffect(() => respond(read()));
+ return dispose;
+ });
+ return () => {
+ dispose();
+ release();
+ };
+ });
+}
+
+export function CallProvider(element) {
+ // what each doc mounted inside holds: by room id, a release each time
+ const mounts = new Map();
+
+ function hold(url) {
+ const id = roomUrl(url);
+ if (!id) return;
+ if (!mounts.has(id)) mounts.set(id, []);
+ mounts.get(id).push(hearRoom(id, { onScreen: true }));
+ }
+
+ function onMounted(event) {
+ if ("url" in (event.detail ?? {})) hold(event.detail.url);
+ }
+
+ function onUnmounted(event) {
+ const id = "url" in (event.detail ?? {}) ? roomUrl(event.detail.url) : null;
+ const held = id && mounts.get(id);
+ if (!held) return;
+ held.pop()();
+ if (!held.length) mounts.delete(id);
+ }
+
+ function onSubscribe(event) {
+ const selector = event.detail?.selector ?? {};
+ switch (selector.type) {
+ case CALL:
+ return answer(event, () => roomState(selector.url), () => hearRoom(selector.url));
+ case CALL_ROOM:
+ return answer(
+ event,
+ () => roomState(selector.url),
+ () => hearRoom(selector.url, { onScreen: true, name: selector.name }),
+ );
+ case CALL_VIDEO:
+ return answer(
+ event,
+ () => ({ wanted: call.videoWanted() }),
+ () => holdVideoSpot({ showing: !!selector.showing }),
+ );
+ }
+ }
+
+ function onCall(event) {
+ const { action, url, at } = event.detail ?? {};
+ if (action === "ask") ask(url, { at: at ?? null });
+ else if (action === "hang-up") hangUp();
+ }
+
+ // what floats: the question, faces, and whoever's on the stage, in a layer
+ // of their own (each its own popover, over the frame)
+ const layer = document.createElement("div");
+ layer.className = "patchwork-call";
+ element.append(layer);
+ const unrender = render(() => {
+ const area = stageArea(element, () => !!call.get());
+ return html`<${CallQuestion} /><${CallFloating} area=${() => area} /><${CallStage} area=${() => area} />`;
+ }, layer);
+
+ element.addEventListener("patchwork:mounted", onMounted);
+ element.addEventListener("patchwork:unmounted", onUnmounted);
+ element.addEventListener("patchwork:subscribe", onSubscribe);
+ element.addEventListener(CALL_EVENT, onCall);
+ // what was mounted before this was (it was loaded late, or swapped in
+ // anew): on screen already, and unmounted like the rest later
+ for (const view of element.querySelectorAll("patchwork-view[doc-url]")) hold(view.getAttribute("doc-url"));
+
+ return () => {
+ element.removeEventListener("patchwork:mounted", onMounted);
+ element.removeEventListener("patchwork:unmounted", onUnmounted);
+ element.removeEventListener("patchwork:subscribe", onSubscribe);
+ element.removeEventListener(CALL_EVENT, onCall);
+ for (const held of mounts.values()) for (const release of held) release();
+ mounts.clear();
+ unrender();
+ layer.remove();
+ };
+}
diff --git a/call/src/ui/along.js b/call/src/ui/along.js
new file mode 100644
index 00000000..61375863
--- /dev/null
+++ b/call/src/ui/along.js
@@ -0,0 +1,63 @@
+// a row of faces that's longer than it has room for (five people in a call,
+// twenty): it scrolls sideways, and fades out at whichever end there's more
+// past, so you can see there's more. data-more on it ("start", "end" or
+// "both") is what its css fades by. a wheel's up and down scrolls it along
+// too, while there's further to go that way (a mouse has no sideways)
+import { onCleanup } from "solid-js";
+
+// how far from an end still counts as at it
+const SLACK = 1;
+// the fade's width: a face brought into view clears it
+const FADE = 16;
+
+/** make `el` (a row, with its faces in it) scroll along. as a ref */
+export function scrollsAlong(el) {
+ el.classList.add("patchwork-call-along");
+
+ function mark() {
+ const room = el.scrollWidth - el.clientWidth;
+ const start = el.scrollLeft > SLACK;
+ const end = el.scrollLeft < room - SLACK;
+ const more = start && end ? "both" : start ? "start" : end ? "end" : null;
+ if (more) el.dataset.more = more;
+ else delete el.dataset.more;
+ }
+
+ function onWheel(event) {
+ if (event.ctrlKey || Math.abs(event.deltaX) >= Math.abs(event.deltaY)) return;
+ const room = el.scrollWidth - el.clientWidth;
+ if (room <= 0) return;
+ const by = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? el.clientWidth : 1);
+ const to = Math.max(0, Math.min(room, el.scrollLeft + by));
+ // at the end already: what's round it has the wheel
+ if (Math.abs(to - el.scrollLeft) < SLACK) return;
+ event.preventDefault();
+ el.scrollLeft = to;
+ }
+
+ el.addEventListener("scroll", mark, { passive: true });
+ el.addEventListener("wheel", onWheel, { passive: false });
+ // its size changing, or someone coming or going
+ const resized = new ResizeObserver(mark);
+ resized.observe(el);
+ const changed = new MutationObserver(mark);
+ changed.observe(el, { childList: true });
+ queueMicrotask(mark);
+
+ onCleanup(() => {
+ el.removeEventListener("scroll", mark);
+ el.removeEventListener("wheel", onWheel);
+ resized.disconnect();
+ changed.disconnect();
+ });
+}
+
+/** scroll whatever row `child` is in so it's in view, clear of the fade */
+export function bringAlong(child) {
+ const row = child?.closest(".patchwork-call-along");
+ if (!row) return;
+ const box = child.getBoundingClientRect();
+ const on = row.getBoundingClientRect();
+ if (box.left < on.left + FADE) row.scrollLeft -= on.left + FADE - box.left;
+ else if (box.right > on.right - FADE) row.scrollLeft += box.right - (on.right - FADE);
+}
diff --git a/call/src/ui/area.js b/call/src/ui/area.js
new file mode 100644
index 00000000..9fe5c2c7
--- /dev/null
+++ b/call/src/ui/area.js
@@ -0,0 +1,56 @@
+// where the stage goes: the first element under the provider a frame has
+// marked data-call-stage (threepane's main area, lattice's panes), as a box
+// on screen, followed as it moves and changes size. null where there's none:
+// the stage fills the window then. only looked at while `active()` (you're in
+// a call): the frame's changes cost nothing otherwise, and while it's found
+// it isn't looked for again. it's measured once a frame at most
+import { createEffect, createSignal, onCleanup } from "solid-js";
+
+const same = (a, b) =>
+ a === b || (!!a && !!b && a.left === b.left && a.top === b.top && a.width === b.width && a.height === b.height);
+
+/** the box of the stage's area under `root`, or null (in a reactive scope: it's let go on cleanup) */
+export function stageArea(root, active = () => true) {
+ const [box, setBox] = createSignal(null, { equals: same });
+
+ createEffect(() => {
+ if (!active()) return setBox(null);
+ let marked = null;
+ let frame = 0;
+ const sizes = new ResizeObserver(() => soon());
+
+ function measure() {
+ frame = 0;
+ if (!marked?.isConnected) return setBox(null);
+ const { left, top, width, height } = marked.getBoundingClientRect();
+ setBox({ left, top, width, height });
+ }
+ function soon() {
+ frame ||= requestAnimationFrame(measure);
+ }
+
+ function find() {
+ if (marked?.isConnected && marked.hasAttribute("data-call-stage")) return;
+ const now = root.querySelector("[data-call-stage]");
+ if (marked) sizes.unobserve(marked);
+ marked = now;
+ if (marked) sizes.observe(marked);
+ soon();
+ }
+
+ const changes = new MutationObserver(find);
+ changes.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ["data-call-stage"] });
+ addEventListener("resize", soon);
+ addEventListener("scroll", soon, true);
+ find();
+
+ onCleanup(() => {
+ cancelAnimationFrame(frame);
+ changes.disconnect();
+ sizes.disconnect();
+ removeEventListener("resize", soon);
+ removeEventListener("scroll", soon, true);
+ });
+ });
+ return box;
+}
diff --git a/call/src/ui/arriving.js b/call/src/ui/arriving.js
new file mode 100644
index 00000000..1d2d0c48
--- /dev/null
+++ b/call/src/ui/arriving.js
@@ -0,0 +1,26 @@
+// what's new: a face, a call that turns up while you're looking
+// comes in (styles.css's [data-arriving]), and what was there when you came,
+// or came with the first sync a moment after, is just there. opening a
+// doc shouldn't have everything in it arrive. and it's new once: a row
+// that's made again (its list worked out afresh) isn't arriving
+import { onCleanup } from "solid-js";
+
+// how long the first sync has to bring what was already there
+const SETTLES_AFTER = 1500;
+
+/**
+ * call where the list is made; then, as each thing in it is, `arriving(key)`
+ * is "" for data-arriving (the first time that key's seen, once it's
+ * settled) or undefined
+ */
+export function arrivals(after = SETTLES_AFTER) {
+ let settled = false;
+ const seen = new Set();
+ const timer = setTimeout(() => (settled = true), after);
+ onCleanup(() => clearTimeout(timer));
+ return (key) => {
+ const fresh = !seen.has(key);
+ seen.add(key);
+ return settled && fresh ? "" : undefined;
+ };
+}
diff --git a/call/src/ui/avatar.js b/call/src/ui/avatar.js
new file mode 100644
index 00000000..b2ebc480
--- /dev/null
+++ b/call/src/ui/avatar.js
@@ -0,0 +1,13 @@
+// a face's picture: the avatar doc, through patchwork's service worker. if
+// that isn't to be had, faces are initials
+const avatarSrc = await import("@inkandswitch/patchwork-filesystem").then(
+ (filesystem) => filesystem.automergeUrlToServiceWorkerUrl,
+ () => null,
+);
+export function faceImage(url) {
+ try {
+ return url && avatarSrc ? avatarSrc(url) : null;
+ } catch {
+ return null;
+ }
+}
diff --git a/call/src/ui/colour.js b/call/src/ui/colour.js
new file mode 100644
index 00000000..55ff282e
--- /dev/null
+++ b/call/src/ui/colour.js
@@ -0,0 +1,20 @@
+// a colour for someone who's not picked one, from who they are: patchwork's
+// presence colours (the account package's palette), as lattice's pointers have
+export const COLOURS = [
+ ["sky", "hsl(200, 70%, 50%)"],
+ ["coral", "hsl(10, 75%, 58%)"],
+ ["emerald", "hsl(145, 70%, 45%)"],
+ ["violet", "hsl(270, 70%, 55%)"],
+ ["amber", "hsl(38, 85%, 50%)"],
+ ["rose", "hsl(350, 70%, 55%)"],
+ ["teal", "hsl(178, 70%, 45%)"],
+ ["indigo", "hsl(235, 70%, 58%)"],
+ ["lime", "hsl(85, 70%, 45%)"],
+ ["fuchsia", "hsl(310, 70%, 55%)"],
+ ["orange", "hsl(25, 80%, 52%)"],
+ ["cyan", "hsl(188, 75%, 48%)"],
+];
+// the colour of someone who's not picked one: one of those, from their url
+export const colourFor = (url) =>
+ COLOURS[[...String(url)].reduce((sum, char) => sum + char.charCodeAt(0), 0) % COLOURS.length][1];
+
diff --git a/call/src/ui/icons.js b/call/src/ui/icons.js
new file mode 100644
index 00000000..64b4be15
--- /dev/null
+++ b/call/src/ui/icons.js
@@ -0,0 +1,44 @@
+// the call's line drawings, on a 16 unit square, in the ink they sit in
+// (lattice's, wm/ui/icons.js: just the ones a call uses)
+const SVG = "http://www.w3.org/2000/svg";
+
+const PATHS = {
+ close: "M4.5 4.5l7 7M11.5 4.5l-7 7",
+ minimise: "M11.5 4.5l-7 7M4.5 6.5v5h5",
+ screen: "M2.5 3.5h11v7.5h-11zM5.5 13.5h5M8 11v2.5",
+ "chevron-down": "M4.5 6.25 8 9.75l3.5-3.5",
+ phone: "M3.5 2.5H6l1 3-1.5 1a7 7 0 0 0 4 4l1-1.5 3 1V12.5a1 1 0 0 1-1 1A10.5 10.5 0 0 1 2.5 3.5a1 1 0 0 1 1-1Z",
+ "hang-up": "M1.8 9.6c3.5-3.3 8.9-3.3 12.4 0l-.9 2-2.6-.5-.4-1.9a6.5 6.5 0 0 0-4.6 0l-.4 1.9-2.6.5Z",
+ mic: "M8 2a2 2 0 0 1 2 2v4a2 2 0 0 1-4 0V4a2 2 0 0 1 2-2ZM4.5 7.5a3.5 3.5 0 0 0 7 0M8 11v2.5",
+ "mic-off": "M8 2a2 2 0 0 1 2 2v4a2 2 0 0 1-4 0V4a2 2 0 0 1 2-2ZM4.5 7.5a3.5 3.5 0 0 0 7 0M8 11v2.5M2.5 2.5l11 11",
+ video: "M2 4.5h8v7H2ZM10 7l4-2.5v7L10 9",
+ "video-off": "M2 4.5h8v7H2ZM10 7l4-2.5v7L10 9M1.5 2.5l12 11",
+};
+
+/**
+ * a line icon, as an element: goes into html`` templates and plain dom
+ * alike. `size` is anything an svg's width takes, or a number of px; by default it's as big as the text
+ * around it, and sits on its line like a letter
+ */
+export function icon(name, size = "1em") {
+ const svg = document.createElementNS(SVG, "svg");
+ svg.setAttribute("viewBox", "0 0 16 16");
+ svg.setAttribute("width", size);
+ svg.setAttribute("height", size);
+ if (typeof size === "number") {
+ svg.style.width = svg.style.height = `${size}px`;
+ }
+ svg.setAttribute("aria-hidden", "true");
+ svg.setAttribute("class", `icon icon-${name}`);
+ svg.style.flex = "none";
+ svg.style.verticalAlign = "-0.125em";
+ const path = document.createElementNS(SVG, "path");
+ path.setAttribute("d", PATHS[name]);
+ path.setAttribute("fill", "none");
+ path.setAttribute("stroke", "currentColor");
+ path.setAttribute("stroke-width", "1.4");
+ path.setAttribute("stroke-linecap", "round");
+ path.setAttribute("stroke-linejoin", "round");
+ svg.append(path);
+ return svg;
+}
diff --git a/call/src/ui/styles.css b/call/src/ui/styles.css
new file mode 100644
index 00000000..5621d827
--- /dev/null
+++ b/call/src/ui/styles.css
@@ -0,0 +1,1088 @@
+/* the call's look: lattice's, where it came from (its wm.css, shell.css), its
+ colours from the theme's studio chrome: faces float over everything, and
+ sit in the sidebars and the title bar */
+
+@layer package {
+ :root,
+ :host,
+ [theme] {
+ --call-panel-muted: oklch(from var(--call-panel-shell-chrome-fill) calc(l + (1 - 2 * round(l)) * 0.5) c h);
+ --call-panel-path-family: var(--call-panel-heading-family, var(--call-panel-shell-family-code));
+ --call-panel-rule: var(--call-panel-shell-chrome-fill-offset-30);
+ --call-panel-shell-chrome-fill: var(--studio-chrome-fill);
+ --call-panel-shell-chrome-fill-offset-20: var(--studio-chrome-fill-offset-20);
+ --call-panel-shell-chrome-fill-offset-30: var(--studio-chrome-fill-offset-30);
+ --call-panel-shell-family-code: var(--studio-family-code);
+ --call-panel-shell-primary: var(--studio-primary);
+ --call-panel-shell-primary-line: var(--studio-primary-line);
+ --call-accent: var(--studio-primary-fill);
+ --call-accent-ink: var(--studio-primary-line);
+ --call-chrome: var(--studio-chrome-fill-offset-20);
+ --call-chrome-hover: var(--studio-chrome-fill-offset-30);
+ --call-danger: var(--studio-danger-text);
+ --call-danger-fill: var(--studio-danger-fill);
+ --call-danger-ink: var(--studio-danger-line);
+ --call-fill: var(--studio-fill);
+ --call-heading-family: var(--call-sans);
+ --call-highlighter: var(--studio-selection-fill);
+ --call-ink: var(--studio-line);
+ --call-line: var(--studio-chrome-line);
+ --call-muted: oklch(from var(--call-fill) calc(l + (1 - 2 * round(l)) * 0.5) c h);
+ --call-round: var(--studio-radius-round);
+ --call-sans: var(--studio-family-ui);
+ --call-sheet: var(--editor-fill);
+
+ /* on video, which is dark whatever the theme: the stage behind a
+ picture, and what's written over one */
+ --call-stage-fill: oklch(0.18 0 0);
+ --call-stage-ink: oklch(0.98 0 0);
+ --call-on-video-ink: oklch(1 0 0 / 0.9);
+ --call-on-video-shadow: oklch(0 0 0 / 0.6);
+ --call-on-video-scrim: oklch(0 0 0 / 0.55);
+ /* the call button, in a title bar, as its neighbours there are */
+ --call-title-fill: var(--studio-fill);
+ --call-title-ink: var(--studio-line);
+ --call-title-line: var(--studio-fill-offset-20);
+ --call-title-line-hover: var(--studio-fill-offset-30);
+ --call-title-hover: var(--studio-fill-offset-10);
+ --call-title-here: var(--studio-danger-fill);
+ --call-title-here-ink: var(--studio-danger-line);
+ --call-title-join: var(--studio-primary-fill);
+ --call-title-join-ink: var(--studio-primary-line);
+ /* faces floating over everything */
+ --call-float-shadow: oklch(0 0 0 / 0.45);
+ --call-float-shadow-soft: oklch(0 0 0 / 0.35);
+ }
+}
+
+/* ── the call's menu ──────────────────────────────────────────────────── */
+
+.patchwork-call-menu {
+ &[popover] {
+ position: fixed;
+ inset: auto;
+ width: 260px;
+ margin: 0;
+ padding: 4px;
+ border: 1px solid var(--call-line);
+ border-radius: 3px;
+ background: var(--call-sheet);
+ box-shadow: 2px 2px 0 var(--call-line);
+ color: var(--call-ink);
+ font: 12px/1.4 var(--call-sans);
+ /* it grows out of where it was asked for (control-panel.js sets the
+ origin; else its top left, under its button),
+ quickly, as it's opened many times a day. it goes at once */
+ transform-origin: 0 0;
+ transition:
+ opacity 150ms cubic-bezier(0.23, 1, 0.32, 1),
+ scale 150ms cubic-bezier(0.23, 1, 0.32, 1);
+
+ @starting-style {
+ opacity: 0;
+ scale: 0.97;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ transition: opacity 150ms ease-out;
+
+ @starting-style {
+ scale: none;
+ }
+ }
+ }
+
+ & .patchwork-call-menu-heading {
+ padding: 6px 8px 2px;
+ color: var(--call-muted);
+ font-size: 10px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ }
+
+ & .patchwork-call-menu-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ width: 100%;
+ padding: 4px 8px;
+ border: none;
+ border-radius: 2px;
+ background: none;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+
+ &:hover {
+ background: var(--call-highlighter);
+ color: var(--call-ink);
+ }
+ }
+
+ /* a tick by what's chosen */
+ & .patchwork-call-panel-choice {
+ &::before {
+ content: "";
+ flex: none;
+ width: 1em;
+ }
+ &[aria-checked="true"]::before {
+ content: "✓";
+ }
+ }
+}
+
+/* something arriving while you're looking (ui/arriving.js): a face grows
+ in. less motion: they fade in */
+@keyframes patchwork-call-arrive {
+ from {
+ opacity: 0;
+ scale: 0.9;
+ }
+}
+@keyframes patchwork-call-arrive-fade {
+ from {
+ opacity: 0;
+ }
+}
+
+/* a row of faces longer than its room (ui/along.js): it scrolls sideways,
+ no bar, and fades out at whichever end there's more past */
+.patchwork-call-along {
+ --call-along-fade: 16px;
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ overflow-y: hidden;
+ overscroll-behavior-x: contain;
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ &[data-more="end"] {
+ mask-image: linear-gradient(to right, #000 calc(100% - var(--call-along-fade)), transparent);
+ }
+ &[data-more="start"] {
+ mask-image: linear-gradient(to left, #000 calc(100% - var(--call-along-fade)), transparent);
+ }
+ &[data-more="both"] {
+ mask-image: linear-gradient(
+ to right,
+ transparent,
+ #000 var(--call-along-fade),
+ #000 calc(100% - var(--call-along-fade)),
+ transparent
+ );
+ }
+}
+
+/* ── faces, the question before you're in a call, and faces floating or in
+ the video tab ────────────────────────────────────────────────────── */
+
+/* someone's face: their pictures while their camera's on, else their
+ avatar, or their initial. yours is as a mirror shows you */
+.patchwork-call-face {
+ --call-face-size: 24px;
+ position: relative;
+ display: inline-grid;
+ flex: none;
+ place-items: center;
+ box-sizing: border-box;
+ width: var(--call-face-size);
+ height: var(--call-face-size);
+ border: 2px solid var(--call-face, var(--call-line));
+ border-radius: 50%;
+ background: var(--call-chrome) center / cover;
+ color: var(--call-ink);
+ font: 600 calc(var(--call-face-size) * 0.42) / 1 var(--call-sans);
+
+ & video {
+ width: 100%;
+ height: 100%;
+ border-radius: inherit;
+ object-fit: cover;
+ /* centred on their face (face.js), which is said four times a second:
+ it glides there between, rather than jumping */
+ transition: object-position 400ms cubic-bezier(0.25, 0.1, 0.25, 1);
+
+ @media (prefers-reduced-motion: reduce) {
+ transition: none;
+ }
+ }
+
+ &[data-mine] video {
+ scale: -1 1;
+ }
+
+ &[data-connecting] {
+ opacity: 0.55;
+ }
+
+ /* speaking: a ring round them, as it starts and stops */
+ outline: 2px solid transparent;
+ outline-offset: 1px;
+ transition: outline-color 160ms ease-out;
+ &[data-speaking] {
+ outline-color: var(--call-accent);
+ }
+
+ &[data-shape="tile"] {
+ width: 100%;
+ height: auto;
+ aspect-ratio: 4 / 3;
+ border-radius: 6px;
+ font-size: 28px;
+ }
+
+ & .patchwork-call-muted {
+ position: absolute;
+ right: -3px;
+ bottom: -3px;
+ display: grid;
+ place-items: center;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--call-danger-fill);
+ color: var(--call-danger-ink);
+ }
+
+ /* sharing their screen: a little screen, top left */
+ & .patchwork-call-sharing {
+ position: absolute;
+ left: -3px;
+ top: -3px;
+ display: grid;
+ place-items: center;
+ width: 16px;
+ height: 16px;
+ border-radius: 4px;
+ background: var(--call-accent);
+ color: var(--call-accent-ink);
+ }
+
+ &[data-shape="tile"] .patchwork-call-sharing {
+ left: 6px;
+ top: 6px;
+ width: 20px;
+ height: 20px;
+ }
+
+ &[data-shape="tile"] .patchwork-call-muted {
+ right: 6px;
+ bottom: 6px;
+ width: 18px;
+ height: 18px;
+ }
+}
+
+.patchwork-call-question {
+ &[popover] {
+ position: fixed;
+ inset: auto;
+ box-sizing: border-box;
+ width: 260px;
+ margin: 0;
+ padding: 10px 12px;
+ border: 1px solid var(--call-line);
+ border-radius: 3px;
+ background: var(--call-sheet);
+ box-shadow: 2px 2px 0 var(--call-line);
+ color: var(--call-ink);
+ font: 12px/1.4 var(--call-sans);
+ transition:
+ opacity 160ms cubic-bezier(0.23, 1, 0.32, 1),
+ scale 160ms cubic-bezier(0.23, 1, 0.32, 1);
+ transform-origin: top left;
+
+ @starting-style {
+ opacity: 0;
+ scale: 0.97;
+ }
+ }
+
+ & p {
+ margin: 0;
+ }
+
+ & .patchwork-call-question-who {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ & .patchwork-call-question-faces {
+ display: flex;
+
+ & .patchwork-call-face + .patchwork-call-face {
+ margin-left: -6px;
+ }
+ }
+
+ & .patchwork-call-question-title {
+ font: 600 13px/1.3 var(--call-heading-family);
+ }
+
+ & .patchwork-call-question-ask {
+ margin-top: 6px;
+ color: var(--call-muted);
+ }
+
+ & .patchwork-call-question-buttons {
+ display: flex;
+ gap: 6px;
+ margin-top: 10px;
+ }
+
+ & .patchwork-call-go {
+ --call-button-fill: var(--call-accent);
+ display: inline-flex;
+ flex: 1;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ padding: 5px 8px;
+ border: 1px solid var(--call-line);
+ border-radius: 3px;
+ background: var(--call-button-fill);
+ color: var(--call-accent-ink);
+ font: 600 12px/1.2 var(--call-sans);
+ cursor: pointer;
+ transition: scale 120ms ease-out;
+
+ &[data-quiet] {
+ --call-button-fill: var(--call-sheet);
+ color: inherit;
+ }
+
+ &:active {
+ scale: 0.97;
+ }
+ }
+
+ & .patchwork-call-question-foot {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 8px;
+ margin-top: 8px;
+ color: var(--call-muted);
+ font-size: 11px;
+ }
+
+ & .patchwork-call-not-now {
+ padding: 2px 4px;
+ border: none;
+ background: none;
+ color: var(--call-muted);
+ font: inherit;
+ cursor: pointer;
+
+ &:hover {
+ color: var(--call-ink);
+ }
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ &[popover] {
+ transition: opacity 160ms ease-out;
+
+ @starting-style {
+ scale: 1;
+ }
+ }
+ }
+}
+
+/* faces floating over everything, each where it's put */
+.patchwork-call-floating {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ pointer-events: none;
+
+ /* in the top layer (a popover), over a frame's own: none of a popover's
+ own look */
+ &[popover] {
+ width: auto;
+ height: auto;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ overflow: visible;
+ }
+
+ & .patchwork-call-float {
+ position: absolute;
+ display: grid;
+ justify-items: center;
+ gap: 4px;
+ pointer-events: auto;
+ cursor: grab;
+ touch-action: none;
+ user-select: none;
+ /* arriving, it comes out of their face in the call bar, and minimised
+ goes back into it (ui.js: arrive, minimise) */
+
+ &:active {
+ cursor: grabbing;
+ }
+
+ /* someone big (CallStage), and you: shown there, so not here too */
+ &[data-staged] {
+ visibility: hidden;
+ }
+
+ & .patchwork-call-face {
+ border-radius: 10px;
+ box-shadow: 2px 2px 0 var(--call-line);
+ }
+ }
+
+ /* the picture's box, its shape (4:3) whichever the face is: the whole
+ picture, or a circle as tall in the middle of it. between the two, its
+ width and corners move */
+ & .patchwork-call-float-picture {
+ position: relative;
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ aspect-ratio: 4 / 3;
+
+ & .patchwork-call-face {
+ width: 100%;
+ height: 100%;
+ aspect-ratio: auto;
+ transition:
+ width 200ms cubic-bezier(0.23, 1, 0.32, 1),
+ border-radius 200ms cubic-bezier(0.23, 1, 0.32, 1);
+ }
+ }
+ & .patchwork-call-float[data-shape="round"] {
+ & .patchwork-call-float-picture .patchwork-call-face {
+ width: 75%;
+ border-radius: 50%;
+ font-size: 32px;
+ }
+ & .patchwork-call-size {
+ right: 14%;
+ }
+ }
+ @media (prefers-reduced-motion: reduce) {
+ & .patchwork-call-float-picture .patchwork-call-face {
+ transition: none;
+ }
+ }
+ /* speaking: their sound, their face's border bumping out all the way
+ round (ui.js: Wiggle), from behind it. it's the ring, so not that too
+ (but with reduced motion, it's the ring) */
+ & .patchwork-call-wiggle {
+ position: absolute;
+ inset: -32px;
+ width: calc(100% + 64px);
+ height: calc(100% + 64px);
+ pointer-events: none;
+ }
+ @media (prefers-reduced-motion: no-preference) {
+ & .patchwork-call-float-picture .patchwork-call-face[data-speaking] {
+ outline-color: transparent;
+ }
+ }
+ /* a floating face's corner: drag it to size the face */
+ & .patchwork-call-size {
+ position: absolute;
+ right: 3px;
+ bottom: 3px;
+ width: 16px;
+ height: 16px;
+ cursor: nwse-resize;
+ background: linear-gradient(
+ 135deg,
+ transparent 50%,
+ var(--call-on-video-ink) 50% 60%,
+ transparent 60% 70%,
+ var(--call-on-video-ink) 70% 80%,
+ transparent 80%
+ );
+ filter: drop-shadow(0 0 1px var(--call-on-video-shadow));
+ opacity: 0;
+ transition: opacity 120ms ease-out;
+ }
+ & .patchwork-call-float:hover .patchwork-call-size {
+ opacity: 1;
+ }
+
+ & .patchwork-call-float-muted {
+ display: inline-grid;
+ place-items: center;
+ width: 14px;
+ height: 14px;
+ margin-left: 3px;
+ border-radius: 50%;
+ background: var(--call-danger-fill);
+ color: var(--call-danger-ink);
+ vertical-align: -3px;
+ }
+
+ & .patchwork-call-float-name {
+ /* over the wiggle */
+ position: relative;
+ padding: 1px 6px;
+ border-radius: var(--call-round);
+ background: var(--call-sheet);
+ color: var(--call-ink);
+ font: 600 10.5px/1.4 var(--call-sans);
+ white-space: nowrap;
+ }
+}
+
+/* a face to click to fill the panes with: a button that's only its face */
+.patchwork-call-stage-button {
+ padding: 0;
+ border: none;
+ background: none;
+ color: inherit;
+ font: inherit;
+ text-align: inherit;
+ cursor: pointer;
+
+ /* someone joining while you're in the call (ui/arriving.js) */
+ &[data-arriving] {
+ animation: patchwork-call-arrive 200ms cubic-bezier(0.23, 1, 0.32, 1) backwards;
+
+ @media (prefers-reduced-motion: reduce) {
+ animation-name: patchwork-call-arrive-fade;
+ }
+ }
+}
+
+/* the faces down a sidebar: one across while there are few, two across when
+ there are more, and past that a list of little faces and names */
+.patchwork-call-tiles {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 10px;
+ padding: 8px 12px 12px;
+
+ &[data-layout="grid"] {
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+ }
+
+ &[data-layout="chips"] {
+ gap: 2px;
+ }
+
+ & .patchwork-call-tile {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+ border-radius: 8px;
+
+ &:focus-visible {
+ outline: 2px solid var(--call-accent);
+ outline-offset: 2px;
+ }
+ }
+
+ &[data-layout="chips"] .patchwork-call-tile {
+ grid-template-columns: auto 1fr;
+ align-items: center;
+ gap: 8px;
+ padding: 3px 4px;
+ }
+
+ & .patchwork-call-tile-name {
+ overflow: hidden;
+ color: var(--call-muted);
+ font: 11px/1.3 var(--call-sans);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &[data-layout="chips"] .patchwork-call-tile-name {
+ color: var(--call-ink);
+ font-size: 12px;
+ }
+}
+
+/* someone filling the panes: their picture, all of it, on dark, their name
+ in the corner, and you in the other. filling the screen, you're bigger */
+.patchwork-call-stage {
+ position: fixed;
+ z-index: 40;
+ box-sizing: border-box;
+
+ &[popover] {
+ inset: auto;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ max-width: none;
+ max-height: none;
+ }
+
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+ background: var(--call-stage-fill);
+ color: var(--call-stage-ink);
+ transition: opacity 180ms cubic-bezier(0.23, 1, 0.32, 1);
+
+ @starting-style {
+ opacity: 0;
+ }
+
+ & > .patchwork-call-face[data-shape="tile"] {
+ width: 100%;
+ height: 100%;
+ aspect-ratio: auto;
+ border: none;
+ border-radius: 0;
+ background-color: transparent;
+ font-size: 96px;
+
+ /* all of the picture, so nothing to centre */
+ & video {
+ object-fit: contain;
+ object-position: center !important;
+ }
+ }
+
+ & .patchwork-call-stage-name {
+ position: absolute;
+ left: 12px;
+ bottom: 12px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 8px;
+ border-radius: 4px;
+ background: var(--call-on-video-scrim);
+ font: 600 13px/1.3 var(--call-sans);
+ }
+
+ & .patchwork-call-stage-close {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ display: grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: none;
+ border-radius: 50%;
+ background: var(--call-on-video-scrim);
+ color: var(--call-stage-ink);
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity 160ms ease-out;
+ }
+
+ &:hover .patchwork-call-stage-close,
+ & .patchwork-call-stage-close:focus-visible {
+ opacity: 1;
+ }
+
+ & .patchwork-call-stage-me {
+ position: absolute;
+ right: 12px;
+ bottom: 12px;
+ width: clamp(96px, 18%, 220px);
+ border-radius: 8px;
+ box-shadow: 0 4px 16px var(--call-float-shadow);
+
+ & .patchwork-call-face {
+ border-radius: 8px;
+ }
+ }
+
+ &:fullscreen {
+ inset: 0 !important;
+ width: 100% !important;
+ height: 100% !important;
+
+ & .patchwork-call-stage-me {
+ right: 20px;
+ bottom: 20px;
+ width: clamp(160px, 22vw, 440px);
+ border-radius: 12px;
+
+ & .patchwork-call-face {
+ border-radius: 12px;
+ }
+ }
+
+ & .patchwork-call-stage-name {
+ left: 20px;
+ bottom: 20px;
+ font-size: 15px;
+ }
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ transition: none;
+ }
+}
+
+/* someone's screen, all of it, on dark; and their face small in its corner */
+.patchwork-call-screen {
+ display: grid;
+ width: 100%;
+ height: 100%;
+ background: var(--call-stage-fill);
+
+ & video {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ }
+}
+.patchwork-call-them {
+ position: absolute;
+ top: 12px;
+ left: 12px;
+ width: clamp(72px, 14%, 180px);
+ border-radius: 8px;
+ box-shadow: 0 4px 16px var(--call-float-shadow);
+
+ & .patchwork-call-face {
+ border-radius: 8px;
+ }
+}
+.patchwork-call-tiles[data-layout="focus"] .patchwork-call-focus > .patchwork-call-screen {
+ aspect-ratio: 4 / 3;
+ border-radius: 8px;
+ overflow: hidden;
+}
+.patchwork-call-tiles[data-layout="focus"] .patchwork-call-them {
+ top: 8px;
+ left: 8px;
+ width: 32%;
+}
+
+/* a floating face's minimise: an arrow before their name, down to the call
+ bar. quiet till it's pointed at */
+.patchwork-call-floating .patchwork-call-float-close {
+ display: inline-grid;
+ place-items: center;
+ width: 16px;
+ height: 16px;
+ margin: 0 2px 0 -3px;
+ padding: 0;
+ border: none;
+ border-radius: 3px;
+ background: transparent;
+ color: var(--call-muted);
+ vertical-align: -4px;
+ cursor: pointer;
+ transition:
+ background-color 120ms ease-out,
+ color 120ms ease-out,
+ scale 120ms ease-out;
+
+ &:active {
+ scale: 0.9;
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--call-accent);
+ outline-offset: 1px;
+ }
+}
+@media (hover: hover) and (pointer: fine) {
+ .patchwork-call-floating .patchwork-call-float-close:hover {
+ background: var(--call-chrome-hover);
+ color: var(--call-ink);
+ }
+}
+
+/* a face that can be dragged out (of the call bar) to float */
+.patchwork-call-drag {
+ display: inline-grid;
+ touch-action: none;
+}
+
+/* what's dragged out: a copy of the face, under the pointer (its place and
+ size are its own: ui.js's ghostOf) */
+.patchwork-call-ghost {
+ box-shadow: 0 4px 14px var(--call-float-shadow-soft);
+}
+
+/* one face filling the sidebar's box, you in its corner */
+.patchwork-call-tiles[data-layout="focus"] {
+ & .patchwork-call-focus {
+ position: relative;
+ display: block;
+ width: 100%;
+ padding: 0;
+ border: none;
+ background: none;
+ color: inherit;
+ font: inherit;
+ cursor: pointer;
+
+ & > .patchwork-call-face {
+ aspect-ratio: 3 / 4;
+ border-radius: 8px;
+ }
+ }
+
+ & .patchwork-call-focus-name {
+ position: absolute;
+ left: 8px;
+ bottom: 8px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ background: var(--call-on-video-scrim);
+ color: var(--call-stage-ink);
+ font: 600 11px/1.3 var(--call-sans);
+ }
+
+ & .patchwork-call-focus-me {
+ position: absolute;
+ right: 8px;
+ bottom: 8px;
+ width: 32%;
+ border-radius: 6px;
+ box-shadow: 0 2px 8px var(--call-float-shadow-soft);
+ }
+}
+
+/* the call you're in, above you: where it is, everyone's faces, and
+ your mic, camera and hanging up. or one on where you are, to join */
+.patchwork-call-panel {
+ display: grid;
+ flex: none;
+ gap: 6px;
+ padding: 8px 8px 8px 10px;
+ border-top: 1px solid var(--call-panel-rule);
+ font: 12px/1.3 var(--call-sans);
+ /* arriving (you join, or a call starts where you are): up from
+ below, as it comes in over you */
+ transition:
+ opacity 200ms cubic-bezier(0.23, 1, 0.32, 1),
+ translate 200ms cubic-bezier(0.23, 1, 0.32, 1);
+
+ @starting-style {
+ opacity: 0;
+ translate: 0 6px;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ transition: opacity 200ms ease-out;
+
+ @starting-style {
+ translate: none;
+ }
+ }
+}
+.patchwork-call-panel-head {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+.patchwork-call-panel-phone {
+ display: grid;
+ color: var(--call-panel-shell-primary);
+}
+.patchwork-call-panel-where {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ font: 600 12px var(--call-panel-path-family);
+ white-space: nowrap;
+ text-overflow: ellipsis;
+
+ /* where the call is, which takes you there */
+ &:is(button) {
+ padding: 0;
+ border: 0;
+ background: none;
+ color: inherit;
+ text-align: start;
+ cursor: var(--studio-cursor-pointer, pointer);
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
+
+/* who's on a call you're not in */
+.patchwork-call-panel-who {
+ margin: 2px 0 6px;
+ color: var(--call-panel-muted);
+ font-size: 12px;
+}
+.patchwork-call-panel-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 6px;
+}
+/* everyone's faces, you first. more than there's room for, they scroll
+ along (.patchwork-call-along). the padding's room for a speaking ring and
+ a muted badge, which the scrolling would cut off */
+.patchwork-call-panel-faces {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ min-width: 0;
+ margin: -4px;
+ padding: 4px;
+}
+/* a face to fill the panes with */
+.patchwork-call-panel-face-button {
+ display: inline-grid;
+ border-radius: 50%;
+
+ &[data-staged] .patchwork-call-face {
+ border-color: var(--call-panel-shell-primary);
+ }
+}
+.patchwork-call-panel-buttons {
+ display: flex;
+ flex: none;
+ gap: 2px;
+}
+.patchwork-call-panel-button {
+ display: grid;
+ place-items: center;
+ width: 26px;
+ height: 26px;
+ padding: 0;
+ border: none;
+ border-radius: 50%;
+ background: none;
+ color: var(--call-panel-muted);
+ cursor: pointer;
+ transition: scale 120ms ease-out;
+
+ &:hover {
+ background: var(--call-panel-shell-chrome-fill-offset-20);
+ color: inherit;
+ }
+ &:active {
+ scale: 0.95;
+ }
+ /* a mic that's muted, a camera that's off */
+ &[data-off] {
+ color: var(--call-danger);
+ }
+ /* your screen, being shared */
+ &[data-on],
+ &[data-on]:hover {
+ background: var(--call-panel-shell-primary);
+ color: var(--call-panel-shell-primary-line, white);
+ }
+}
+.patchwork-call-panel-hang-up {
+ background: var(--call-danger-fill);
+ color: var(--call-danger-ink);
+
+ &:hover {
+ background: var(--call-danger-fill);
+ color: var(--call-danger-ink);
+ filter: brightness(1.08);
+ }
+}
+.patchwork-call-panel-join {
+ padding: 2px 10px;
+ border: 1px solid var(--call-line);
+ border-radius: 999px;
+ background: var(--call-panel-shell-primary);
+ color: var(--call-panel-shell-primary-line);
+ font: 600 11px/1.4 var(--call-sans);
+ cursor: pointer;
+ transition: scale 120ms ease-out;
+
+ &:active {
+ scale: 0.95;
+ }
+}
+.patchwork-call-panel-problem {
+ margin: 0;
+ color: var(--call-danger);
+ font-size: 11px;
+}
+.patchwork-call-panel-note {
+ margin: 0;
+ color: var(--call-panel-muted);
+ font-size: 11px;
+}
+
+/* ── the video, a tab in a frame's sidebar ───────────────────────────────── */
+
+.patchwork-call-video {
+ box-sizing: border-box;
+ height: 100%;
+ overflow: auto;
+ padding: 8px;
+}
+
+.patchwork-call-video-none {
+ margin: 0;
+ color: var(--call-muted);
+ font: 12px var(--call-sans);
+}
+
+/* ── the call button, in a doc's title bar ───────────────────────────────── */
+
+.patchwork-call-title {
+ display: flex;
+ align-items: center;
+ height: 100%;
+}
+
+.patchwork-call-button {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ height: 22px;
+ padding: 0 var(--studio-space-xs, 0.375rem);
+ border: var(--studio-border-width, 1px) var(--studio-border-style, solid) var(--call-title-line);
+ border-radius: var(--studio-radius-sm, 4px);
+ background: var(--call-title-fill);
+ color: var(--call-title-ink);
+ font: inherit;
+ font-size: 0.8em;
+ font-weight: 500;
+ cursor: var(--studio-cursor-pointer, pointer);
+ transition:
+ border-color var(--studio-transition-fast, 0.1s ease),
+ background var(--studio-transition-fast, 0.1s ease);
+ user-select: none;
+
+ &:hover {
+ border-color: var(--call-title-line-hover);
+ background: var(--call-title-hover);
+ }
+
+ &:active {
+ scale: 0.97;
+ }
+
+ /* someone's on: join them */
+ &[data-state="join"] {
+ border-color: transparent;
+ background: var(--call-title-join);
+ color: var(--call-title-join-ink);
+ }
+
+ /* you're in it: hang up */
+ &[data-state="here"] {
+ border-color: transparent;
+ background: var(--call-title-here);
+ color: var(--call-title-here-ink);
+ }
+}
+
+.patchwork-call-button-count {
+ font-variant-numeric: tabular-nums;
+}
diff --git a/call/src/ui/ui.js b/call/src/ui/ui.js
new file mode 100644
index 00000000..5eb55815
--- /dev/null
+++ b/call/src/ui/ui.js
@@ -0,0 +1,816 @@
+// a call's faces, and the question before you're in one (from lattice's
+// wm/call/ui.js). someone's face in the control panel, clicked, fills the
+// stage (CallStage), and that, double clicked, the screen. in the video tab,
+// clicked, they fill its box. floating, each face can be put away, and
+// dragged out of the control panel again (DragOut)
+import { createEffect, createMemo, createSignal, For, Show, onCleanup } from "solid-js";
+import html from "solid-js/html";
+import { call, onCallIn, join, setFloating, hideFloating, showFloating, toggleStage, setFull, closeStage } from "../engine/call.js";
+import { tilesFor } from "../engine/sizes.js";
+import { hearing } from "../engine/speaking.js";
+import { ringWave, louder, wiggle, WIGGLE_HEARD, WIGGLE_HOLD } from "../engine/wiggle.js";
+import { faceImage } from "./avatar.js";
+import { centreOn } from "../engine/face.js";
+import { colourFor } from "./colour.js";
+import { icon } from "./icons.js";
+import { bringAlong } from "./along.js";
+
+const initial = (name) => (String(name ?? "")[0] ?? "?").toUpperCase();
+
+// "chee", "chee and mimi", "chee, mimi and agnes"
+export function names(list) {
+ if (list.length < 2) return list.join("");
+ return `${list.slice(0, -1).join(", ")} and ${list.at(-1)}`;
+}
+
+// a of `stream()` (null for none), always quiet: the sound is the
+// call's own to play, once. it's centred on `face()` (face.js), where that's
+// known, as its box and the picture change
+function videoOf(stream, face) {
+ const video = document.createElement("video");
+ video.autoplay = true;
+ video.muted = true;
+ video.playsInline = true;
+ const centre = () => centreOn(video, face());
+ video.addEventListener("loadedmetadata", centre);
+ video.addEventListener("resize", centre);
+ const resized = new ResizeObserver(centre);
+ resized.observe(video);
+ createEffect(centre);
+ onCleanup(() => resized.disconnect());
+ let tracks = -1;
+ createEffect(() => {
+ const now = stream();
+ const count = now?.getVideoTracks().length ?? 0;
+ // a track added to the stream it already has isn't always picked up
+ if (video.srcObject !== now || count !== tracks) {
+ video.srcObject = null;
+ video.srcObject = now;
+ }
+ tracks = count;
+ if (now) video.play().catch(() => {});
+ });
+ onCleanup(() => (video.srcObject = null));
+ return video;
+}
+
+/**
+ * someone in the call (`props.id`, or "me" for you), as a round face: their
+ * pictures while their camera's on, else their avatar, or their initial.
+ * `props.size` is in px; `props.shape` "round" (the default) or "tile";
+ * `props.muted` false leaves out saying they're muted (it's said beside
+ * their name instead)
+ */
+export function Face(props) {
+ const mine = () => props.id === "me";
+ const person = () =>
+ mine()
+ ? { ...call.me(), mic: call.get()?.mic, camera: call.get()?.camera, face: call.get()?.face }
+ : call.peers().find((peer) => peer.id === props.id);
+ const stream = () => (mine() ? call.local() : person()?.camera ? call.stream(props.id) : null);
+ const video = videoOf(stream, () => person()?.face ?? null);
+ const image = () => faceImage(person()?.avatar);
+ return html` props.shape ?? "round"}
+ data-mine=${() => (mine() ? "" : undefined)}
+ data-camera=${() => (stream() ? "" : undefined)}
+ data-connecting=${() => (!mine() && person()?.connection !== "connected" ? "" : undefined)}
+ data-speaking=${() => ((mine() ? call.get()?.speaking : person()?.speaking) ? "" : undefined)}
+ style=${() => ({
+ // theirs, or one for them, as presence would give them
+ "--call-face": person() ? person().colour || colourFor(person().contact || props.id) : null,
+ "--call-face-size": props.size ? `${props.size}px` : null,
+ "background-image": !stream() && image() ? `url("${image()}")` : null,
+ })}
+ title=${() => (props.title === false ? undefined : mine() ? "you" : person()?.name)}
+ >
+ ${() => (stream() ? video : image() ? "" : initial(person()?.name))}
+ <${Show} when=${() => props.muted !== false && person() && !person().mic}>
+ ${icon("mic-off", 9)}
+ />
+ <${Show} when=${() => (mine() ? call.get()?.screen : person()?.screen)}>
+ ${icon("screen", 9)}
+ />
+ `;
+}
+
+/** someone's screen (`props.id`), while they're sharing it: all of it */
+function Screen(props) {
+ const video = videoOf(() => call.screen(props.id), () => null);
+ return html`${video} `;
+}
+
+/**
+ * someone big (`props.id`): their screen while they're sharing it, with
+ * their face small in its corner, else their face
+ */
+function Picture(props) {
+ return html`<${Show}
+ when=${() => call.screen(props.id)}
+ fallback=${html`<${Face} id=${props.id} shape="tile" muted=${false} title=${false} />`}
+ >
+ <${Screen} id=${props.id} />
+ <${Face} id=${props.id} shape="tile" muted=${false} title=${false} />
+ />`;
+}
+
+/**
+ * the question: share your video, and call (or join the one that's on).
+ * shows when something's pressed to ask (call.js's ask), where it asked, or
+ * else near the top left. pressed, it's yours to answer: it has the focus
+ */
+export function CallQuestion() {
+ let popover;
+ let first;
+ const asking = call.asking;
+ const others = createMemo(() => (asking() ? onCallIn(asking().url) : []));
+ const elsewhere = () => {
+ const now = call.get();
+ return now && asking() && now.room !== asking().url ? now : null;
+ };
+
+ createEffect(() => {
+ const now = asking();
+ if (!popover) return;
+ if (!now) {
+ popover.removeAttribute("data-open");
+ if (popover.matches?.(":popover-open")) popover.hidePopover();
+ return;
+ }
+ const at = now.at ?? { x: 16, y: 16 };
+ popover.style.left = `${Math.max(8, Math.min(at.x, innerWidth - 268))}px`;
+ popover.style.top = `${Math.max(8, Math.min(at.y, innerHeight - 180))}px`;
+ popover.setAttribute("data-open", "");
+ try {
+ if (!popover.matches(":popover-open")) popover.showPopover();
+ } catch {}
+ first?.focus({ preventScroll: true });
+ });
+
+ function go(video) {
+ const now = asking();
+ call.stopAsking();
+ if (now) join(now.url, { video });
+ }
+
+ return html` (popover = el)}
+ onToggle=${(event) => event.newState === "closed" && call.stopAsking()}
+ >
+ <${Show}
+ when=${() => others().length}
+ fallback=${html`
start a call here?
`}
+ >
+
+
+ <${For} each=${others}>
+ ${(person) => {
+ const image = faceImage(person.who.avatar);
+ return html`${image ? "" : initial(person.who.name)} `;
+ }}
+ />
+
+
+ ${() => `${names([...new Set(others().map((person) => person.who.name))])} on a call`}
+
+
+ />
+
share your video?
+
+ (first = el)} onClick=${() => go(true)}>
+ ${icon("video", 13)} video on
+
+ go(false)}>${icon("mic", 13)} just voice
+
+
+
`;
+}
+
+// over everything (the top layer, as a frame's own popovers are), once it's
+// on the page; then `after()`
+function onTop(el, after) {
+ queueMicrotask(() => {
+ try {
+ if (el.isConnected && !el.matches(":popover-open")) el.showPopover();
+ } catch {}
+ after?.();
+ });
+}
+
+// back on top of the top layer, which stacks by what was shown last, not by
+// z-index: floating faces over the stage, as they were in lattice
+function raise(el) {
+ if (!el) return;
+ try {
+ if (!el.matches(":popover-open")) return;
+ el.hidePopover();
+ el.showPopover();
+ } catch {}
+}
+
+// where the stage goes, as a style: the box, or the whole window
+function boxStyle(box) {
+ return box
+ ? { left: `${box.left}px`, top: `${box.top}px`, width: `${box.width}px`, height: `${box.height}px` }
+ : { left: "0px", top: "0px", width: "100%", height: "100%" };
+}
+
+// a floating face's width, to start with, and as small and big as it goes
+const FLOAT_MIN = 72;
+const FLOAT_MAX = 640;
+// to start with, everyone else bigger than you
+const THEIR_WIDTH = 200;
+const MY_WIDTH = 120;
+
+// a little, but always the same little, for `key`: -1 to 1
+function nudge(key) {
+ let hash = 0;
+ for (const char of key) hash = (hash * 31 + char.charCodeAt(0)) | 0;
+ return ((Math.abs(hash) % 1000) / 1000) * 2 - 1;
+}
+
+// where a floating face starts (fractions of the window): you in the bottom
+// right, and everyone else (`index`: which of them) at the left of `panes`
+// (a box: where they are), near the call, a little way down, two across and
+// a few down, each nudged out of line a little
+function startAt(index, key, panes) {
+ if (key === "me") {
+ return {
+ x: 1 - (MY_WIDTH + 24) / innerWidth,
+ y: 1 - (MY_WIDTH * 0.75 + 56) / innerHeight,
+ width: MY_WIDTH,
+ };
+ }
+ const column = index % 2;
+ const row = Math.floor(index / 2);
+ const area = panes?.width ? panes : { left: innerWidth * 0.25, top: 0, height: innerHeight };
+ const left = area.left + 40 + column * (THEIR_WIDTH + 32) + (row % 2) * 48 + nudge(`${key}x`) * 20;
+ const top = area.top + area.height * 0.24 + row * (THEIR_WIDTH * 0.75 + 56) + nudge(`${key}y`) * 20;
+ return { x: left / innerWidth, y: top / innerHeight, width: THEIR_WIDTH };
+}
+
+// where a floating face goes: where it was put, but all of it in the window
+// (a face sized bigger near an edge moves away from it)
+function inWindow({ x, y, width }) {
+ const height = width * 0.75 + 24;
+ return {
+ left: `${Math.max(0, Math.min(x * innerWidth, innerWidth - width - 8))}px`,
+ top: `${Math.max(0, Math.min(y * innerHeight, innerHeight - height - 8))}px`,
+ };
+}
+
+// ── a floating face going to the call bar, and coming out of it ─────────
+
+// on screen, moving: in and out. coming in: out
+const EASE_MOVE = "cubic-bezier(0.77, 0, 0.175, 1)";
+const EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)";
+let stillMotion = null;
+const reducedMotion = () => (stillMotion ??= matchMedia("(prefers-reduced-motion: reduce)")).matches;
+
+// someone's face in the call bar: where their floating face goes to, and
+// comes from. scrolled along out of sight, it's brought back into it first
+function inBar(id) {
+ const face = document.querySelector(`.patchwork-call-panel [data-call-face="${CSS.escape(id)}"]`);
+ bringAlong(face);
+ return face;
+}
+
+// the transform that puts `float`'s picture over `target`, and the origin it's
+// from (the picture's middle), or null if either isn't on screen
+function towards(float, target) {
+ const picture = float.querySelector(".patchwork-call-float-picture")?.getBoundingClientRect();
+ const box = float.getBoundingClientRect();
+ const to = target?.getBoundingClientRect();
+ if (!picture?.width || !to?.width) return null;
+ const x = picture.left + picture.width / 2;
+ const y = picture.top + picture.height / 2;
+ return {
+ origin: `${x - box.left}px ${y - box.top}px`,
+ transform: `translate(${to.left + to.width / 2 - x}px, ${to.top + to.height / 2 - y}px) scale(${to.width / picture.width})`,
+ };
+}
+
+// put away: it goes into their face in the call bar, then it's gone
+function minimise(float, id, done) {
+ const to = reducedMotion() ? null : towards(float, inBar(id));
+ float.style.pointerEvents = "none";
+ const motion = to
+ ? float.animate(
+ [
+ { transformOrigin: to.origin, transform: "none", opacity: 1 },
+ { transformOrigin: to.origin, transform: to.transform, opacity: 0.3 },
+ ],
+ { duration: 240, easing: EASE_MOVE, fill: "forwards" },
+ )
+ : float.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 120, easing: "ease-out", fill: "forwards" });
+ motion.finished.then(done, done);
+}
+
+// the ones just dragged out of the call bar: they're where they were let go,
+// not coming out of it
+const dropped = new Set();
+
+// arriving (you've joined, they have, or faces are floating now): out of
+// their face in the call bar to where it goes, one after another
+function arrive(float, id, key, index) {
+ const plain = dropped.delete(key) || reducedMotion();
+ const from = plain ? null : towards(float, inBar(id));
+ if (!from) {
+ float.animate([{ opacity: 0, scale: 0.95 }, { opacity: 1, scale: 1 }], { duration: plain ? 160 : 200, easing: EASE_OUT });
+ return;
+ }
+ float.animate(
+ [
+ { transformOrigin: from.origin, transform: from.transform, opacity: 0.3 },
+ { transformOrigin: from.origin, transform: "none", opacity: 1 },
+ ],
+ { duration: 280, delay: Math.min(index, 8) * 40, easing: EASE_OUT, fill: "backwards" },
+ );
+}
+
+// ── the wiggle round a floating face, while they're speaking ───────────
+
+const WIGGLE_POINTS = 160;
+// how much of their sound goes round (of the analyser's 2048: about 20ms)
+const WIGGLE_SPAN = 1024;
+// how far past the picture it can go (the canvas is that much bigger)
+const WIGGLE_PAD = 32;
+// a face's border (styles.css): the line that wiggles
+const FACE_BORDER = 2;
+// a floating face's corners, as a picture (styles.css)
+const TILE_ROUND = 10;
+
+/**
+ * their sound's waveform, all the way round their floating face (in its
+ * picture's box): its border, bumping out from where it is, as soon as
+ * there's sound, and while `props.speaking`. behind the face, so still it's
+ * the border. not with reduced motion (the ring's shown then, as elsewhere)
+ */
+function Wiggle(props) {
+ const canvas = document.createElement("canvas");
+ canvas.className = "patchwork-call-wiggle";
+ canvas.setAttribute("aria-hidden", "true");
+ let frame = requestAnimationFrame(draw);
+ onCleanup(() => cancelAnimationFrame(frame));
+ // how much it's shown (0 to 1), and whether there's anything to clear
+ let shown = 0;
+ let drawn = false;
+ let samples = null;
+ let loudest = 0;
+ let heardAt = -Infinity;
+ let colour = null;
+ let colourAt = -Infinity;
+
+ function draw(now) {
+ frame = requestAnimationFrame(draw);
+ const analyser = !reducedMotion() && hearing(props.id);
+ if (analyser) {
+ if (samples?.length !== analyser.fftSize) samples = new Float32Array(analyser.fftSize);
+ analyser.getFloatTimeDomainData(samples);
+ let sum = 0;
+ for (const sample of samples) sum += sample * sample;
+ if (Math.sqrt(sum / samples.length) >= WIGGLE_HEARD) heardAt = now;
+ }
+ const wanted = !!analyser && (now - heardAt < WIGGLE_HOLD || !!props.speaking);
+ shown += ((wanted ? 1 : 0) - shown) * (wanted ? 0.6 : 0.3);
+ if (shown < 0.01 && !drawn) return;
+ const picture = canvas.parentElement;
+ const face = picture?.querySelector(".patchwork-call-face");
+ if (!face) return;
+ const g = canvas.getContext("2d");
+ const dpr = devicePixelRatio || 1;
+ const width = picture.offsetWidth + 2 * WIGGLE_PAD;
+ const height = picture.offsetHeight + 2 * WIGGLE_PAD;
+ if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
+ canvas.width = Math.round(width * dpr);
+ canvas.height = Math.round(height * dpr);
+ }
+ g.setTransform(dpr, 0, 0, dpr, 0, 0);
+ g.clearRect(0, 0, width, height);
+ drawn = false;
+ if (shown < 0.01 || !analyser) return;
+
+ const heard = louder(ringWave(samples, WIGGLE_POINTS, WIGGLE_SPAN), loudest);
+ loudest = heard.loudest;
+ // the face's box, and its corners: between a picture's and a circle's
+ // as its width goes from the whole picture to round (styles.css)
+ const w = face.offsetWidth;
+ const h = face.offsetHeight;
+ const round = Math.max(0, Math.min(1, (picture.offsetWidth - w) / (picture.offsetWidth * 0.25)));
+ const r = TILE_ROUND + (Math.min(w, h) / 2 - TILE_ROUND) * round;
+ // out from the middle of the border, either way of the sound as out
+ const swing = Math.max(4, Math.min(9, Math.min(w, h) * 0.07)) * shown;
+ const points = wiggle(w, h, r, heard.values.map(Math.abs), -FACE_BORDER / 2, swing);
+
+ if (now - colourAt > 500) {
+ colour = getComputedStyle(face).borderTopColor;
+ colourAt = now;
+ }
+ g.translate(WIGGLE_PAD + face.offsetLeft + w / 2, WIGGLE_PAD + face.offsetTop + h / 2);
+ g.beginPath();
+ g.moveTo(...points[0]);
+ for (const point of points) g.lineTo(...point);
+ g.closePath();
+ g.strokeStyle = colour;
+ g.globalAlpha = shown;
+ g.lineWidth = FACE_BORDER;
+ g.lineJoin = "round";
+ g.stroke();
+ g.globalAlpha = 1;
+ drawn = true;
+ }
+ return canvas;
+}
+
+/**
+ * everyone's faces, floating over everything, each where you put it and the
+ * size you made it (to start with, near `props.area`, the stage's box):
+ * dragged to move, and by its corner to size. each is a
+ * circle centred on their face, or, double clicked, the picture's own shape
+ * (4:3), none of it cut off (and double clicked again, a circle)
+ */
+export function CallFloating(props) {
+ // you too, last: by id, so each keeps its place as people come and go.
+ // not the ones put away (dragged out of the call bar, they're back)
+ const ids = createMemo(() =>
+ [...call.peers().map((peer) => peer.id), "me"].filter((id) => !call.settings.floating[call.floatKey(id)]?.hidden),
+ );
+ // (in the sidebar, but nothing's showing the sidebar's: floating meanwhile)
+ return html`<${Show} when=${() => call.get() && (call.mode() === "floating" || !call.videoShowing())}>
+
+ <${For} each=${ids}>
+ ${(id, index) => {
+ const mine = id === "me";
+ const peer = () => (mine ? null : call.peers().find((peer) => peer.id === id));
+ const key = () => call.floatKey(id);
+ const name = () => (mine ? "you" : peer()?.name);
+ const muted = () => (mine ? !call.get()?.mic : !peer()?.mic);
+ const at = () => ({ ...startAt(index(), key(), props.area), ...call.settings.floating[key()] });
+ let float;
+ // what's being done to it: {sizing, dx, dy (or the width it was), at}
+ let dragging = null;
+ function onPointerDown(event) {
+ if (event.button !== 0 || event.target.closest(".patchwork-call-float-close")) return;
+ const box = float.getBoundingClientRect();
+ const sizing = !!event.target.closest(".patchwork-call-size");
+ dragging = sizing
+ ? { sizing, x: event.clientX, width: at().width, at: at() }
+ : { sizing, dx: event.clientX - box.left, dy: event.clientY - box.top, at: at() };
+ float.setPointerCapture(event.pointerId);
+ event.preventDefault();
+ }
+ function onPointerMove(event) {
+ if (!dragging) return;
+ if (dragging.sizing) {
+ const width = Math.round(Math.max(FLOAT_MIN, Math.min(FLOAT_MAX, dragging.width + event.clientX - dragging.x)));
+ dragging.at = { ...dragging.at, width };
+ Object.assign(float.style, { ...inWindow(dragging.at), width: `${width}px` });
+ return;
+ }
+ const x = Math.max(0, Math.min(1, (event.clientX - dragging.dx) / innerWidth));
+ const y = Math.max(0, Math.min(1, (event.clientY - dragging.dy) / innerHeight));
+ dragging.at = { ...dragging.at, x, y };
+ Object.assign(float.style, inWindow(dragging.at));
+ }
+ function onPointerUp() {
+ if (dragging) setFloating(key(), dragging.at);
+ dragging = null;
+ }
+ const shape = () => (at().shape === "tile" ? "tile" : "round");
+ return html`
(call.stage() && (mine || call.stage() === id) ? "" : undefined)}
+ onDblClick=${() => setFloating(key(), { ...at(), shape: shape() === "round" ? "tile" : "round" })}
+ ref=${(el) => {
+ float = el;
+ // once it's where it goes
+ requestAnimationFrame(() => float.isConnected && arrive(float, id, key(), index()));
+ }}
+ style=${() => ({ ...inWindow(at()), width: `${at().width}px` })}
+ onPointerDown=${onPointerDown}
+ onPointerMove=${onPointerMove}
+ onPointerUp=${onPointerUp}
+ onPointerCancel=${onPointerUp}
+ >
+
+ <${Wiggle} id=${id} speaking=${() => (mine ? call.get()?.speaking : peer()?.speaking)} />
+ <${Face} id=${id} shape="tile" muted=${false} />
+
+
+
+ `minimise ${mine ? "your face" : `${name()}'s face`}`}
+ title="minimise to the call bar (click them there, or drag them out, to have them back)"
+ onClick=${() => minimise(float, id, () => hideFloating(key()))}
+ onDblClick=${(event) => event.stopPropagation()}
+ >
+ ${icon("minimise", 11)}
+
+ ${name}
+ <${Show} when=${muted}>
+ ${icon("mic-off", 10)}
+ />
+
+
`;
+ }}
+ />
+
+ />`;
+}
+
+// a face dragged out (of the call bar) and let go somewhere else: their
+// floating face is back, there. only while faces are floating. from the
+// press, the whole window's listened to (a little face is soon left behind
+// by the pointer), and the browser's own dragging is kept out of it.
+// returns the handler for the press, and whether what just happened was a
+// drag (so it's not a click too)
+const DRAG_FROM = 5;
+const GHOST_SIZE = 56;
+function dragOut(id) {
+ let dragged = false;
+ function onPointerDown(event) {
+ if (event.button !== 0 || call.mode() !== "floating") return;
+ const from = event.target.closest("[data-call-face]");
+ if (!from) return;
+ // no text selected, no picture dragged by the browser
+ event.preventDefault();
+ dragged = false;
+ const start = { x: event.clientX, y: event.clientY };
+ let ghost = null;
+ const move = (moved) => {
+ if (moved.pointerId !== event.pointerId) return;
+ if (!ghost) {
+ if (Math.hypot(moved.clientX - start.x, moved.clientY - start.y) < DRAG_FROM) return;
+ ghost = ghostOf(from.querySelector(".patchwork-call-face"));
+ }
+ ghost.style.left = `${moved.clientX}px`;
+ ghost.style.top = `${moved.clientY}px`;
+ };
+ const stop = () => {
+ removeEventListener("pointermove", move, true);
+ removeEventListener("pointerup", up, true);
+ removeEventListener("pointercancel", cancel, true);
+ ghost?.remove();
+ };
+ const cancel = (ended) => {
+ if (ended.pointerId === event.pointerId) stop();
+ };
+ const up = (ended) => {
+ if (ended.pointerId !== event.pointerId) return;
+ const was = !!ghost;
+ stop();
+ if (!was) return;
+ dragged = true;
+ // let go back over the call bar: never mind
+ if (from.closest(".patchwork-call-panel")?.contains(document.elementFromPoint(ended.clientX, ended.clientY))) return;
+ const key = call.floatKey(id);
+ const width = call.settings.floating[key]?.width ?? (key === "me" ? MY_WIDTH : THEIR_WIDTH);
+ dropped.add(key);
+ showFloating(key, {
+ x: Math.max(0, (ended.clientX - width / 2) / innerWidth),
+ y: Math.max(0, (ended.clientY - (width * 0.75) / 2) / innerHeight),
+ width,
+ });
+ };
+ addEventListener("pointermove", move, true);
+ addEventListener("pointerup", up, true);
+ addEventListener("pointercancel", cancel, true);
+ }
+ return {
+ onPointerDown,
+ // (once: the click after a drag)
+ wasDrag: () => {
+ const was = dragged;
+ dragged = false;
+ return was;
+ },
+ };
+}
+
+// what's dragged: a copy of the face, pictures and all, under the pointer.
+// its look is its own, so nothing it's dragged over or out of shapes it
+function ghostOf(face) {
+ const ghost = face.cloneNode(true);
+ const video = face.querySelector("video");
+ const copy = ghost.querySelector("video");
+ if (video && copy) {
+ copy.srcObject = video.srcObject;
+ copy.muted = true;
+ copy.play().catch(() => {});
+ }
+ ghost.classList.add("patchwork-call-ghost");
+ Object.assign(ghost.style, {
+ position: "fixed",
+ zIndex: "2147483000",
+ width: `${GHOST_SIZE}px`,
+ height: `${GHOST_SIZE}px`,
+ translate: "-50% -50%",
+ pointerEvents: "none",
+ margin: "0",
+ });
+ ghost.style.setProperty("--call-face-size", `${GHOST_SIZE}px`);
+ document.body.append(ghost);
+ return ghost;
+}
+
+// someone minimised (floating, put away): their floating face back where it
+// was, out of the call bar. whether they were
+function restore(id) {
+ const key = call.floatKey(id);
+ if (call.mode() !== "floating" || !call.settings.floating[key]?.hidden) return false;
+ showFloating(key, {});
+ return true;
+}
+
+/**
+ * `props.id`'s face (props.children), to drag out of where it is into a
+ * floating face, or, minimised, click to have it back
+ */
+export function DragOut(props) {
+ const drag = dragOut(props.id);
+ return html` (props.id === "me" ? "restore your floating face" : "restore floating face")}
+ data-call-face=${() => props.id}
+ title=${() => (call.mode() === "floating" ? "drag out to float (minimised: click to have it back)" : undefined)}
+ onPointerDown=${drag.onPointerDown}
+ onDragStart=${(event) => event.preventDefault()}
+ onClick=${() => !drag.wasDrag() && restore(props.id)}
+ >
+ ${props.children}
+ `;
+}
+
+/**
+ * a face to click to fill the panes with them (again: the panes back), or,
+ * floating, drag out to float, and minimised, click to have back where it
+ * was. `props.id`; the rest's what's in it
+ */
+export function StageButton(props) {
+ const drag = dragOut(props.id);
+ return html` `patchwork-call-stage-button ${props.class ?? ""}`}
+ data-call-face=${() => props.id}
+ data-staged=${() => (call.stage() === props.id ? "" : undefined)}
+ data-arriving=${props.arriving}
+ aria-pressed=${() => String(call.stage() === props.id)}
+ title=${() =>
+ call.stage() === props.id
+ ? "back to the panes"
+ : call.mode() === "floating"
+ ? "fill the panes with them (or drag out to float)"
+ : "fill the panes with them"}
+ onPointerDown=${drag.onPointerDown}
+ onDragStart=${(event) => event.preventDefault()}
+ onClick=${() => !drag.wasDrag() && !restore(props.id) && toggleStage(props.id)}
+ >
+ ${props.children}
+ `;
+}
+
+/**
+ * everyone's faces, for a sidebar, yours last: big while there are few, two
+ * across when there are more, and past that a list, whoever spoke last at
+ * the top. one clicked fills the box, you in its corner; clicked again, all
+ * of them
+ */
+export function CallTiles() {
+ const [focus, setFocus] = createSignal(null);
+ const focused = () => (focus() && call.peers().some((peer) => peer.id === focus()) ? focus() : null);
+ const count = () => call.peers().length + 1;
+ const layout = () => tilesFor(count());
+ const peers = createMemo(() => {
+ const list = call.peers();
+ if (layout() !== "chips") return list;
+ return [...list].sort((a, b) => (b.speaking ? 1 : 0) - (a.speaking ? 1 : 0) || (b.spoke ?? 0) - (a.spoke ?? 0));
+ });
+ const name = (id) => call.peers().find((peer) => peer.id === id)?.name;
+
+ const Focused = () => html`
+ setFocus(null)}>
+ <${Show} when=${focused} keyed>${(id) => html`<${Picture} id=${id} />`}/>
+ ${() => name(focused())}
+ <${Face} id="me" shape="tile" title=${false} />
+
+
`;
+
+ return html`<${Show} when=${focused} fallback=${html`
+ <${For} each=${peers}>
+ ${(peer) => html`
setFocus(peer.id)}
+ >
+ <${Show}
+ when=${() => layout() !== "chips"}
+ fallback=${html`<${Face} id=${peer.id} size=${28} title=${false} />`}
+ >
+ <${Face} id=${peer.id} shape="tile" title=${false} />
+ />
+ ${() => peer.name}
+ `}
+ />
+ <${Show} when=${() => call.get()}>
+
+ <${Show}
+ when=${() => layout() !== "chips"}
+ fallback=${html`<${Face} id="me" size=${28} title=${false} />`}
+ >
+ <${Face} id="me" shape="tile" title=${false} />
+ />
+ you
+
+ />
+
`}>
+ ${Focused}
+ />`;
+}
+
+/**
+ * whoever's been clicked (call.stage()), filling `props.area` (the
+ * stage's box, or else the window), with you in its corner. double clicked,
+ * it fills the screen, you a little bigger; escape, or its ×, puts it away
+ */
+export function CallStage(props) {
+ let stage;
+ const id = () => call.stage();
+ const person = () => call.peers().find((peer) => peer.id === id());
+
+ // the whole screen, while it's wanted, and not once it's left (escape)
+ createEffect(() => {
+ // (read first: it's what this runs again for)
+ const full = call.full();
+ id();
+ if (!stage?.isConnected) return;
+ if (full && document.fullscreenElement !== stage) {
+ stage.requestFullscreen?.().catch(() => setFull(false));
+ } else if (!full && document.fullscreenElement === stage) {
+ document.exitFullscreen().catch(() => {});
+ }
+ });
+ const onFullscreen = () => {
+ if (call.full() && document.fullscreenElement !== stage) setFull(false);
+ };
+ const onKey = (event) => {
+ if (event.key === "Escape" && id() && !document.fullscreenElement) closeStage();
+ };
+ document.addEventListener("fullscreenchange", onFullscreen);
+ document.addEventListener("keydown", onKey);
+ onCleanup(() => {
+ document.removeEventListener("fullscreenchange", onFullscreen);
+ document.removeEventListener("keydown", onKey);
+ if (stage && document.fullscreenElement === stage) document.exitFullscreen().catch(() => {});
+ });
+
+ return html`<${Show} when=${id}>
+ {
+ stage = el;
+ onTop(el, () => raise(el.closest(".patchwork-call")?.querySelector(".patchwork-call-floating")));
+ }}
+ style=${() => boxStyle(props.area)}
+ onDblClick=${() => setFull(!call.full())}
+ title="double click: the whole screen"
+ >
+ <${Show} when=${id} keyed>
+ ${(staged) => html`<${Picture} id=${staged} />`}
+ />
+
+ ${() => (call.screen(id()) ? `${person()?.name}'s screen` : person()?.name)}
+ <${Show} when=${() => person() && !person().mic}>${icon("mic-off", 12)}/>
+
+ event.stopPropagation()}
+ >
+ ${icon("close", 14)}
+
+
+ <${Face} id="me" shape="tile" title=${false} />
+
+
+ />`;
+}
diff --git a/call/src/vendor/deepfilternet/LICENSE b/call/src/vendor/deepfilternet/LICENSE
new file mode 100644
index 00000000..24a7bc4c
--- /dev/null
+++ b/call/src/vendor/deepfilternet/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2026 WorkAdvetu.re
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/call/src/vendor/deepfilternet/deepfilternet-worklet-processor.js b/call/src/vendor/deepfilternet/deepfilternet-worklet-processor.js
new file mode 100644
index 00000000..ecb01656
--- /dev/null
+++ b/call/src/vendor/deepfilternet/deepfilternet-worklet-processor.js
@@ -0,0 +1,634 @@
+// vendored from @workadventure/noise-suppression@0.2.0 (MIT, ./LICENSE):
+// dist/assets/deepfilternet-worklet-processor.js, as it is. DeepFilterNet's
+// libDF, compiled to wasm, run in an AudioWorklet
+(function() {
+ //#region src/audio-worklet-global-scope-shim.ts
+ if (!("self" in globalThis)) Object.defineProperty(globalThis, "self", {
+ configurable: true,
+ value: globalThis
+ });
+ if (!("location" in globalThis)) Object.defineProperty(globalThis, "location", {
+ configurable: true,
+ value: { href: "https://audio-worklet.local/audio-worklet-processor.js" }
+ });
+ if (!("TextDecoder" in globalThis)) {
+ class AudioWorkletTextDecoder {
+ encoding;
+ constructor(label = "utf-8") {
+ this.encoding = label.toLowerCase();
+ }
+ decode(input) {
+ const bytes = this.toBytes(input);
+ if (this.encoding === "utf-16le" || this.encoding === "utf-16") return this.decodeUtf16Le(bytes);
+ return this.decodeUtf8(bytes);
+ }
+ toBytes(input) {
+ if (input === void 0) return new Uint8Array();
+ if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
+ return new Uint8Array(input);
+ }
+ decodeUtf16Le(bytes) {
+ const codeUnits = [];
+ for (let i = 0; i + 1 < bytes.length; i += 2) codeUnits.push(bytes[i] | bytes[i + 1] << 8);
+ return String.fromCharCode(...codeUnits);
+ }
+ decodeUtf8(bytes) {
+ const codePoints = [];
+ for (let i = 0; i < bytes.length;) {
+ const first = bytes[i++];
+ if (first < 128) {
+ codePoints.push(first);
+ continue;
+ }
+ if ((first & 224) === 192 && i < bytes.length) {
+ codePoints.push((first & 31) << 6 | bytes[i++] & 63);
+ continue;
+ }
+ if ((first & 240) === 224 && i + 1 < bytes.length) {
+ codePoints.push((first & 15) << 12 | (bytes[i++] & 63) << 6 | bytes[i++] & 63);
+ continue;
+ }
+ if ((first & 248) === 240 && i + 2 < bytes.length) {
+ codePoints.push((first & 7) << 18 | (bytes[i++] & 63) << 12 | (bytes[i++] & 63) << 6 | bytes[i++] & 63);
+ continue;
+ }
+ codePoints.push(65533);
+ }
+ return String.fromCodePoint(...codePoints);
+ }
+ }
+ Object.defineProperty(globalThis, "TextDecoder", {
+ configurable: true,
+ value: AudioWorkletTextDecoder
+ });
+ }
+ if (!("URL" in globalThis)) {
+ class AudioWorkletURL {
+ href;
+ constructor(url, base) {
+ if (base !== void 0 && url === ".") {
+ const lastSlash = base.lastIndexOf("/");
+ this.href = lastSlash >= 0 ? base.slice(0, lastSlash + 1) : base;
+ return;
+ }
+ this.href = url;
+ }
+ toString() {
+ return this.href;
+ }
+ static createObjectURL() {
+ throw new Error("URL.createObjectURL is not available in AudioWorklet.");
+ }
+ static revokeObjectURL() {}
+ }
+ Object.defineProperty(globalThis, "URL", {
+ configurable: true,
+ value: AudioWorkletURL
+ });
+ }
+ if (!("crypto" in globalThis)) Object.defineProperty(globalThis, "crypto", {
+ configurable: true,
+ value: { getRandomValues(array) {
+ if (array) {
+ const bytes = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
+ for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
+ }
+ return array;
+ } }
+ });
+ if (!("TextEncoder" in globalThis)) {
+ class AudioWorkletTextEncoder {
+ encoding = "utf-8";
+ encode(input = "") {
+ const bytes = [];
+ for (const char of input) {
+ const code = char.codePointAt(0) ?? 0;
+ if (code < 128) bytes.push(code);
+ else if (code < 2048) bytes.push(192 | code >> 6, 128 | code & 63);
+ else if (code < 65536) bytes.push(224 | code >> 12, 128 | code >> 6 & 63, 128 | code & 63);
+ else bytes.push(240 | code >> 18, 128 | code >> 12 & 63, 128 | code >> 6 & 63, 128 | code & 63);
+ }
+ return Uint8Array.from(bytes);
+ }
+ }
+ Object.defineProperty(globalThis, "TextEncoder", {
+ configurable: true,
+ value: AudioWorkletTextEncoder
+ });
+ }
+ //#endregion
+ //#region forks/deepfilternet/df.js
+ var DFState = class {
+ __destroy_into_raw() {
+ const ptr = this.__wbg_ptr;
+ this.__wbg_ptr = 0;
+ DFStateFinalization.unregister(this);
+ return ptr;
+ }
+ free() {
+ const ptr = this.__destroy_into_raw();
+ wasm.__wbg_dfstate_free(ptr, 0);
+ }
+ };
+ if (Symbol.dispose) DFState.prototype[Symbol.dispose] = DFState.prototype.free;
+ /**
+ * Create a DeepFilterNet Model
+ *
+ * Args:
+ * - path: File path to a DeepFilterNet tar.gz onnx model
+ * - atten_lim: Attenuation limit in dB.
+ *
+ * Returns:
+ * - DF state doing the full processing: stft, DNN noise reduction, istft.
+ * @param {Uint8Array} model_bytes
+ * @param {number} atten_lim
+ * @returns {number}
+ */
+ function df_create(model_bytes, atten_lim) {
+ const ptr0 = passArray8ToWasm0(model_bytes, wasm.__wbindgen_malloc_command_export);
+ const len0 = WASM_VECTOR_LEN;
+ return wasm.df_create(ptr0, len0, atten_lim) >>> 0;
+ }
+ /**
+ * Get DeepFilterNet frame size in samples.
+ * @param {number} st
+ * @returns {number}
+ */
+ function df_get_frame_length(st) {
+ return wasm.df_get_frame_length(st) >>> 0;
+ }
+ /**
+ * Processes a chunk of samples.
+ *
+ * Args:
+ * - df_state: Created via df_create()
+ * - input: Input buffer of length df_get_frame_length()
+ * - output: Output buffer of length df_get_frame_length()
+ *
+ * Returns:
+ * - Local SNR of the current frame.
+ * @param {number} st
+ * @param {Float32Array} input
+ * @returns {Float32Array}
+ */
+ function df_process_frame(st, input) {
+ const ptr0 = passArrayF32ToWasm0(input, wasm.__wbindgen_malloc_command_export);
+ const len0 = WASM_VECTOR_LEN;
+ return wasm.df_process_frame(st, ptr0, len0);
+ }
+ function __wbg_get_imports() {
+ return {
+ __proto__: null,
+ "./df_bg.js": {
+ __proto__: null,
+ __wbg___wbindgen_throw_6b64449b9b9ed33c: function(arg0, arg1) {
+ throw new Error(getStringFromWasm0(arg0, arg1));
+ },
+ __wbg_getRandomValues_cc7f052a444bb2ce: function() {
+ return handleError(function(arg0, arg1) {
+ globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
+ }, arguments);
+ },
+ __wbg_new_from_slice_b6858b485924da4e: function(arg0, arg1) {
+ return new Float32Array(getArrayF32FromWasm0(arg0, arg1));
+ },
+ __wbindgen_init_externref_table: function() {
+ const table = wasm.__wbindgen_externrefs;
+ const offset = table.grow(4);
+ table.set(0, void 0);
+ table.set(offset + 0, void 0);
+ table.set(offset + 1, null);
+ table.set(offset + 2, true);
+ table.set(offset + 3, false);
+ }
+ }
+ };
+ }
+ var DFStateFinalization = typeof FinalizationRegistry === "undefined" ? {
+ register: () => {},
+ unregister: () => {}
+ } : new FinalizationRegistry((ptr) => wasm.__wbg_dfstate_free(ptr >>> 0, 1));
+ function addToExternrefTable0(obj) {
+ const idx = wasm.__externref_table_alloc_command_export();
+ wasm.__wbindgen_externrefs.set(idx, obj);
+ return idx;
+ }
+ function getArrayF32FromWasm0(ptr, len) {
+ ptr = ptr >>> 0;
+ return getFloat32ArrayMemory0().subarray(ptr / 4, ptr / 4 + len);
+ }
+ function getArrayU8FromWasm0(ptr, len) {
+ ptr = ptr >>> 0;
+ return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
+ }
+ var cachedFloat32ArrayMemory0 = null;
+ function getFloat32ArrayMemory0() {
+ if (cachedFloat32ArrayMemory0 === null || cachedFloat32ArrayMemory0.byteLength === 0) cachedFloat32ArrayMemory0 = new Float32Array(wasm.memory.buffer);
+ return cachedFloat32ArrayMemory0;
+ }
+ function getStringFromWasm0(ptr, len) {
+ ptr = ptr >>> 0;
+ return decodeText(ptr, len);
+ }
+ var cachedUint8ArrayMemory0 = null;
+ function getUint8ArrayMemory0() {
+ if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
+ return cachedUint8ArrayMemory0;
+ }
+ function handleError(f, args) {
+ try {
+ return f.apply(this, args);
+ } catch (e) {
+ const idx = addToExternrefTable0(e);
+ wasm.__wbindgen_exn_store_command_export(idx);
+ }
+ }
+ function passArray8ToWasm0(arg, malloc) {
+ const ptr = malloc(arg.length * 1, 1) >>> 0;
+ getUint8ArrayMemory0().set(arg, ptr / 1);
+ WASM_VECTOR_LEN = arg.length;
+ return ptr;
+ }
+ function passArrayF32ToWasm0(arg, malloc) {
+ const ptr = malloc(arg.length * 4, 4) >>> 0;
+ getFloat32ArrayMemory0().set(arg, ptr / 4);
+ WASM_VECTOR_LEN = arg.length;
+ return ptr;
+ }
+ var cachedTextDecoder = new TextDecoder("utf-8", {
+ ignoreBOM: true,
+ fatal: true
+ });
+ cachedTextDecoder.decode();
+ var MAX_SAFARI_DECODE_BYTES = 2146435072;
+ var numBytesDecoded = 0;
+ function decodeText(ptr, len) {
+ numBytesDecoded += len;
+ if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
+ cachedTextDecoder = new TextDecoder("utf-8", {
+ ignoreBOM: true,
+ fatal: true
+ });
+ cachedTextDecoder.decode();
+ numBytesDecoded = len;
+ }
+ return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
+ }
+ var WASM_VECTOR_LEN = 0, wasm;
+ function __wbg_finalize_init(instance, module) {
+ wasm = instance.exports;
+ cachedFloat32ArrayMemory0 = null;
+ cachedUint8ArrayMemory0 = null;
+ wasm.__wbindgen_start();
+ return wasm;
+ }
+ function initSync(module) {
+ if (wasm !== void 0) return wasm;
+ if (module !== void 0) if (Object.getPrototypeOf(module) === Object.prototype) ({module} = module);
+ else console.warn("using deprecated parameters for `initSync()`; pass a single object instead");
+ const imports = __wbg_get_imports();
+ if (!(module instanceof WebAssembly.Module)) module = new WebAssembly.Module(module);
+ return __wbg_finalize_init(new WebAssembly.Instance(module, imports), module);
+ }
+ //#endregion
+ //#region src/float32-ring-buffer.ts
+ var Float32RingBuffer = class {
+ storage;
+ readIndex = 0;
+ writeIndex = 0;
+ availableSamples = 0;
+ constructor(size) {
+ this.storage = new Float32Array(size);
+ }
+ availableRead() {
+ return this.availableSamples;
+ }
+ availableWrite() {
+ return this.storage.length - this.availableSamples;
+ }
+ push(source) {
+ if (source.length > this.availableWrite()) throw new Error("AudioWorklet ring buffer overflow.");
+ let remaining = source.length;
+ let sourceOffset = 0;
+ while (remaining > 0) {
+ const chunk = Math.min(remaining, this.storage.length - this.writeIndex);
+ this.storage.set(source.subarray(sourceOffset, sourceOffset + chunk), this.writeIndex);
+ this.writeIndex = (this.writeIndex + chunk) % this.storage.length;
+ this.availableSamples += chunk;
+ remaining -= chunk;
+ sourceOffset += chunk;
+ }
+ }
+ pullInto(target) {
+ if (target.length > this.availableSamples) return false;
+ let remaining = target.length;
+ let targetOffset = 0;
+ while (remaining > 0) {
+ const chunk = Math.min(remaining, this.storage.length - this.readIndex);
+ target.set(this.storage.subarray(this.readIndex, this.readIndex + chunk), targetOffset);
+ this.readIndex = (this.readIndex + chunk) % this.storage.length;
+ this.availableSamples -= chunk;
+ remaining -= chunk;
+ targetOffset += chunk;
+ }
+ return true;
+ }
+ clear() {
+ this.readIndex = 0;
+ this.writeIndex = 0;
+ this.availableSamples = 0;
+ }
+ };
+ //#endregion
+ //#region src/pause-gate.ts
+ var INPUT_HISTORY_FRAMES = 4;
+ var DIGITAL_SILENCE_DB = -90;
+ /**
+ * Attenuates the pauses of an already denoised signal, frame by frame.
+ *
+ * The denoiser runs at a gentle limit so speech keeps no gating artefacts; this gate removes up to
+ * `extraAttenuationDb` more in pauses. Speech is detected on the denoised frames, where it stands far above the
+ * residual noise whatever the noise type, and which the denoiser kept (so keystrokes and other noise bursts it removes
+ * do not count). Output is delayed by `lookaheadFrames`, so the gate ramps open over those
+ * frames and is fully open when the first speech frame comes out.
+ */
+ var PauseGate = class {
+ options;
+ queue = [];
+ gainDb = 0;
+ hangover = 0;
+ speechRun = 0;
+ floorDb;
+ inputLevelsDb = [];
+ constructor(options) {
+ this.options = options;
+ }
+ /**
+ * Takes one denoised frame and the input frame the denoiser was just given, and returns the gated frame from
+ * `lookaheadFrames` earlier (silence at first).
+ */
+ process(frame, input) {
+ const { extraAttenuationDb, lookaheadFrames, hangoverFrames, releaseDbPerFrame, speechAboveFloorDb, maxSpeechAttenuationDb } = this.options;
+ const levelDb = levelOf(frame);
+ this.inputLevelsDb.push(levelOf(input));
+ if (this.inputLevelsDb.length > INPUT_HISTORY_FRAMES) this.inputLevelsDb.shift();
+ const keptByDenoiser = levelDb - Math.max(...this.inputLevelsDb) > -maxSpeechAttenuationDb;
+ if (levelDb > DIGITAL_SILENCE_DB) {
+ this.floorDb = Math.min(levelDb, (this.floorDb ?? levelDb) + .1);
+ const speech = levelDb > this.floorDb + speechAboveFloorDb && keptByDenoiser;
+ this.speechRun = speech ? this.speechRun + 1 : 0;
+ if (this.speechRun >= (this.options.minSpeechFrames ?? 1)) this.hangover = lookaheadFrames + hangoverFrames;
+ } else this.speechRun = 0;
+ this.queue.push(frame.slice());
+ if (this.queue.length <= lookaheadFrames) return new Float32Array(frame.length);
+ const delayed = this.queue.shift();
+ const fromDb = this.gainDb;
+ if (this.hangover > 0) {
+ this.hangover--;
+ this.gainDb = Math.min(0, this.gainDb + extraAttenuationDb / Math.max(1, lookaheadFrames));
+ } else this.gainDb = Math.max(-extraAttenuationDb, this.gainDb - releaseDbPerFrame);
+ for (let i = 0; i < delayed.length; i++) {
+ const db = fromDb + (this.gainDb - fromDb) * i / delayed.length;
+ delayed[i] *= 10 ** (db / 20);
+ }
+ return delayed;
+ }
+ };
+ function levelOf(frame) {
+ let energy = 0;
+ for (const sample of frame) energy += sample * sample;
+ return 10 * Math.log10(energy / frame.length + 1e-12);
+ }
+ //#endregion
+ //#region src/post-gain.ts
+ var DEFAULT_POST_GAIN = {
+ targetDb: -23,
+ maxGainDb: 15,
+ minGainDb: -10,
+ speechThresholdDb: -45,
+ adaptDbPerFrame: .05,
+ ceiling: .9
+ };
+ /**
+ * Levels the denoised voice, frame by frame, in place of the browser's automatic gain control (which runs before the
+ * denoiser and raises the noise along with the voice). The gain only moves on speech frames, and a frame whose peak
+ * would exceed the ceiling is scaled down for that frame alone, so nothing clips.
+ */
+ var PostGain = class {
+ options;
+ gainDb = 0;
+ constructor(options = DEFAULT_POST_GAIN) {
+ this.options = options;
+ }
+ get currentGainDb() {
+ return this.gainDb;
+ }
+ process(frame) {
+ const { targetDb, maxGainDb, minGainDb, speechThresholdDb, adaptDbPerFrame, ceiling } = this.options;
+ let energy = 0;
+ let peak = 0;
+ for (const sample of frame) {
+ energy += sample * sample;
+ peak = Math.max(peak, Math.abs(sample));
+ }
+ const levelDb = 10 * Math.log10(energy / frame.length + 1e-12);
+ const fromDb = this.gainDb;
+ if (levelDb > speechThresholdDb) {
+ const wantedDb = Math.min(maxGainDb, Math.max(minGainDb, targetDb - levelDb));
+ const step = Math.max(-adaptDbPerFrame, Math.min(adaptDbPerFrame, wantedDb - this.gainDb));
+ this.gainDb += step;
+ }
+ const limit = peak > 0 ? ceiling / peak : Infinity;
+ for (let i = 0; i < frame.length; i++) {
+ const gain = Math.min(10 ** ((fromDb + (this.gainDb - fromDb) * i / frame.length) / 20), limit);
+ frame[i] *= gain;
+ }
+ return frame;
+ }
+ };
+ //#endregion
+ //#region src/load-monitor.ts
+ /**
+ * Tells whether a real-time loop keeps up: sums the time spent per frame over windows of `windowFrames` frames, and
+ * reports once when two windows in a row exceeded `maxLoad` of real time. One slow window is a hiccup (a tab switch,
+ * garbage collection); two are a machine that is too slow.
+ */
+ var LoadMonitor = class {
+ windowFrames;
+ frameDurationMs;
+ maxLoad;
+ framesInWindow = 0;
+ busyMs = 0;
+ overloadedWindows = 0;
+ reported = false;
+ constructor(windowFrames, frameDurationMs, maxLoad) {
+ this.windowFrames = windowFrames;
+ this.frameDurationMs = frameDurationMs;
+ this.maxLoad = maxLoad;
+ }
+ /** Records one frame; returns the load of the window when this frame triggers the report, undefined otherwise. */
+ record(elapsedMs) {
+ this.busyMs += elapsedMs;
+ this.framesInWindow++;
+ if (this.framesInWindow < this.windowFrames) return;
+ const load = this.busyMs / (this.windowFrames * this.frameDurationMs);
+ this.framesInWindow = 0;
+ this.busyMs = 0;
+ this.overloadedWindows = load > this.maxLoad ? this.overloadedWindows + 1 : 0;
+ if (this.overloadedWindows >= 2 && !this.reported) {
+ this.reported = true;
+ return load;
+ }
+ }
+ };
+ /**
+ * Collects the per-window load and the slow frames of a real-time loop, and returns a single report after
+ * `reportAfterWindows` windows (then nothing). Meant for telemetry: how heavy the denoiser really is on users'
+ * machines, where the overload check only says when it is too heavy.
+ */
+ var LoadSampler = class {
+ windowFrames;
+ frameDurationMs;
+ reportAfterWindows;
+ slowFrameMs;
+ windowLoads = [];
+ framesInWindow = 0;
+ busyMs = 0;
+ slowFrames = 0;
+ frames = 0;
+ reported = false;
+ constructor(windowFrames, frameDurationMs, reportAfterWindows, slowFrameMs = 3) {
+ this.windowFrames = windowFrames;
+ this.frameDurationMs = frameDurationMs;
+ this.reportAfterWindows = reportAfterWindows;
+ this.slowFrameMs = slowFrameMs;
+ }
+ record(elapsedMs) {
+ if (this.reported) return;
+ this.frames++;
+ if (elapsedMs >= this.slowFrameMs) this.slowFrames++;
+ this.busyMs += elapsedMs;
+ this.framesInWindow++;
+ if (this.framesInWindow < this.windowFrames) return;
+ this.windowLoads.push(this.busyMs / (this.windowFrames * this.frameDurationMs));
+ this.framesInWindow = 0;
+ this.busyMs = 0;
+ if (this.windowLoads.length < this.reportAfterWindows) return;
+ this.reported = true;
+ const sorted = [...this.windowLoads].sort((a, b) => a - b);
+ const at = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
+ return {
+ windows: sorted.length,
+ medianLoad: at(.5),
+ p95Load: at(.95),
+ maxLoad: sorted[sorted.length - 1],
+ slowFrames: this.slowFrames,
+ frames: this.frames
+ };
+ }
+ };
+ //#endregion
+ //#region src/deepfilternet-shared.ts
+ var DEEPFILTERNET_AUDIO_WORKLET_PROCESSOR_NAME = "workadventure-deepfilternet";
+ /** DeepFilterNet3 runs at 48 kHz only. */
+ var DEEPFILTERNET_SAMPLE_RATE = 48e3;
+ //#endregion
+ //#region src/deepfilternet-worklet-processor.ts
+ var RING_BUFFER_CAPACITY = 4096;
+ var now = typeof performance !== "undefined" && typeof performance.now === "function" ? () => performance.now() : () => Date.now();
+ var DeepFilterNetProcessor = class extends AudioWorkletProcessor {
+ bypassUntilReady;
+ state;
+ frameSamples = 0;
+ frame = new Float32Array(0);
+ pauseGate;
+ postGain;
+ loadMonitor;
+ loadSampler;
+ inputRing = new Float32RingBuffer(RING_BUFFER_CAPACITY);
+ outputRing = new Float32RingBuffer(RING_BUFFER_CAPACITY);
+ constructor(options) {
+ super();
+ const processorOptions = options.processorOptions;
+ this.bypassUntilReady = processorOptions.bypassUntilReady;
+ this.port.onmessage = (event) => {
+ if (event.data.type === "dispose") this.state = void 0;
+ };
+ try {
+ if (sampleRate !== 48e3) throw new Error(`DeepFilterNet3 needs a ${DEEPFILTERNET_SAMPLE_RATE} Hz AudioContext, got ${sampleRate} Hz.`);
+ initSync({ module: processorOptions.wasmModule });
+ this.state = df_create(new Uint8Array(processorOptions.modelBytes), processorOptions.speechAttenuationDb);
+ this.frameSamples = df_get_frame_length(this.state);
+ this.frame = new Float32Array(this.frameSamples);
+ if (processorOptions.pauseGate) this.pauseGate = new PauseGate(processorOptions.pauseGate);
+ if (processorOptions.postGain) this.postGain = new PostGain(processorOptions.postGain);
+ if (processorOptions.maxLoad > 0) this.loadMonitor = new LoadMonitor(200, this.frameSamples / sampleRate * 1e3, processorOptions.maxLoad);
+ if (processorOptions.loadReportAfterMs > 0) this.loadSampler = new LoadSampler(200, this.frameSamples / sampleRate * 1e3, Math.max(1, Math.round(processorOptions.loadReportAfterMs / 2e3)));
+ const ready = {
+ type: "ready",
+ frameSamples: this.frameSamples
+ };
+ this.port.postMessage(ready);
+ } catch (error) {
+ this.fail(error);
+ }
+ }
+ process(inputs, outputs) {
+ const input = inputs[0]?.[0];
+ const output = outputs[0]?.[0];
+ if (!output) return true;
+ if (!input) {
+ output.fill(0);
+ return true;
+ }
+ if (this.state === void 0) {
+ if (this.bypassUntilReady) output.set(input.subarray(0, output.length));
+ else output.fill(0);
+ return true;
+ }
+ try {
+ this.inputRing.push(input);
+ while (this.inputRing.availableRead() >= this.frameSamples) {
+ this.inputRing.pullInto(this.frame);
+ const startedAt = now();
+ const denoised = df_process_frame(this.state, this.frame);
+ const gated = this.pauseGate ? this.pauseGate.process(denoised, this.frame) : denoised;
+ this.outputRing.push(this.postGain ? this.postGain.process(gated) : gated);
+ const elapsedMs = now() - startedAt;
+ const overloadLoad = this.loadMonitor?.record(elapsedMs);
+ const loadReport = this.loadSampler?.record(elapsedMs);
+ if (loadReport) {
+ const message = {
+ type: "load-report",
+ ...loadReport
+ };
+ this.port.postMessage(message);
+ }
+ if (overloadLoad !== void 0) {
+ const overload = {
+ type: "overload",
+ load: overloadLoad
+ };
+ this.port.postMessage(overload);
+ }
+ }
+ if (!this.outputRing.pullInto(output)) output.fill(0);
+ } catch (error) {
+ this.fail(error);
+ if (this.bypassUntilReady) output.set(input.subarray(0, output.length));
+ else output.fill(0);
+ }
+ return true;
+ }
+ fail(error) {
+ this.state = void 0;
+ const message = {
+ type: "error",
+ message: error instanceof Error ? error.message : String(error)
+ };
+ this.port.postMessage(message);
+ }
+ };
+ registerProcessor(DEEPFILTERNET_AUDIO_WORKLET_PROCESSOR_NAME, DeepFilterNetProcessor);
+ //#endregion
+})();
diff --git a/call/src/vendor/deepfilternet/deepfilternet.js b/call/src/vendor/deepfilternet/deepfilternet.js
new file mode 100644
index 00000000..edd40cce
--- /dev/null
+++ b/call/src/vendor/deepfilternet/deepfilternet.js
@@ -0,0 +1,103 @@
+// vendored from @workadventure/noise-suppression@0.2.0 (MIT, ./LICENSE):
+// dist/deepfilternet.js, as it is, but for the processor's url, which is
+// the one beside this. its wasm and model aren't vendored: they're big,
+// so ../../call/noise.js downloads them the first time they're wanted
+// (and checks and keeps them), and hands their urls in
+//#region \0virtual:deepfilternet-audio-worklet-module-url
+var e = new URL("./deepfilternet-worklet-processor.js", import.meta.url).href, t = new URL("assets/deepfilternet/df_bg.wasm", import.meta.url).href, n = new URL("assets/deepfilternet/DeepFilterNet3_onnx.tar.gz", import.meta.url).href, r = "workadventure-deepfilternet", i = 48e3, a = {
+ targetDb: -23,
+ maxGainDb: 15,
+ minGainDb: -10,
+ speechThresholdDb: -45,
+ adaptDbPerFrame: .05,
+ ceiling: .9
+}, o = 25, s = 45, c = 3e4, l = .7, u = 6e4, d = /* @__PURE__ */ new WeakMap(), f = /* @__PURE__ */ new Map(), p = /* @__PURE__ */ new Map();
+function m(e, t, n) {
+ let r = e.get(t);
+ return r || (r = n().catch((n) => {
+ throw e.delete(t), n;
+ }), e.set(t, r)), r;
+}
+async function h(e) {
+ let t = await fetch(e);
+ if (!t.ok) throw Error(`Failed to load ${e}: ${t.status} ${t.statusText}`);
+ return t.arrayBuffer();
+}
+async function g(e) {
+ let t = new Uint8Array(e, 0, 2);
+ if (t[0] === 31 && t[1] === 139) return e;
+ let n = new Blob([e]).stream().pipeThrough(new CompressionStream("gzip"));
+ return new Response(n).arrayBuffer();
+}
+function _(e, t) {
+ let n = d.get(e);
+ n || (n = /* @__PURE__ */ new Map(), d.set(e, n));
+ let r = n.get(t);
+ return r || (r = e.audioWorklet.addModule(t), n.set(t, r)), r;
+}
+function v(e, t) {
+ return new Promise((n, r) => {
+ let i = globalThis.setTimeout(() => {
+ s(), r(/* @__PURE__ */ Error("Timed out waiting for the DeepFilterNet3 worklet to initialize."));
+ }, t), a = (e) => {
+ e.data.type === "ready" ? (s(), n(e.data)) : e.data.type === "error" && (s(), r(Error(e.data.message)));
+ }, o = () => {
+ s(), r(/* @__PURE__ */ Error("The DeepFilterNet3 AudioWorklet processor failed."));
+ }, s = () => {
+ globalThis.clearTimeout(i), e.port.removeEventListener("message", a), e.removeEventListener("processorerror", o);
+ };
+ e.port.addEventListener("message", a), e.port.start(), e.addEventListener("processorerror", o);
+ });
+}
+async function y(d, y = {}) {
+ if (d.sampleRate !== 48e3) throw Error(`DeepFilterNet3 needs a ${i} Hz AudioContext, got ${d.sampleRate} Hz.`);
+ let b = y.moduleUrl ?? e, x = y.wasmUrl ?? t, S = y.modelUrl ?? n, C = y.speechAttenuationDb ?? o, w = y.pauseAttenuationDb ?? s, [, T, E] = await Promise.all([
+ _(d, b),
+ m(f, x, async () => WebAssembly.compile(await h(x))),
+ m(p, S, async () => g(await h(S)))
+ ]), D = {
+ wasmModule: T,
+ modelBytes: E,
+ speechAttenuationDb: C,
+ pauseGate: w > C ? {
+ extraAttenuationDb: w - C,
+ lookaheadFrames: y.pauseGateLookaheadFrames ?? 3,
+ hangoverFrames: 10,
+ releaseDbPerFrame: .6,
+ speechAboveFloorDb: 10,
+ maxSpeechAttenuationDb: Math.min(15, C - 5)
+ } : void 0,
+ bypassUntilReady: y.bypassUntilReady ?? !0,
+ postGain: y.postGain ? {
+ ...a,
+ ...y.postGain === !0 ? {} : y.postGain
+ } : void 0,
+ maxLoad: y.maxLoad ?? l,
+ loadReportAfterMs: y.onLoadReport ? y.loadReportAfterMs ?? u : 0
+ };
+ D.pauseGate && y.minSpeechFrames !== void 0 && (D.pauseGate.minSpeechFrames = y.minSpeechFrames);
+ let O = new AudioWorkletNode(d, r, {
+ channelCount: 1,
+ channelCountMode: "explicit",
+ numberOfInputs: 1,
+ numberOfOutputs: 1,
+ outputChannelCount: [1],
+ processorOptions: D
+ }), { onOverload: k, onLoadReport: A } = y;
+ return (k || A) && O.port.addEventListener("message", (e) => {
+ if (e.data.type === "overload") k?.(e.data.load);
+ else if (e.data.type === "load-report") {
+ let { type: t, ...n } = e.data;
+ A?.(n);
+ }
+ }), {
+ node: O,
+ ready: v(O, y.readyTimeoutMs ?? c),
+ dispose() {
+ O.port.postMessage({ type: "dispose" }), O.disconnect();
+ }
+ };
+}
+//#endregion
+export { i as DEEPFILTERNET_SAMPLE_RATE, a as DEFAULT_POST_GAIN, y as createDeepFilterNetAudioWorklet };
+
diff --git a/call/src/video.js b/call/src/video.js
new file mode 100644
index 00000000..f194ef98
--- /dev/null
+++ b/call/src/video.js
@@ -0,0 +1,20 @@
+// the video: everyone's faces as tiles, for a frame's sidebar (threepane's
+// right one, a tab; lattice's inspector). where faces go when they're "in
+// the sidebar". out of a call, it says so
+import { Show } from "solid-js";
+import { render } from "solid-js/web";
+import html from "solid-js/html";
+import { call } from "./engine/call.js";
+import { CallTiles } from "./ui/ui.js";
+import "./ui/styles.css";
+
+export function CallVideo(element) {
+ return render(
+ () => html`
+ <${Show} when=${() => call.get()} fallback=${html`
no call
`}>
+ <${CallTiles} />
+ />
+
`,
+ element,
+ );
+}
diff --git a/call/test/button.test.js b/call/test/button.test.js
new file mode 100644
index 00000000..c19a7ae1
--- /dev/null
+++ b/call/test/button.test.js
@@ -0,0 +1,93 @@
+import { afterEach, beforeEach, expect, test, vi } from "vitest";
+import { accept } from "@inkandswitch/patchwork-providers";
+import { settle } from "./fake.js";
+
+// the button knows the call only by asking: a provider that answers by hand
+let host, element, stop, respond, asked, sent;
+beforeEach(async () => {
+ respond = null;
+ asked = null;
+ sent = [];
+ host = document.body.appendChild(document.createElement("div"));
+ host.addEventListener("patchwork:call", (event) => sent.push(event.detail));
+ element = host.appendChild(document.createElement("div"));
+ stop = () => {};
+});
+afterEach(() => {
+ stop();
+ host.remove();
+});
+
+async function mount({ answering = true } = {}) {
+ if (answering) {
+ host.addEventListener("patchwork:subscribe", (event) => {
+ asked = event.detail.selector;
+ accept(event, (r) => {
+ respond = r;
+ return () => {};
+ });
+ });
+ }
+ const { default: CallDoctitleButton } = await import("../src/button.js");
+ stop = CallDoctitleButton({ url: "automerge:doc" }, element);
+ await settle();
+}
+const button = () => element.querySelector("button");
+const say = async (state) => {
+ respond({ here: false, others: 0, elsewhere: false, ...state });
+ await settle();
+};
+
+test("without a provider, nothing", async () => {
+ await mount({ answering: false });
+ expect(button()).toBe(null);
+});
+
+test("it asks about its own doc", async () => {
+ await mount();
+ expect(asked).toEqual({ type: "patchwork:call", url: "automerge:doc" });
+ expect(button()).toBe(null);
+});
+
+test("nobody on: call everyone here, which asks", async () => {
+ await mount();
+ await say({});
+ expect(button().dataset.state).toBe("call");
+ expect(button().getAttribute("aria-label")).toBe("call everyone here");
+ button().click();
+ expect(sent).toHaveLength(1);
+ expect(sent[0]).toMatchObject({ action: "ask", url: "automerge:doc" });
+ expect(sent[0].at).toEqual({ x: expect.any(Number), y: expect.any(Number) });
+});
+
+test("some on: join, saying how many", async () => {
+ await mount();
+ await say({ others: 2 });
+ expect(button().dataset.state).toBe("join");
+ expect(button().getAttribute("aria-label")).toBe("join the call");
+ expect(button().textContent).toContain("2");
+});
+
+test("in it: hang up", async () => {
+ await mount();
+ await say({ here: true, others: 1 });
+ expect(button().dataset.state).toBe("here");
+ expect(button().getAttribute("aria-label")).toBe("hang up");
+ button().click();
+ expect(sent).toEqual([{ action: "hang-up" }]);
+});
+
+test("a provider that turns up later is asked again", async () => {
+ await mount({ answering: false });
+ expect(button()).toBe(null);
+ host.addEventListener("patchwork:subscribe", (event) => {
+ accept(event, (r) => {
+ respond = r;
+ return () => {};
+ });
+ });
+ window.dispatchEvent(new CustomEvent("patchwork:mounted", { detail: { componentId: "call-provider" } }));
+ await settle();
+ await say({});
+ expect(button()?.dataset.state).toBe("call");
+});
diff --git a/call/test/call.test.js b/call/test/call.test.js
new file mode 100644
index 00000000..e34c1e17
--- /dev/null
+++ b/call/test/call.test.js
@@ -0,0 +1,277 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { generateAutomergeUrl } from "@automerge/automerge-repo/slim";
+
+const KEY = "lattice-wm:call";
+const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+function fakeHandle(url, doc = {}) {
+ const listeners = new Set();
+ return {
+ url,
+ doc: () => doc,
+ broadcast: vi.fn(),
+ on: vi.fn((name, fn) => name === "ephemeral-message" && listeners.add(fn)),
+ off: vi.fn((name, fn) => listeners.delete(fn)),
+ emit: (message, senderId = "their-tab") => listeners.forEach((fn) => fn({ message, senderId })),
+ listeners,
+ };
+}
+
+// every test gets the engine as a new page would: nothing lasting, nothing found
+let engine, room, handles;
+async function fresh() {
+ delete globalThis[Symbol.for("patchwork-call:lasting")];
+ vi.resetModules();
+ handles = new Map();
+ globalThis.repo = {
+ find: vi.fn(async (url) => {
+ if (!handles.has(url)) handles.set(url, fakeHandle(url));
+ return handles.get(url);
+ }),
+ };
+ engine = await import("../src/engine/call.js");
+ room = await import("../src/engine/room.js");
+}
+
+const here = (handle, from = "x", who = { name: "ana" }) => handle.emit({ [KEY]: "here", from, who });
+
+beforeEach(async () => {
+ sessionStorage.clear();
+ localStorage.clear();
+ vi.stubGlobal("navigator", {
+ ...globalThis.navigator,
+ mediaDevices: { getUserMedia: () => Promise.reject(new Error("no media here")), enumerateDevices: async () => [] },
+ });
+ // happy-dom's has no tracks
+ vi.stubGlobal(
+ "MediaStream",
+ // (its own kind, so a takes it)
+ class extends (globalThis.MediaStream ?? Object) {
+ tracks = [];
+ getTracks = () => [...this.tracks];
+ getVideoTracks = () => this.tracks.filter((t) => t.kind === "video");
+ addTrack = (t) => this.tracks.push(t);
+ removeTrack = (t) => (this.tracks = this.tracks.filter((x) => x !== t));
+ },
+ );
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ await fresh();
+});
+
+afterEach(() => {
+ engine.hangUp();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+describe("hearing a room", () => {
+ test("says hello once, however many are hearing it", async () => {
+ const url = generateAutomergeUrl();
+ engine.hearRoom(url);
+ engine.hearRoom(url, { onScreen: true });
+ await tick();
+ const handle = handles.get(url);
+ expect(handle.broadcast).toHaveBeenCalledTimes(1);
+ expect(handle.broadcast).toHaveBeenCalledWith({ [KEY]: "hello" });
+ });
+
+ test("whoever's on a call there is heard, and listed while it's on screen", async () => {
+ const url = generateAutomergeUrl();
+ const offHearing = engine.hearRoom(url);
+ await tick();
+ here(handles.get(url));
+ expect(engine.onCallIn(url)).toHaveLength(1);
+ expect(engine.call.rooms()).toEqual([]);
+ const offScreen = engine.hearRoom(url, { onScreen: true });
+ expect(engine.call.rooms().map((r) => r.url)).toEqual([url]);
+ expect(engine.call.rooms()[0].others).toHaveLength(1);
+ offScreen();
+ expect(engine.call.rooms()).toEqual([]);
+ offHearing();
+ });
+
+ test("a bye from someone never heard is nothing", async () => {
+ const url = generateAutomergeUrl();
+ engine.hearRoom(url);
+ await tick();
+ expect(() => handles.get(url).emit({ [KEY]: "bye", from: "nobody" })).not.toThrow();
+ expect(engine.onCallIn(url)).toEqual([]);
+ });
+
+ test("it stops hearing only when the last one stops", async () => {
+ const url = generateAutomergeUrl();
+ const one = engine.hearRoom(url);
+ const two = engine.hearRoom(url);
+ await tick();
+ const handle = handles.get(url);
+ one();
+ one(); // twice is once
+ expect(handle.off).not.toHaveBeenCalled();
+ two();
+ expect(handle.off).toHaveBeenCalledTimes(1);
+ expect(handle.listeners.size).toBe(0);
+ });
+
+ test("a version of a doc is the same room", async () => {
+ const url = generateAutomergeUrl();
+ engine.hearRoom(`${url}#heads|abc`);
+ engine.hearRoom(url);
+ await tick();
+ expect(globalThis.repo.find).toHaveBeenCalledTimes(1);
+ expect(handles.get(url).on).toHaveBeenCalledTimes(1);
+ });
+
+ test("a key room is heard as nothing, for now", async () => {
+ const key = room.keyRoom(new Uint8Array(32).fill(7));
+ const off = engine.hearRoom(key, { onScreen: true });
+ await tick();
+ expect(() => off()).not.toThrow();
+ expect(globalThis.repo.find).not.toHaveBeenCalled();
+ });
+});
+
+describe("names", () => {
+ test("the name it's heard by, else the doc's title", async () => {
+ const url = generateAutomergeUrl();
+ handles.set(url, fakeHandle(url, { title: "the doc" }));
+ const off = engine.hearRoom(url, { onScreen: true, name: "the project" });
+ await tick();
+ await tick();
+ expect(engine.call.nameOf(url)).toBe("the project");
+ off();
+ expect(engine.call.nameOf(url)).toBe("the doc");
+ expect(engine.call.nameOf(generateAutomergeUrl())).toBe("");
+ });
+});
+
+describe("joining", () => {
+ test("a key room can't be joined yet, and says so", async () => {
+ await engine.join(room.keyRoom(new Uint8Array(32)));
+ expect(engine.call.get()).toBe(null);
+ expect(engine.call.problem()).toBe("calls over a key aren't available yet");
+ });
+
+ test("a doc that can't be reached says so", async () => {
+ globalThis.repo.find = vi.fn(async () => Promise.reject(new Error("unavailable")));
+ await engine.join(generateAutomergeUrl());
+ expect(engine.call.get()).toBe(null);
+ expect(engine.call.problem()).toBe("couldn't reach that doc");
+ });
+
+ test("joining a doc's call starts one there, saying you're here", async () => {
+ const url = generateAutomergeUrl();
+ await engine.join(url);
+ expect(engine.call.get()?.room).toBe(url);
+ expect(engine.call.problem()).toBe(null);
+ const said = handles.get(url).broadcast.mock.calls.map(([m]) => m[KEY]);
+ expect(said).toContain("here");
+ });
+
+ test("someone saying hello is answered", async () => {
+ vi.useFakeTimers({ toFake: ["setTimeout"] });
+ try {
+ const url = generateAutomergeUrl();
+ await engine.join(url);
+ const handle = handles.get(url);
+ handle.broadcast.mockClear();
+ handle.emit({ [KEY]: "hello" });
+ handle.emit({ "lattice-wm:hello?": true });
+ vi.advanceTimersByTime(300);
+ const said = handle.broadcast.mock.calls.map(([m]) => m[KEY]);
+ expect(said.filter((m) => m === "here")).toHaveLength(2);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
+
+describe("rejoining after a reload", () => {
+ async function remembered(url) {
+ const { keepCall, leavingCall } = await import("../src/engine/rejoin.js");
+ keepCall({ room: url, camera: false, mic: true });
+ leavingCall();
+ }
+
+ test("back in it when its room is on screen again", async () => {
+ const url = generateAutomergeUrl();
+ await remembered(url);
+ engine.hearRoom(url, { onScreen: true });
+ await tick();
+ await tick();
+ expect(engine.call.get()?.room).toBe(url);
+ });
+
+ test("not when it's only heard (a button asking about it)", async () => {
+ const url = generateAutomergeUrl();
+ await remembered(url);
+ engine.hearRoom(url);
+ await tick();
+ await tick();
+ expect(engine.call.get()).toBe(null);
+ });
+});
+
+describe("where faces go", () => {
+ test("in the sidebar only while something can show them there", () => {
+ engine.setFaces("sidebar");
+ expect(engine.call.mode()).toBe("floating");
+ expect(engine.call.settings.faces).toBe("floating");
+ const release = engine.holdVideoSpot();
+ expect(engine.call.canShowVideo()).toBe(true);
+ engine.setFaces("sidebar");
+ expect(engine.call.mode()).toBe("sidebar");
+ release();
+ expect(engine.call.canShowVideo()).toBe(false);
+ expect(engine.call.mode()).toBe("floating");
+ expect(engine.call.settings.faces).toBe("sidebar");
+ });
+
+ test("the video's wanted while you're in a call with faces in the sidebar", async () => {
+ const release = engine.holdVideoSpot();
+ engine.setFaces("sidebar");
+ expect(engine.call.videoWanted()).toBe(false);
+ await engine.join(generateAutomergeUrl());
+ expect(engine.call.videoWanted()).toBe(true);
+ release();
+ expect(engine.call.videoWanted()).toBe(false);
+ });
+
+ test("whether the video's actually showing somewhere, not just able to be", () => {
+ const able = engine.holdVideoSpot();
+ expect(engine.call.videoShowing()).toBe(false);
+ const showing = engine.holdVideoSpot({ showing: true });
+ expect(engine.call.videoShowing()).toBe(true);
+ expect(engine.call.canShowVideo()).toBe(true);
+ showing();
+ expect(engine.call.videoShowing()).toBe(false);
+ able();
+ });
+
+ test("on cursors isn't the call's to say any more", () => {
+ expect(engine.call.cursors).toBe(undefined);
+ expect(engine.setCursors).toBe(undefined);
+ expect(engine.inProject).toBe(undefined);
+ });
+});
+
+describe("settings", () => {
+ test("its own, not lattice's: faces put away there aren't put away here", async () => {
+ localStorage.setItem("lattice-wm:call", JSON.stringify({ faces: "sidebar", floating: { "automerge:c1": { hidden: true } } }));
+ await fresh();
+ expect(engine.call.settings.faces).toBe("floating");
+ expect(engine.call.settings.floating).toEqual({});
+ engine.setFloating("me", { x: 0.5, y: 0.5 });
+ expect(JSON.parse(localStorage.getItem("patchwork-call:settings")).floating.me).toEqual({ x: 0.5, y: 0.5 });
+ expect(JSON.parse(localStorage.getItem("lattice-wm:call")).faces).toBe("sidebar");
+ });
+});
+
+describe("asking", () => {
+ test("asking about a room, at a place", () => {
+ const url = generateAutomergeUrl();
+ engine.ask(`${url}#h`, { at: { x: 1, y: 2 } });
+ expect(engine.call.asking()).toEqual({ url, at: { x: 1, y: 2 } });
+ engine.call.stopAsking();
+ expect(engine.call.asking()).toBe(null);
+ });
+});
diff --git a/call/test/channel.test.js b/call/test/channel.test.js
new file mode 100644
index 00000000..c4a9ad18
--- /dev/null
+++ b/call/test/channel.test.js
@@ -0,0 +1,69 @@
+import { afterEach, beforeEach, expect, test, vi } from "vitest";
+import { generateAutomergeUrl } from "@automerge/automerge-repo/slim";
+import { openChannel } from "../src/engine/channel.js";
+
+function fakeHandle(url) {
+ const listeners = new Set();
+ return {
+ url,
+ broadcast: vi.fn(),
+ on: (name, fn) => name === "ephemeral-message" && listeners.add(fn),
+ off: (name, fn) => listeners.delete(fn),
+ emit: (event) => listeners.forEach((fn) => fn(event)),
+ listeners,
+ };
+}
+
+let saved;
+beforeEach(() => (saved = globalThis.repo));
+afterEach(() => (globalThis.repo = saved));
+
+test("a doc's channel is its handle's ephemeral messages", async () => {
+ const url = generateAutomergeUrl();
+ const handle = fakeHandle(url);
+ globalThis.repo = { find: vi.fn(async () => handle) };
+ const channel = await openChannel(url);
+ expect(channel.id).toBe(url);
+ channel.send({ a: 1 });
+ expect(handle.broadcast).toHaveBeenCalledWith({ a: 1 });
+ const heard = vi.fn();
+ channel.on(heard);
+ handle.emit({ message: { b: 2 }, senderId: 7, handle });
+ expect(heard).toHaveBeenCalledWith({ message: { b: 2 }, senderId: "7" });
+ channel.off(heard);
+ expect(handle.listeners.size).toBe(0);
+});
+
+test("a doc's channel is found once", async () => {
+ const url = generateAutomergeUrl();
+ globalThis.repo = { find: vi.fn(async () => fakeHandle(url)) };
+ expect(await openChannel(url)).toBe(await openChannel(url));
+ expect(globalThis.repo.find).toHaveBeenCalledTimes(1);
+});
+
+test("a key's channel isn't there yet", async () => {
+ globalThis.repo = { find: vi.fn() };
+ expect(await openChannel("key:" + "00".repeat(32))).toBe(null);
+ expect(globalThis.repo.find).not.toHaveBeenCalled();
+});
+
+test("no repo, or a doc that can't be found, is no channel", async () => {
+ globalThis.repo = undefined;
+ expect(await openChannel(generateAutomergeUrl())).toBe(null);
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const url = generateAutomergeUrl();
+ globalThis.repo = { find: vi.fn(async () => Promise.reject(new Error("unavailable"))) };
+ expect(await openChannel(url)).toBe(null);
+ expect(warn).toHaveBeenCalled();
+ // and it's tried again next time, not remembered as missing
+ globalThis.repo.find = vi.fn(async () => fakeHandle(url));
+ expect(await openChannel(url)).not.toBe(null);
+ warn.mockRestore();
+});
+
+test("a doc's channel knows what the doc's called", async () => {
+ const url = generateAutomergeUrl();
+ const handle = { ...fakeHandle(url), doc: () => ({ title: "the doc" }) };
+ globalThis.repo = { find: vi.fn(async () => handle) };
+ expect(await (await openChannel(url)).title()).toBe("the doc");
+});
diff --git a/call/test/control-panel.test.js b/call/test/control-panel.test.js
new file mode 100644
index 00000000..2b427464
--- /dev/null
+++ b/call/test/control-panel.test.js
@@ -0,0 +1,133 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { generateAutomergeUrl } from "@automerge/automerge-repo/slim";
+import { freshPage, here, settle } from "./fake.js";
+
+// happy-dom has no popovers: the menu closes itself after a choice
+HTMLElement.prototype.showPopover ??= function () {};
+HTMLElement.prototype.hidePopover ??= function () {};
+
+let engine, CallControlPanel, handles, parent, element, stop;
+beforeEach(async () => {
+ handles = freshPage();
+ engine = await import("../src/engine/call.js");
+ ({ CallControlPanel } = await import("../src/control-panel.js"));
+ parent = document.body.appendChild(document.createElement("div"));
+ element = parent.appendChild(document.createElement("div"));
+ stop = CallControlPanel(element);
+});
+afterEach(() => {
+ stop();
+ parent.remove();
+ engine.hangUp();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+const button = (words) => [...element.querySelectorAll("button")].find((b) => b.textContent.includes(words));
+
+describe("its size", () => {
+ test("no room at all till there's a call to show", async () => {
+ expect(element.dataset.sidebarSize).toBe("none");
+ const url = generateAutomergeUrl();
+ const release = engine.hearRoom(url, { onScreen: true });
+ await settle();
+ here(handles.get(url));
+ expect(element.dataset.sidebarSize).toBe("fit");
+ release();
+ expect(element.dataset.sidebarSize).toBe("none");
+ });
+
+ test("stopped, it takes no room", () => {
+ stop();
+ expect(element.dataset.sidebarSize).toBe("none");
+ stop = () => {};
+ });
+
+ test("a call that couldn't be joined says so", async () => {
+ await engine.join("key:" + "00".repeat(32));
+ expect(element.dataset.sidebarSize).toBe("fit");
+ expect(element.textContent).toContain("calls over a key aren't available yet");
+ });
+});
+
+describe("a call on screen you're not in", () => {
+ let url;
+ beforeEach(async () => {
+ url = generateAutomergeUrl();
+ engine.hearRoom(url, { onScreen: true, name: "the project" });
+ await settle();
+ here(handles.get(url));
+ });
+
+ test("it's asked about: where, who, and joining", async () => {
+ expect(element.textContent).toContain("the project");
+ expect(element.textContent).toContain("ana on a call");
+ button("video on").click();
+ await settle();
+ expect(engine.call.get()?.room).toBe(url);
+ });
+
+ test("its name takes you to it", () => {
+ const opened = vi.fn();
+ parent.addEventListener("patchwork:open-document", (event) => opened(event.detail.url));
+ button("the project").click();
+ expect(opened).toHaveBeenCalledWith(url);
+ });
+});
+
+describe("in a call", () => {
+ let url;
+ beforeEach(async () => {
+ url = generateAutomergeUrl();
+ engine.hearRoom(url, { onScreen: true, name: "the project" });
+ await engine.join(url);
+ await settle();
+ });
+
+ test("its name takes you back to it", () => {
+ const opened = vi.fn();
+ parent.addEventListener("patchwork:open-document", (event) => opened(event.detail.url));
+ button("the project").click();
+ expect(opened).toHaveBeenCalledWith(url);
+ });
+
+ test("the menu says where faces go: in the sidebar only where there's a sidebar to show them", async () => {
+ const menu = () => element.querySelector(".patchwork-call-menu");
+ const items = () => [...menu().querySelectorAll("button")].map((b) => b.textContent.trim());
+ expect(items()).toContain("floating");
+ expect(items()).not.toContain("in the sidebar");
+ expect(items().some((text) => text.includes("cursor"))).toBe(false);
+ const release = engine.holdVideoSpot();
+ await settle();
+ expect(items()).toContain("in the sidebar");
+ release();
+ });
+
+ test("choosing in the sidebar puts faces there", async () => {
+ const release = engine.holdVideoSpot();
+ await settle();
+ const item = [...element.querySelectorAll(".patchwork-call-menu button")].find(
+ (b) => b.textContent.trim() === "in the sidebar",
+ );
+ expect(engine.call.mode()).toBe("floating");
+ item.click();
+ expect(engine.call.mode()).toBe("sidebar");
+ release();
+ });
+
+ test("hanging up", async () => {
+ const thrown = [];
+ const onError = (event) => thrown.push(String(event.error ?? event.message));
+ window.addEventListener("error", onError);
+ try {
+ element.querySelector('button[aria-label="hang up"]').click();
+ await settle();
+ } catch (error) {
+ thrown.push(String(error));
+ } finally {
+ window.removeEventListener("error", onError);
+ }
+ expect(engine.call.get()).toBe(null);
+ expect(thrown).toEqual([]);
+ });
+});
diff --git a/call/test/faces.test.js b/call/test/faces.test.js
new file mode 100644
index 00000000..3db6e0ef
--- /dev/null
+++ b/call/test/faces.test.js
@@ -0,0 +1,115 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { createSignal } from "solid-js";
+
+// the engine, as far as a face reads it: who's in the call, and finding them
+const [peers, setPeers] = createSignal([]);
+const [inCall, setInCall] = createSignal(null);
+vi.mock("../src/engine/call.js", () => {
+ const call = {
+ get: () => inCall(),
+ peers: () => peers(),
+ me: () => ({ name: "me", contact: "", colour: "", avatar: "" }),
+ local: () => null,
+ stream: () => null,
+ screen: () => null,
+ mode: () => "floating",
+ stage: () => null,
+ full: () => false,
+ asking: () => null,
+ stopAsking: () => {},
+ nameOf: () => "",
+ settings: { floating: {} },
+ floatKey: (id) => id,
+ find({ peer, contact }) {
+ return (
+ peers().find((p) => peer && p.peer === peer) ?? peers().find((p) => contact && p.contact === contact) ?? null
+ );
+ },
+ };
+ const nothing = () => {};
+ return {
+ call,
+ onCallIn: () => [],
+ join: nothing,
+ setFloating: nothing,
+ hideFloating: nothing,
+ showFloating: nothing,
+ toggleStage: nothing,
+ setFull: nothing,
+ closeStage: nothing,
+ };
+});
+
+const { names } = await import("../src/ui/ui.js");
+const { CallFace } = await import("../src/call-face.js");
+
+describe("names", () => {
+ test("one, two, or more of them", () => {
+ expect(names([])).toBe("");
+ expect(names(["chee"])).toBe("chee");
+ expect(names(["chee", "mimi"])).toBe("chee and mimi");
+ expect(names(["chee", "mimi", "agnes"])).toBe("chee, mimi and agnes");
+ });
+});
+
+describe("call-face", () => {
+ const ana = { id: "p1", peer: "tab-1", contact: "automerge:c1", name: "ana", colour: "", avatar: "", mic: true };
+ let element, stop;
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
+ beforeEach(() => {
+ setPeers([]);
+ setInCall(null);
+ element = document.createElement("div");
+ document.body.append(element);
+ });
+ afterEach(() => {
+ stop?.();
+ element.remove();
+ });
+
+ test("nobody's face, out of a call", () => {
+ element.setAttribute("contact", "automerge:c1");
+ stop = CallFace(element);
+ expect(element.querySelector(".patchwork-call-face")).toBe(null);
+ });
+
+ test("their face while they're in your call, by who they are", () => {
+ setInCall({ room: "automerge:r", mic: true });
+ setPeers([ana]);
+ element.setAttribute("contact", "automerge:c1");
+ element.setAttribute("size", "44");
+ stop = CallFace(element);
+ const face = element.querySelector(".patchwork-call-face");
+ expect(face).not.toBe(null);
+ expect(face.style.getPropertyValue("--call-face-size")).toBe("44px");
+ expect(face.getAttribute("title")).toBe("ana");
+ });
+
+ test("by the tab they're in, too, and without their name when asked", () => {
+ setInCall({ room: "automerge:r", mic: true });
+ setPeers([ana]);
+ element.setAttribute("peer", "tab-1");
+ element.setAttribute("no-title", "");
+ stop = CallFace(element);
+ expect(element.querySelector(".patchwork-call-face").hasAttribute("title")).toBe(false);
+ });
+
+ test("someone else asked for: gone", async () => {
+ setInCall({ room: "automerge:r", mic: true });
+ setPeers([ana]);
+ element.setAttribute("contact", "automerge:c1");
+ stop = CallFace(element);
+ expect(element.querySelector(".patchwork-call-face")).not.toBe(null);
+ element.setAttribute("contact", "automerge:nobody");
+ await flush();
+ expect(element.querySelector(".patchwork-call-face")).toBe(null);
+ });
+
+ test("stopped, it's gone", () => {
+ setInCall({ room: "automerge:r", mic: true });
+ setPeers([ana]);
+ element.setAttribute("contact", "automerge:c1");
+ CallFace(element)();
+ expect(element.querySelector(".patchwork-call-face")).toBe(null);
+ });
+});
diff --git a/call/test/fake.js b/call/test/fake.js
new file mode 100644
index 00000000..e572bd7b
--- /dev/null
+++ b/call/test/fake.js
@@ -0,0 +1,55 @@
+// a page for the engine to run in: a repo of fake docs whose ephemeral
+// messages can be heard and said, no media, and nothing lasting from before
+import { vi } from "vitest";
+
+export function fakeHandle(url, doc = {}) {
+ const listeners = new Set();
+ return {
+ url,
+ doc: () => doc,
+ broadcast: vi.fn(),
+ on: vi.fn((name, fn) => name === "ephemeral-message" && listeners.add(fn)),
+ off: vi.fn((name, fn) => listeners.delete(fn)),
+ emit: (message, senderId = "their-tab") => listeners.forEach((fn) => fn({ message, senderId })),
+ listeners,
+ };
+}
+
+/** a new page: returns the docs found, by url */
+export function freshPage() {
+ delete globalThis[Symbol.for("patchwork-call:lasting")];
+ vi.resetModules();
+ sessionStorage.clear();
+ localStorage.clear();
+ const handles = new Map();
+ globalThis.repo = {
+ find: vi.fn(async (url) => {
+ if (!handles.has(url)) handles.set(url, fakeHandle(url));
+ return handles.get(url);
+ }),
+ };
+ vi.stubGlobal("navigator", {
+ ...globalThis.navigator,
+ mediaDevices: { getUserMedia: () => Promise.reject(new Error("no media here")), enumerateDevices: async () => [] },
+ });
+ vi.stubGlobal(
+ "MediaStream",
+ // (its own kind, so a takes it)
+ class extends (globalThis.MediaStream ?? Object) {
+ tracks = [];
+ getTracks = () => [...this.tracks];
+ getVideoTracks = () => this.tracks.filter((t) => t.kind === "video");
+ addTrack = (t) => this.tracks.push(t);
+ removeTrack = (t) => (this.tracks = this.tracks.filter((x) => x !== t));
+ },
+ );
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ return handles;
+}
+
+export const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
+export const settle = async () => {
+ for (let i = 0; i < 5; i++) await tick();
+};
+export const here = (handle, from = "x", who = { name: "ana" }) =>
+ handle.emit({ "lattice-wm:call": "here", from, who });
diff --git a/call/test/kept.test.js b/call/test/kept.test.js
new file mode 100644
index 00000000..e8daa00b
--- /dev/null
+++ b/call/test/kept.test.js
@@ -0,0 +1,65 @@
+import { test } from "vitest";
+import assert from "node:assert/strict";
+import { keep, kept } from "../src/engine/kept.js";
+import { REJOIN_WITHIN, callToRejoin, forgetCall, keepCall, leavingCall } from "../src/engine/rejoin.js";
+
+function memory() {
+ const items = new Map();
+ return {
+ getItem: (key) => (items.has(key) ? items.get(key) : null),
+ setItem: (key, value) => items.set(key, String(value)),
+ removeItem: (key) => items.delete(key),
+ items,
+ };
+}
+
+test("kept: what's kept comes back, and nothing is the fallback", () => {
+ const storage = memory();
+ assert.equal(kept("k", "none", storage), "none");
+ keep("k", { a: 1 }, storage);
+ assert.deepEqual(kept("k", null, storage), { a: 1 });
+ keep("k", null, storage);
+ assert.equal(storage.items.has("k"), false);
+});
+
+test("kept: storage that throws, or isn't json, is the fallback", () => {
+ const broken = {
+ getItem() {
+ throw new Error("no");
+ },
+ setItem() {
+ throw new Error("no");
+ },
+ };
+ assert.equal(kept("k", 7, broken), 7);
+ keep("k", 1, broken);
+ const storage = memory();
+ storage.setItem("k", "{not json");
+ assert.equal(kept("k", 7, storage), 7);
+});
+
+test("rejoin: a call left by a reload is rejoined, as it was", () => {
+ const storage = memory();
+ keepCall({ room: "automerge:room", camera: true, mic: false }, { storage });
+ // still in it: not one to rejoin
+ assert.equal(callToRejoin("automerge:room", { now: 5000, storage }), null);
+ leavingCall({ at: 5000, storage });
+ assert.deepEqual(callToRejoin("automerge:room", { now: 6000, storage }), { camera: true, mic: false });
+ assert.equal(callToRejoin("automerge:other", { now: 6000, storage }), null);
+});
+
+test("rejoin: not once it's too long ago, nor after hanging up", () => {
+ const storage = memory();
+ keepCall({ room: "automerge:room", camera: false, mic: true }, { storage });
+ leavingCall({ at: 0, storage });
+ assert.equal(callToRejoin("automerge:room", { now: REJOIN_WITHIN + 1, storage }), null);
+ assert.ok(callToRejoin("automerge:room", { now: REJOIN_WITHIN, storage }));
+ forgetCall({ storage });
+ assert.equal(callToRejoin("automerge:room", { now: 1, storage }), null);
+});
+
+test("rejoin: leaving with no call kept keeps nothing", () => {
+ const storage = memory();
+ leavingCall({ at: 1, storage });
+ assert.equal(storage.items.size, 0);
+});
diff --git a/call/test/lasting.test.js b/call/test/lasting.test.js
new file mode 100644
index 00000000..5b729e7e
--- /dev/null
+++ b/call/test/lasting.test.js
@@ -0,0 +1,23 @@
+import { test } from "vitest";
+import assert from "node:assert/strict";
+
+// a swap imports every module again from a new url (lattice/dev-swap.js): two
+// imports by different urls are two modules, as they are then
+test("a second version of a module gets the same lasting value", async () => {
+ const first = await import("../src/engine/lasting.js?first");
+ const second = await import("../src/engine/lasting.js?second");
+ assert.notEqual(first, second);
+ let made = 0;
+ const a = first.lasting("test:thing", () => ({ n: ++made }));
+ const b = second.lasting("test:thing", () => ({ n: ++made }));
+ assert.equal(a, b);
+ assert.equal(made, 1);
+});
+
+test("each key is its own", async () => {
+ const { lasting } = await import("../src/engine/lasting.js");
+ assert.notEqual(
+ lasting("test:one", () => ({})),
+ lasting("test:two", () => ({})),
+ );
+});
diff --git a/call/test/layer.test.js b/call/test/layer.test.js
new file mode 100644
index 00000000..6bfbea26
--- /dev/null
+++ b/call/test/layer.test.js
@@ -0,0 +1,230 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { createRoot, createSignal } from "solid-js";
+import { render } from "solid-js/web";
+import html from "solid-js/html";
+
+const [asking, setAsking] = createSignal(null);
+const [inCall, setInCall] = createSignal(null);
+const [mode, setMode] = createSignal("floating");
+const [showing, setShowing] = createSignal(false);
+const [staged, setStaged] = createSignal(null);
+const join = vi.fn();
+const names = { "automerge:other": "other" };
+vi.mock("../src/engine/call.js", () => {
+ const call = {
+ get: () => inCall(),
+ peers: () => [],
+ me: () => ({ name: "me" }),
+ local: () => null,
+ stream: () => null,
+ screen: () => null,
+ mode: () => mode(),
+ videoShowing: () => showing(),
+ stage: () => staged(),
+ full: () => false,
+ asking: () => asking(),
+ stopAsking: () => setAsking(null),
+ nameOf: (url) => names[url] ?? "",
+ settings: { floating: {} },
+ floatKey: (id) => id,
+ find: () => null,
+ };
+ const nothing = () => {};
+ return {
+ call,
+ onCallIn: () => [],
+ join,
+ setFloating: nothing,
+ hideFloating: nothing,
+ showFloating: nothing,
+ toggleStage: nothing,
+ setFull: nothing,
+ closeStage: nothing,
+ };
+});
+
+// happy-dom has no web animations: floating faces animate as they arrive
+HTMLElement.prototype.animate ??= function () {
+ return { finished: Promise.resolve(), cancel() {}, onfinish: null };
+};
+
+const { CallQuestion, CallFloating, CallStage } = await import("../src/ui/ui.js");
+const { stageArea } = await import("../src/ui/area.js");
+const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+describe("the question", () => {
+ let host, dispose;
+ beforeEach(() => {
+ setAsking(null);
+ setInCall(null);
+ join.mockClear();
+ host = document.body.appendChild(document.createElement("div"));
+ dispose = render(() => html`<${CallQuestion} />`, host);
+ });
+ afterEach(() => {
+ dispose();
+ host.remove();
+ });
+ const question = () => host.querySelector(".patchwork-call-question");
+ const button = (words) => [...host.querySelectorAll("button")].find((b) => b.textContent.includes(words));
+
+ test("nothing asked, nothing shown", () => {
+ expect(question()?.hasAttribute("data-open") ?? false).toBe(false);
+ });
+
+ test("asked, it's there, where it was asked", async () => {
+ setAsking({ url: "automerge:here", at: { x: 40, y: 50 } });
+ await tick();
+ expect(question().hasAttribute("data-open")).toBe(true);
+ expect(question().style.left).toBe("40px");
+ expect(question().textContent).toContain("start a call here?");
+ });
+
+ test("just voice joins without your camera, and the question's gone", async () => {
+ setAsking({ url: "automerge:here", at: null });
+ await tick();
+ button("just voice").click();
+ expect(join).toHaveBeenCalledWith("automerge:here", { video: false });
+ expect(asking()).toBe(null);
+ });
+
+ test("in a call somewhere else, it says joining this leaves that", async () => {
+ setInCall({ room: "automerge:other" });
+ setAsking({ url: "automerge:here", at: null });
+ await tick();
+ expect(question().textContent).toContain("leaves the call in other");
+ });
+});
+
+describe("floating faces", () => {
+ test("float while faces are in the sidebar but the video isn't showing there", async () => {
+ setInCall({ room: "automerge:here", mic: true });
+ setMode("sidebar");
+ setShowing(false);
+ const host = document.body.appendChild(document.createElement("div"));
+ const dispose = render(() => html`<${CallFloating} area=${() => null} />`, host);
+ expect(host.querySelector(".patchwork-call-floating")).not.toBe(null);
+ setShowing(true);
+ expect(host.querySelector(".patchwork-call-floating")).toBe(null);
+ setMode("floating");
+ expect(host.querySelector(".patchwork-call-floating")).not.toBe(null);
+ dispose();
+ host.remove();
+ setInCall(null);
+ });
+});
+
+describe("faces floating in the stage's area", () => {
+ test("they start near it, given a box (the provider passes its area, which html makes a getter)", async () => {
+ setInCall({ room: "automerge:here", mic: true });
+ setMode("floating");
+ const box = { left: 10, top: 20, width: 300, height: 200 };
+ const host = document.body.appendChild(document.createElement("div"));
+ const area = () => box;
+ const dispose = render(() => html`<${CallFloating} area=${area} /><${CallStage} area=${area} />`, host);
+ await tick();
+ expect(host.querySelector(".patchwork-call-float")).not.toBe(null);
+ setStaged("p1");
+ await tick();
+ expect(host.querySelector(".patchwork-call-stage").style.width).toBe("300px");
+ dispose();
+ host.remove();
+ setStaged(null);
+ setInCall(null);
+ });
+});
+
+describe("the stage, and faces floating", () => {
+ test("someone staged doesn't cover the floating faces", async () => {
+ const shown = [];
+ const proto = HTMLElement.prototype;
+ const saved = { show: proto.showPopover, hide: proto.hidePopover, matches: proto.matches };
+ const open = new Set();
+ proto.showPopover = function () {
+ open.add(this);
+ shown.push(this.className);
+ };
+ proto.hidePopover = function () {
+ open.delete(this);
+ };
+ proto.matches = function (selector) {
+ return selector === ":popover-open" ? open.has(this) : saved.matches.call(this, selector);
+ };
+ try {
+ setInCall({ room: "automerge:here", mic: true });
+ setMode("floating");
+ const host = document.body.appendChild(document.createElement("div"));
+ host.className = "patchwork-call";
+ const dispose = render(
+ () => html`<${CallFloating} area=${() => null} /><${CallStage} area=${() => null} />`,
+ host,
+ );
+ await tick();
+ expect(shown.at(-1)).toBe("patchwork-call-floating");
+ setStaged("p1");
+ await tick();
+ await tick();
+ expect(shown).toContain("patchwork-call-stage");
+ expect(shown.at(-1)).toBe("patchwork-call-floating");
+ dispose();
+ host.remove();
+ } finally {
+ Object.assign(proto, { showPopover: saved.show, hidePopover: saved.hide, matches: saved.matches });
+ setStaged(null);
+ setInCall(null);
+ }
+ });
+});
+
+describe("the stage's area", () => {
+ const frame = () => new Promise((resolve) => setTimeout(resolve, 40));
+
+ test("where the frame says the stage goes, for as long as it says so", async () => {
+ const root = document.body.appendChild(document.createElement("div"));
+ let area, dispose;
+ createRoot((d) => {
+ dispose = d;
+ area = stageArea(root, () => true);
+ });
+ expect(area()).toBe(null);
+ const main = document.createElement("div");
+ main.getBoundingClientRect = () => ({ left: 10, top: 20, width: 300, height: 200 });
+ main.setAttribute("data-call-stage", "");
+ root.append(main);
+ await frame();
+ expect(area()).toMatchObject({ left: 10, top: 20, width: 300, height: 200 });
+ main.remove();
+ await frame();
+ expect(area()).toBe(null);
+ dispose();
+ root.remove();
+ });
+
+ test("out of a call, the frame's changes aren't looked at", async () => {
+ const root = document.body.appendChild(document.createElement("div"));
+ const [active, setActive] = createSignal(false);
+ const looked = vi.spyOn(root, "querySelector");
+ let area, dispose;
+ createRoot((d) => {
+ dispose = d;
+ area = stageArea(root, active);
+ });
+ root.append(document.createElement("p"));
+ await frame();
+ expect(looked).not.toHaveBeenCalled();
+ const main = document.createElement("div");
+ main.getBoundingClientRect = () => ({ left: 1, top: 2, width: 3, height: 4 });
+ main.setAttribute("data-call-stage", "");
+ root.append(main);
+ setActive(true);
+ await frame();
+ expect(area()).toMatchObject({ width: 3 });
+ // found, it isn't looked for again as the frame changes
+ looked.mockClear();
+ root.append(document.createElement("p"));
+ await frame();
+ expect(looked).not.toHaveBeenCalled();
+ dispose();
+ root.remove();
+ });
+});
diff --git a/call/test/provider.test.js b/call/test/provider.test.js
new file mode 100644
index 00000000..a23794c9
--- /dev/null
+++ b/call/test/provider.test.js
@@ -0,0 +1,197 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { generateAutomergeUrl } from "@automerge/automerge-repo/slim";
+import { subscribe } from "@inkandswitch/patchwork-providers";
+
+const KEY = "lattice-wm:call";
+const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
+// subscriptions answer over a MessagePort: a few turns to arrive
+const settle = async () => {
+ for (let i = 0; i < 5; i++) await tick();
+};
+
+function fakeHandle(url) {
+ const listeners = new Set();
+ return {
+ url,
+ doc: () => ({}),
+ broadcast: vi.fn(),
+ on: vi.fn((name, fn) => name === "ephemeral-message" && listeners.add(fn)),
+ off: vi.fn((name, fn) => listeners.delete(fn)),
+ emit: (message) => listeners.forEach((fn) => fn({ message, senderId: "their-tab" })),
+ listeners,
+ };
+}
+
+let engine, CallProvider, handles, host, deep, stop;
+beforeEach(async () => {
+ delete globalThis[Symbol.for("patchwork-call:lasting")];
+ vi.resetModules();
+ handles = new Map();
+ globalThis.repo = {
+ find: vi.fn(async (url) => {
+ if (!handles.has(url)) handles.set(url, fakeHandle(url));
+ return handles.get(url);
+ }),
+ };
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ engine = await import("../src/engine/call.js");
+ ({ CallProvider } = await import("../src/provider.js"));
+ host = document.createElement("div");
+ deep = host.appendChild(document.createElement("div")).appendChild(document.createElement("div"));
+ document.body.append(host);
+ stop = CallProvider(host);
+});
+
+afterEach(() => {
+ stop?.();
+ host.remove();
+ engine.hangUp();
+ vi.restoreAllMocks();
+});
+
+const mounted = (url, from = deep) =>
+ from.dispatchEvent(new CustomEvent("patchwork:mounted", { bubbles: true, composed: true, detail: { url, toolId: "t" } }));
+const unmounted = (url, from = deep) =>
+ from.dispatchEvent(new CustomEvent("patchwork:unmounted", { bubbles: true, composed: true, detail: { url, toolId: "t" } }));
+const here = (url, from = "x") => handles.get(url).emit({ [KEY]: "here", from, who: { name: "ana" } });
+
+describe("docs on screen", () => {
+ test("a doc mounted anywhere inside is heard, and on screen", async () => {
+ const url = generateAutomergeUrl();
+ mounted(url);
+ await settle();
+ here(url);
+ expect(engine.call.rooms().map((r) => r.url)).toEqual([url]);
+ });
+
+ test("mounted twice, it stays till it's unmounted twice", async () => {
+ const url = generateAutomergeUrl();
+ mounted(url);
+ mounted(url);
+ await settle();
+ here(url);
+ unmounted(url);
+ expect(engine.call.rooms()).toHaveLength(1);
+ unmounted(url);
+ expect(engine.call.rooms()).toHaveLength(0);
+ expect(handles.get(url).listeners.size).toBe(0);
+ });
+
+ test("a version of it is the same doc", async () => {
+ const url = generateAutomergeUrl();
+ mounted(`${url}#heads|abc`);
+ await settle();
+ here(url);
+ expect(engine.call.rooms()).toHaveLength(1);
+ unmounted(url);
+ expect(engine.call.rooms()).toHaveLength(0);
+ });
+
+ test("a component that isn't a doc is nothing", async () => {
+ deep.dispatchEvent(new CustomEvent("patchwork:mounted", { bubbles: true, detail: { componentId: "c" } }));
+ await settle();
+ expect(globalThis.repo.find).not.toHaveBeenCalled();
+ });
+});
+
+describe("arriving late", () => {
+ test("docs already on screen when it mounts are heard, and their unmounts pair up", async () => {
+ stop();
+ const url = generateAutomergeUrl();
+ const view = deep.appendChild(document.createElement("patchwork-view"));
+ view.setAttribute("doc-url", `${url}#heads|abc`);
+ stop = CallProvider(host);
+ await settle();
+ here(url);
+ expect(engine.call.rooms().map((r) => r.url)).toEqual([url]);
+ unmounted(url, view);
+ expect(engine.call.rooms()).toHaveLength(0);
+ });
+});
+
+describe("subscriptions", () => {
+ test("patchwork:call says whether there's a call on a doc", async () => {
+ const url = generateAutomergeUrl();
+ const heard = [];
+ const off = subscribe(deep, { type: "patchwork:call", url }, (value) => heard.push(value));
+ await settle();
+ expect(heard.at(-1)).toEqual({ here: false, others: 0, elsewhere: false });
+ here(url);
+ await settle();
+ expect(heard.at(-1)).toEqual({ here: false, others: 1, elsewhere: false });
+ // asked about, it's heard, but it isn't on screen
+ expect(engine.call.rooms()).toHaveLength(0);
+ off();
+ });
+
+ test("patchwork:call-room says a room is here, and what it's called", async () => {
+ const url = generateAutomergeUrl();
+ const heard = [];
+ const off = subscribe(deep, { type: "patchwork:call-room", url, name: "proj" }, (value) => heard.push(value));
+ await settle();
+ expect(heard.at(-1)).toEqual({ here: false, others: 0, elsewhere: false });
+ here(url);
+ expect(engine.call.nameOf(url)).toBe("proj");
+ expect(engine.call.rooms().map((r) => r.name)).toEqual(["proj"]);
+ off();
+ await settle();
+ expect(engine.call.rooms()).toHaveLength(0);
+ expect(engine.call.nameOf(url)).toBe("");
+ });
+
+ test("patchwork:call-video says a frame can show the video, and is told when it's wanted", async () => {
+ const heard = [];
+ const off = subscribe(deep, { type: "patchwork:call-video" }, (value) => heard.push(value));
+ await settle();
+ expect(heard.at(-1)).toEqual({ wanted: false });
+ expect(engine.call.canShowVideo()).toBe(true);
+ off();
+ await settle();
+ expect(engine.call.canShowVideo()).toBe(false);
+ });
+
+ test("patchwork:call-video says whether the frame's showing it right now", async () => {
+ const off = subscribe(deep, { type: "patchwork:call-video", showing: true }, () => {});
+ await settle();
+ expect(engine.call.videoShowing()).toBe(true);
+ off();
+ await settle();
+ expect(engine.call.videoShowing()).toBe(false);
+ });
+});
+
+describe("asking", () => {
+ test("patchwork:call ask puts the question, where it was asked", async () => {
+ const url = generateAutomergeUrl();
+ deep.dispatchEvent(
+ new CustomEvent("patchwork:call", { bubbles: true, composed: true, detail: { action: "ask", url, at: { x: 1, y: 2 } } }),
+ );
+ expect(engine.call.asking()).toEqual({ url, at: { x: 1, y: 2 } });
+ });
+
+ test("anything else asked is nothing", () => {
+ deep.dispatchEvent(new CustomEvent("patchwork:call", { bubbles: true, detail: { action: "explode" } }));
+ expect(engine.call.asking()).toBe(null);
+ });
+});
+
+test("stopped, it hears nothing more", async () => {
+ stop();
+ stop = null;
+ mounted(generateAutomergeUrl());
+ await settle();
+ expect(globalThis.repo.find).not.toHaveBeenCalled();
+});
+
+describe("what floats", () => {
+ test("the provider draws the question when something asks, and takes it away when stopped", async () => {
+ const url = generateAutomergeUrl();
+ deep.dispatchEvent(new CustomEvent("patchwork:call", { bubbles: true, detail: { action: "ask", url } }));
+ await settle();
+ const question = host.querySelector(".patchwork-call > .patchwork-call-question");
+ expect(question?.hasAttribute("data-open")).toBe(true);
+ stop();
+ stop = null;
+ expect(host.querySelector(".patchwork-call")).toBe(null);
+ });
+});
diff --git a/call/test/room.test.js b/call/test/room.test.js
new file mode 100644
index 00000000..644c782f
--- /dev/null
+++ b/call/test/room.test.js
@@ -0,0 +1,83 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { generateAutomergeUrl } from "@automerge/automerge-repo/slim";
+
+const load = vi.fn();
+vi.mock("@inkandswitch/patchwork-plugins", () => ({
+ getRegistry: () => ({ load }),
+}));
+
+const { isKeyRoom, keyRoom, pageRepo, roomUrl, titleOf } = await import("../src/engine/room.js");
+
+describe("roomUrl", () => {
+ const url = generateAutomergeUrl();
+
+ test("a doc's url is its room", () => {
+ expect(roomUrl(url)).toBe(url);
+ });
+
+ test("heads and paths are the same room", () => {
+ expect(roomUrl(`${url}#heads|abc`)).toBe(url);
+ expect(roomUrl(`${url}/path/to`)).toBe(url);
+ });
+
+ test("anything else isn't a room", () => {
+ expect(roomUrl("")).toBe(null);
+ expect(roomUrl(null)).toBe(null);
+ expect(roomUrl(undefined)).toBe(null);
+ expect(roomUrl("https://x")).toBe(null);
+ expect(roomUrl("automerge:not-a-doc")).toBe(null);
+ });
+
+ test("a key is a room, lowercased, if it's 32 bytes of hex", () => {
+ expect(roomUrl("KEY:" + "AB".repeat(32))).toBe("key:" + "ab".repeat(32));
+ expect(roomUrl("key:" + "a".repeat(63))).toBe(null);
+ expect(roomUrl("key:" + "g".repeat(64))).toBe(null);
+ });
+});
+
+describe("keyRoom", () => {
+ test("32 bytes, as hex", () => {
+ expect(keyRoom(new Uint8Array(32).fill(255))).toBe("key:" + "ff".repeat(32));
+ expect(isKeyRoom(keyRoom(new Uint8Array(32)))).toBe(true);
+ expect(isKeyRoom(generateAutomergeUrl())).toBe(false);
+ });
+
+ test("anything but 32 bytes is refused", () => {
+ expect(() => keyRoom(new Uint8Array(31))).toThrow(RangeError);
+ });
+});
+
+describe("pageRepo", () => {
+ let saved;
+ beforeEach(() => (saved = { repo: globalThis.repo, patchwork: globalThis.patchwork }));
+ afterEach(() => Object.assign(globalThis, saved));
+
+ test("the page's repo, wherever this host put it", () => {
+ const repo = {};
+ globalThis.repo = repo;
+ expect(pageRepo()).toBe(repo);
+ const other = {};
+ globalThis.repo = undefined;
+ globalThis.patchwork = { repo: other };
+ expect(pageRepo()).toBe(other);
+ globalThis.patchwork = undefined;
+ expect(pageRepo()).toBe(null);
+ });
+});
+
+describe("titleOf", () => {
+ const handleOf = (doc) => ({ doc: () => doc });
+
+ test("the datatype's title", async () => {
+ load.mockResolvedValueOnce({ module: { getTitle: (d) => "T:" + d.x } });
+ expect(await titleOf(handleOf({ "@patchwork": { type: "t" }, x: 1 }))).toBe("T:1");
+ expect(load).toHaveBeenCalledWith("t");
+ });
+
+ test("its own title when the datatype can't be had, or nothing", async () => {
+ load.mockRejectedValueOnce(new Error("no"));
+ expect(await titleOf(handleOf({ "@patchwork": { type: "t" }, title: "mine" }))).toBe("mine");
+ expect(await titleOf(handleOf({}))).toBe("");
+ expect(await titleOf(handleOf(undefined))).toBe("");
+ });
+});
diff --git a/call/test/sizes.test.js b/call/test/sizes.test.js
new file mode 100644
index 00000000..2c3375fe
--- /dev/null
+++ b/call/test/sizes.test.js
@@ -0,0 +1,47 @@
+import { test } from "vitest";
+import assert from "node:assert/strict";
+import { SPEAKING_AFTER, QUIET_AFTER, levelOf, speakingStep, tilesFor, videoFor } from "../src/engine/sizes.js";
+
+test("the sidebar's tiles: big while there are few, chips when there are many", () => {
+ assert.equal(tilesFor(1), "tiles");
+ assert.equal(tilesFor(4), "tiles");
+ assert.equal(tilesFor(5), "grid");
+ assert.equal(tilesFor(9), "grid");
+ assert.equal(tilesFor(10), "chips");
+ assert.equal(tilesFor(20), "chips");
+});
+
+test("video sent: less of it, the more people there are to send it to", () => {
+ assert.equal(videoFor(1), null);
+ assert.equal(videoFor(5), null);
+ const six = videoFor(6);
+ const ten = videoFor(10);
+ assert.ok(six.maxBitrate > ten.maxBitrate);
+ assert.ok(six.scaleResolutionDownBy < ten.scaleResolutionDownBy);
+ assert.ok(ten.maxFramerate <= six.maxFramerate);
+});
+
+test("a level: silence is nothing, a full swing is loud", () => {
+ assert.equal(levelOf(new Uint8Array(64).fill(128)), 0);
+ const swing = Uint8Array.from({ length: 64 }, (_, i) => (i % 2 ? 255 : 0));
+ assert.ok(levelOf(swing) > 0.9);
+});
+
+test("speaking: a moment's sound isn't, and a pause doesn't stop it", () => {
+ let state = { speaking: false, loudSince: null, quietSince: null };
+ // a click
+ state = speakingStep(state, 0.3, 0);
+ state = speakingStep(state, 0.0, 50);
+ assert.equal(state.speaking, false);
+ // talking
+ state = speakingStep(state, 0.3, 1000);
+ state = speakingStep(state, 0.3, 1000 + SPEAKING_AFTER);
+ assert.equal(state.speaking, true);
+ // a breath
+ state = speakingStep(state, 0.0, 2000);
+ state = speakingStep(state, 0.0, 2000 + QUIET_AFTER - 1);
+ assert.equal(state.speaking, true);
+ // stopped
+ state = speakingStep(state, 0.0, 2000 + QUIET_AFTER);
+ assert.equal(state.speaking, false);
+});
diff --git a/call/test/video.test.js b/call/test/video.test.js
new file mode 100644
index 00000000..a99d66c8
--- /dev/null
+++ b/call/test/video.test.js
@@ -0,0 +1,31 @@
+import { afterEach, beforeEach, expect, test, vi } from "vitest";
+import { generateAutomergeUrl } from "@automerge/automerge-repo/slim";
+import { freshPage, settle } from "./fake.js";
+
+let engine, CallVideo, element, stop;
+beforeEach(async () => {
+ freshPage();
+ engine = await import("../src/engine/call.js");
+ ({ CallVideo } = await import("../src/video.js"));
+ element = document.body.appendChild(document.createElement("div"));
+ stop = CallVideo(element);
+});
+afterEach(() => {
+ stop();
+ element.remove();
+ engine.hangUp();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+test("out of a call, it says so", () => {
+ expect(element.querySelector(".patchwork-call-video-none")?.textContent).toBe("no call");
+ expect(element.querySelector(".patchwork-call-tiles")).toBe(null);
+});
+
+test("in one, everyone's tiles: you, to start with", async () => {
+ await engine.join(generateAutomergeUrl());
+ await settle();
+ expect(element.querySelector(".patchwork-call-video-none")).toBe(null);
+ expect(element.querySelector('.patchwork-call-tiles .patchwork-call-tile[data-mine]')).not.toBe(null);
+});
diff --git a/call/test/wiggle.test.js b/call/test/wiggle.test.js
new file mode 100644
index 00000000..0dbd9d9f
--- /dev/null
+++ b/call/test/wiggle.test.js
@@ -0,0 +1,74 @@
+import { test } from "vitest";
+import assert from "node:assert/strict";
+import { ringWave, louder, edgeAt, wiggle, LOUDER_FLOOR } from "../src/engine/wiggle.js";
+
+const close = (a, b) => assert.ok(Math.abs(a - b) < 1e-6, `${a} ≈ ${b}`);
+
+// a sine `cycles` times over `length`, `phase` in
+const sine = (length, cycles, amplitude = 1, phase = 0) =>
+ Float32Array.from({ length }, (_, i) => amplitude * Math.sin(phase + (2 * Math.PI * cycles * i) / length));
+
+test("wiggle: silence is flat", () => {
+ const values = ringWave(new Float32Array(2048), 160, 1024);
+ assert.equal(values.length, 160);
+ assert.ok(values.every((value) => value === 0));
+});
+
+test("wiggle: the waveform goes round, the same both sides, so its ends meet", () => {
+ const values = ringWave(sine(2048, 16), 160, 1024);
+ for (let i = 1; i < 80; i++) assert.equal(values[i], values[160 - i]);
+ assert.ok(Math.max(...values) > 0.8 && Math.min(...values) < -0.8, "it swings both ways");
+});
+
+test("wiggle: it starts where the sound goes up through silence, so it holds still", () => {
+ const a = ringWave(sine(2048, 16, 1, 0.5), 160, 1024);
+ const b = ringWave(sine(2048, 16, 1, 2.5), 160, 1024);
+ for (let i = 0; i < 160; i++) assert.ok(Math.abs(a[i] - b[i]) < 0.15, `point ${i}: ${a[i]} ≈ ${b[i]}`);
+ // its first stretch is the start of a rise
+ assert.ok(a[0] >= 0 && a[0] < 0.35, `from silence: ${a[0]}`);
+});
+
+test("wiggle: quiet's made as loud as loud, but hum isn't", () => {
+ const quiet = louder([0.1, -0.05, 0]);
+ close(quiet.values[0], 1);
+ close(quiet.values[1], -0.5);
+ const hum = louder([LOUDER_FLOOR / 3]);
+ close(hum.values[0], 1 / 3);
+ // after something loud, quiet's quiet, for a while
+ const after = louder([0.1], 1);
+ assert.ok(after.values[0] < 0.2);
+ assert.ok(after.loudest < 1);
+});
+
+test("wiggle: round a circle, every point's on it and out's away from the middle", () => {
+ for (let i = 0; i < 40; i++) {
+ const at = edgeAt(100, 100, 50, i / 40);
+ close(Math.hypot(at.x, at.y), 50);
+ close(at.nx, at.x / 50);
+ close(at.ny, at.y / 50);
+ }
+ const bottom = edgeAt(100, 100, 50, 0);
+ close(bottom.x, 0);
+ close(bottom.y, 50);
+ const top = edgeAt(100, 100, 50, 0.5);
+ close(top.x, 0);
+ close(top.y, -50);
+});
+
+test("wiggle: round a box, its edges, and the left side first", () => {
+ const left = edgeAt(200, 100, 10, 0.25);
+ close(left.x, -100);
+ assert.equal(left.nx, -1);
+ const right = edgeAt(200, 100, 10, 0.75);
+ close(right.x, 100);
+ assert.equal(right.nx, 1);
+ const square = edgeAt(200, 100, 0, 0.1);
+ assert.ok(Math.abs(square.x) <= 100 && Math.abs(square.y) <= 50);
+});
+
+test("wiggle: points sit out from the edge, swinging either way", () => {
+ const points = wiggle(100, 100, 50, [0, 1, 0, -1], 8, 5);
+ close(Math.hypot(...points[0]), 58);
+ close(Math.hypot(...points[1]), 63);
+ close(Math.hypot(...points[3]), 53);
+});
diff --git a/call/vite.config.js b/call/vite.config.js
new file mode 100644
index 00000000..5aaec810
--- /dev/null
+++ b/call/vite.config.js
@@ -0,0 +1,26 @@
+import { defineConfig } from "vite";
+import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js";
+import externals from "@inkandswitch/patchwork-bootloader/externals";
+
+export default defineConfig({
+ base: "./",
+ plugins: [cssInjectedByJsPlugin({ relativeCSSInjection: true })],
+
+ build: {
+ sourcemap: true,
+ cssCodeSplit: true,
+ emptyOutDir: true,
+ minify: false,
+ rollupOptions: {
+ external: externals,
+ input: "./src/index.js",
+ output: {
+ format: "es",
+ entryFileNames: "[name].js",
+ chunkFileNames: "assets/[name]-[hash].js",
+ assetFileNames: "assets/[name][extname]",
+ },
+ preserveEntrySignatures: "strict",
+ },
+ },
+});
diff --git a/call/vitest.config.js b/call/vitest.config.js
new file mode 100644
index 00000000..9efa5dbc
--- /dev/null
+++ b/call/vitest.config.js
@@ -0,0 +1,14 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "happy-dom",
+ globals: true,
+ passWithNoTests: true,
+ // solid and its store resolved by vite alike (browser, dev), not one by node
+ server: { deps: { inline: [/solid-js/] } },
+ },
+ resolve: {
+ conditions: ["development", "browser"],
+ },
+});
diff --git a/threepane/src/PatchworkFrame.tsx b/threepane/src/PatchworkFrame.tsx
index b06df3d3..fa9235e5 100644
--- a/threepane/src/PatchworkFrame.tsx
+++ b/threepane/src/PatchworkFrame.tsx
@@ -45,6 +45,7 @@ import { seedExampleDocuments } from "./account/seedExampleDocuments";
import { handleAddToSidebar } from "./account/addToSidebar";
import { ensureFrameStyles } from "./ensureFrameStyles";
import { useHandleDoc } from "./hooks/useHandleDoc";
+import { CALL_PROVIDER } from "./datatypes";
type SelectedView = {
url: AutomergeUrl;
@@ -179,13 +180,21 @@ export function PatchworkFrame({
element is the ctx a worker spec's open(ctx) gets.
*/}
-
-
-
+ {/*
+ The call (the call package): round everything, so it hears
+ every doc on screen, reaches the title bar and both
+ sidebars, and carries on as docs change. Not waited on: it's
+ late-bound, and renders nothing without the package.
+ */}
+
+
+
+
+
diff --git a/threepane/src/components/CallVideoOpener.test.tsx b/threepane/src/components/CallVideoOpener.test.tsx
new file mode 100644
index 00000000..3e671b27
--- /dev/null
+++ b/threepane/src/components/CallVideoOpener.test.tsx
@@ -0,0 +1,153 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { createSignal } from "solid-js";
+import { render } from "solid-js/web";
+import { accept } from "@inkandswitch/patchwork-providers";
+
+const [tagged, setTagged] = createSignal<{ id: string; name: string }[]>([]);
+vi.mock("../hooks", () => ({ useTaggedComponents: () => tagged }));
+
+const { CallVideoOpener } = await import("./CallVideoOpener");
+
+const settle = async () => {
+ for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0));
+};
+
+let host: HTMLElement;
+let dispose: () => void;
+let respond: ((value: { wanted: boolean }) => void) | null;
+let subscribed: number;
+let selectors: any[];
+const [collapsed, setCollapsed] = createSignal(false);
+const [selected, setSelected] = createSignal(
+ "comments-view"
+);
+const open = vi.fn((id: string) => setSelected(id));
+const select = vi.fn((id: string | undefined) => setSelected(id));
+
+beforeEach(() => {
+ respond = null;
+ subscribed = 0;
+ selectors = [];
+ setCollapsed(false);
+ open.mockClear();
+ select.mockClear();
+ setSelected("comments-view");
+ setTagged([
+ { id: "comments-view", name: "Comments" },
+ { id: "call-video", name: "Call" },
+ ]);
+ host = document.body.appendChild(document.createElement("div"));
+ host.addEventListener("patchwork:subscribe", (event: any) => {
+ if (event.detail.selector.type !== "patchwork:call-video") return;
+ subscribed++;
+ selectors.push(event.detail.selector);
+ accept(event, (r: any) => {
+ respond = r;
+ // (an unsubscribe arrives over a port: maybe after the next test's begun)
+ return () => {
+ if (respond === r) respond = null;
+ };
+ });
+ });
+});
+afterEach(() => {
+ dispose?.();
+ host.remove();
+});
+
+function mount() {
+ dispose = render(
+ () => (
+
+ ),
+ host.appendChild(document.createElement("div"))
+ );
+}
+
+describe("the call's video tab", () => {
+ it("is opened when the video's wanted", async () => {
+ mount();
+ await settle();
+ respond!({ wanted: true });
+ await settle();
+ expect(open).toHaveBeenCalledWith("call-video");
+ });
+
+ it("goes back to the tab that was showing when it isn't", async () => {
+ mount();
+ await settle();
+ respond!({ wanted: true });
+ await settle();
+ respond!({ wanted: false });
+ await settle();
+ expect(select).toHaveBeenCalledWith("comments-view");
+ });
+
+ it("goes back to no tab chosen (the first) if none was", async () => {
+ setSelected(undefined);
+ mount();
+ await settle();
+ respond!({ wanted: true });
+ await settle();
+ respond!({ wanted: false });
+ await settle();
+ expect(select).toHaveBeenCalledWith(undefined);
+ });
+
+ it("leaves the tab alone if you've moved on from it", async () => {
+ mount();
+ await settle();
+ respond!({ wanted: true });
+ await settle();
+ setSelected("history-view");
+ respond!({ wanted: false });
+ await settle();
+ expect(select).not.toHaveBeenCalled();
+ });
+
+ it("asks again when the call's provider turns up", async () => {
+ mount();
+ await settle();
+ expect(subscribed).toBe(1);
+ window.dispatchEvent(
+ new CustomEvent("patchwork:mounted", {
+ detail: { componentId: "call-provider" },
+ })
+ );
+ await settle();
+ expect(subscribed).toBe(2);
+ });
+
+ it("says whether the video's showing: the sidebar open, on its tab", async () => {
+ mount();
+ await settle();
+ expect(selectors.at(-1)).toEqual({
+ type: "patchwork:call-video",
+ showing: false,
+ });
+ respond!({ wanted: true });
+ await settle();
+ expect(selectors.at(-1)).toEqual({
+ type: "patchwork:call-video",
+ showing: true,
+ });
+ setCollapsed(true);
+ await settle();
+ expect(selectors.at(-1)).toEqual({
+ type: "patchwork:call-video",
+ showing: false,
+ });
+ });
+
+ it("isn't asked about without a call video tab", async () => {
+ setTagged([{ id: "comments-view", name: "Comments" }]);
+ mount();
+ await settle();
+ expect(subscribed).toBe(0);
+ });
+});
diff --git a/threepane/src/components/CallVideoOpener.tsx b/threepane/src/components/CallVideoOpener.tsx
new file mode 100644
index 00000000..aded9fc9
--- /dev/null
+++ b/threepane/src/components/CallVideoOpener.tsx
@@ -0,0 +1,75 @@
+import { createEffect, onCleanup, type Accessor } from "solid-js";
+import { subscribe } from "@inkandswitch/patchwork-providers";
+import { useTaggedComponents } from "../hooks";
+import { CALL_PROVIDER } from "../datatypes";
+
+/** The call's video (the call package's `context-tool`). */
+const CALL_VIDEO = "call-video";
+
+/**
+ * Shows the call's video tab while it's wanted. While there's a Call tab in
+ * the context sidebar, threepane tells the call provider it can show the
+ * video (a `patchwork:call-video` subscription, saying whether the tab is on
+ * screen now), so "in the sidebar" is offered as a place for faces, and
+ * faces float whenever the tab isn't showing. When the video's wanted (in a
+ * call, faces in the sidebar) the tab is opened; when it stops (hung up, or
+ * faces floating again) the tab that was showing before comes back, unless
+ * you've moved on from the call's yourself.
+ *
+ * Lives above the per-document remount, so what it opened survives a doc
+ * switch. It must sit inside the call provider.
+ */
+export function CallVideoOpener(props: {
+ selected: Accessor;
+ select: (id: string | undefined) => void;
+ open: (id: string) => void;
+ isCollapsed: Accessor;
+}) {
+ let anchor!: HTMLSpanElement;
+ const tools = useTaggedComponents("context-tool");
+ const canShow = () => tools().some((t) => t.id === CALL_VIDEO);
+ const showing = () => !props.isCollapsed() && props.selected() === CALL_VIDEO;
+
+ let previous: string | undefined;
+ let opened = false;
+ function heard({ wanted }: { wanted?: boolean }) {
+ if (wanted) {
+ if (opened) return;
+ previous = props.selected();
+ opened = true;
+ if (!showing()) props.open(CALL_VIDEO);
+ return;
+ }
+ // (none chosen before: none again, which shows the first tab)
+ if (
+ opened &&
+ props.selected() === CALL_VIDEO &&
+ previous !== CALL_VIDEO
+ ) {
+ props.select(previous);
+ }
+ opened = false;
+ }
+
+ createEffect(() => {
+ if (!canShow()) return;
+ const selector = { type: "patchwork:call-video", showing: showing() };
+ const listen = () =>
+ subscribe<{ wanted?: boolean }>(anchor, selector, heard);
+ let unsubscribe = listen();
+ // a provider that turns up after this asked (loaded late, or swapped in
+ // anew) is asked again
+ const again = (event: Event) => {
+ if ((event as CustomEvent).detail?.componentId !== CALL_PROVIDER) return;
+ unsubscribe();
+ unsubscribe = listen();
+ };
+ addEventListener("patchwork:mounted", again);
+ onCleanup(() => {
+ removeEventListener("patchwork:mounted", again);
+ unsubscribe();
+ });
+ });
+
+ return ;
+}
diff --git a/threepane/src/components/DocumentAreaRoot.tsx b/threepane/src/components/DocumentAreaRoot.tsx
index c92c86f2..e3c242b1 100644
--- a/threepane/src/components/DocumentAreaRoot.tsx
+++ b/threepane/src/components/DocumentAreaRoot.tsx
@@ -39,6 +39,7 @@ import { useSidebarResize } from "../hooks/useSidebarResize";
import { FrameTopBar } from "./FrameTopBar";
import { ContextSidebar } from "./ContextSidebar";
import { CommentsOpener } from "./CommentsOpener";
+import { CallVideoOpener } from "./CallVideoOpener";
import { MainDocumentView } from "./MainDocumentView";
/**
@@ -149,35 +150,47 @@ export function DocumentAreaRoot(props: DocumentAreaRootProps) {
isDraftStateProviderReady() ? draftStateProviderHost() : undefined;
return (
-
- {(docUrl) => (
-
-
- {(host) => (
-
- )}
-
-
- )}
-
+ <>
+ {/* above the per-document remount, so a doc switch doesn't drop it */}
+ {
+ setSelectedContextToolId(toolId);
+ setIsRightSidebarCollapsed(false);
+ }}
+ />
+
+ {(docUrl) => (
+
+
+ {(host) => (
+
+ )}
+
+
+ )}
+
+ >
);
}
@@ -301,7 +314,8 @@ function DraftDocumentArea(props: {
}
/>
-
+ {/* where the call's stage goes: data-call-stage */}
+
against a repo; the layout doesn't need it
+vi.mock("@inkandswitch/patchwork-elements", () => ({}));
+
+let host: HTMLElement;
+let dispose: () => void;
+afterEach(() => {
+ dispose?.();
+ host?.remove();
+});
+
+function mount(widgets: ToolSlot[], ready = true) {
+ host = document.body.appendChild(document.createElement("div"));
+ dispose = render(
+ () => (
+ widgets}
+ configHandle={() => undefined}
+ rootFolderUrl={() => undefined}
+ ready={() => ready}
+ />
+ ),
+ host
+ );
+ // (solid sets a custom element's `component` as a property, as SlotView's)
+ return [...host.querySelectorAll("patchwork-view")].filter(
+ (view) => (view as any).component === "call-control-panel"
+ );
+}
+
+describe("the call's control panel, in the sidebar", () => {
+ it("is there, last, after the configured widgets", () => {
+ const panels = mount([["chee/document-list", "automerge:r" as any]]);
+ expect(panels).toHaveLength(1);
+ const views = [
+ ...host.querySelectorAll(".threepane-widget > patchwork-view"),
+ ];
+ expect(views.at(-1)).toBe(panels[0]);
+ });
+
+ it("is there with no widgets configured", () => {
+ expect(mount([])).toHaveLength(1);
+ });
+
+ it("is only there once if it's configured as a widget too", () => {
+ expect(mount(["call-control-panel"])).toHaveLength(1);
+ });
+
+ it("takes no room till the panel says it wants some (or if there's no call package)", () => {
+ const [panel] = mount([]);
+ expect(panel.getAttribute("data-sidebar-size")).toBe("none");
+ });
+
+ it("waits, as the widgets do, for the main document", () => {
+ expect(mount([], false)).toHaveLength(0);
+ });
+});
diff --git a/threepane/src/components/SidebarWidgets.tsx b/threepane/src/components/SidebarWidgets.tsx
index 86358b5b..d1cf9f85 100644
--- a/threepane/src/components/SidebarWidgets.tsx
+++ b/threepane/src/components/SidebarWidgets.tsx
@@ -1,8 +1,8 @@
import type { AutomergeUrl, DocHandle } from "@automerge/automerge-repo/slim";
import { For, Show, type Accessor } from "solid-js";
import type { ThreepaneConfigDoc, ToolSlot } from "../types";
-import { SlotView } from "./SlotView";
-import { DOCUMENT_LIST_TOOL } from "../datatypes";
+import { SlotView, slotId } from "./SlotView";
+import { CALL_CONTROL_PANEL, DOCUMENT_LIST_TOOL } from "../datatypes";
/**
* The left pane: a stack of configured widgets, each rendered via SlotView — a
@@ -10,6 +10,13 @@ import { DOCUMENT_LIST_TOOL } from "../datatypes";
* string renders a `patchwork:component`. Normally seeded with a document list
* on the account's root folder; the empty-state add button is a fallback for
* when no widgets are configured (there's no add/remove UI yet).
+ *
+ * The call's control panel always comes last, whatever's configured (unless
+ * it's configured itself). It takes no room until there's a call to show.
+ *
+ * A widget says how much room it wants with `data-sidebar-size` on its own
+ * element: `fill` (the default: shares what's left), `fit` (as tall as it
+ * is) or `none` (no room at all). styles.css reads it.
*/
export function SidebarWidgets(props: {
widgets: Accessor;
@@ -28,6 +35,9 @@ export function SidebarWidgets(props: {
});
};
+ const callConfigured = () =>
+ props.widgets().some((slot) => slotId(slot) === CALL_CONTROL_PANEL);
+
return (
);
diff --git a/threepane/src/datatypes.ts b/threepane/src/datatypes.ts
index 3d2e7b06..3d0250ad 100644
--- a/threepane/src/datatypes.ts
+++ b/threepane/src/datatypes.ts
@@ -26,6 +26,14 @@ export const DEFAULT_DOCTITLE_TOOLS: string[] = [
*/
export const DOCUMENT_LIST_TOOL = "chee/document-list";
+/**
+ * The call (the `call` package): its provider, mounted round the whole frame,
+ * and its control panel, always at the bottom of the sidebar's widgets.
+ * Late-bound component ids: without the package they render nothing.
+ */
+export const CALL_PROVIDER = "call-provider";
+export const CALL_CONTROL_PANEL = "call-control-panel";
+
/**
* The threepane config doc's schema version. Defaults are only filled in when a
* doc is created or migrated up to this version, never on later mounts, so a
diff --git a/threepane/src/styles.css b/threepane/src/styles.css
index f82a3d00..5e140b34 100644
--- a/threepane/src/styles.css
+++ b/threepane/src/styles.css
@@ -700,6 +700,20 @@
height: 100%;
}
+/* a widget as tall as it is (data-sidebar-size="fit" on its view), or with
+ no room at all ("none"); "fill", or nothing said, shares what's left */
+.threepane-widget:has(> [data-sidebar-size="fit"]) {
+ flex: 0 0 auto;
+}
+
+.threepane-widget:has(> [data-sidebar-size="fit"]) patchwork-view {
+ height: auto;
+}
+
+.threepane-widget:has(> [data-sidebar-size="none"]) {
+ display: none;
+}
+
/* empty state: offer to add the document list */
.threepane-widgets__empty {
flex: 1;