diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9e2e3c9 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1468 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +dependencies = [ + "alsa-sys", + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1" +dependencies = [ + "alsa", + "block2", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", + "web-sys", + "windows", + "windows-core", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rtrb" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5974e397833d890f2f74963408e0d4a6cd8eda2f6a445ae06750291b5d7a997e" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 2.0.21", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "zhila-core" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror 2.0.21", + "zhila-protocol", +] + +[[package]] +name = "zhila-daemon" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "futures-util", + "serde_json", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", + "zhila-core", + "zhila-engine", + "zhila-protocol", +] + +[[package]] +name = "zhila-engine" +version = "0.1.0" +dependencies = [ + "cpal", + "rtrb", + "thiserror 2.0.21", + "zhila-core", + "zhila-protocol", +] + +[[package]] +name = "zhila-protocol" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..88b6576 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,33 @@ +[workspace] +resolver = "2" +members = [ + "crates/zhila-protocol", + "crates/zhila-core", + "crates/zhila-engine", + "crates/zhila-daemon", +] +exclude = ["app"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT" +authors = ["ZHILA"] + +[workspace.dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +tokio = { version = "1", features = ["rt", "macros", "net", "sync", "signal", "time"] } +tokio-tungstenite = "0.30" +futures-util = { version = "0.3", features = ["sink"] } +cpal = "0.18" +rtrb = "0.4" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1" +clap = { version = "4", features = ["derive"] } + +[profile.release] +lto = "thin" +codegen-units = 1 diff --git a/README.md b/README.md index cc6801b..0df07f6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,77 @@ # ZHILA -Sound is terrain. A sampling instrument with an agent on the same verbs as the hand. +Sound is terrain. A sampling instrument where the material is cut in a quarry, laid as stones in a nave, and played by a hand or an agent through the same verbs. -P0 is in progress: the Rust core, the local protocol and the browser surface. See `docs/PLAN.md`. +This repository is **P0**: the engine, the wire, and the first descent surface. The visual demo lives in [`reference/demo`](reference/demo) as a design reference — not as the codebase. The roadmap is in [`docs/PLAN.md`](docs/PLAN.md). + +![P0 surface](preview-p0-playing.png) + +## What is real in P0 + +- **Rust engine.** `cpal` output stream, 48 kHz, five veins, a sixteen-step phrase, a fixed voice pool, per-vein gains and levels, a bar clock. No Web Audio. +- **Protocol v1.** JSON control frames and a 31-byte binary meter frame over a local WebSocket. +- **Session + journal.** Validation, revisions, a journal for project edits (tempo, gains), performance events that are not history. +- **Daemon.** Loopback-only, token in the hello, origin guard, snapshot on connect, results and events, binary meters at 30 Hz. +- **Surface.** A Svelte 5 descent whose terrain, eye, city, road, stones and playhead react to the five vein levels and the master; a real wire log; engine facts; cilia; a master signal trace; vein gain sliders; transport; keyboard control. +- **Verification.** Rust unit tests, a wire smoke test against live audio, a browser smoke test with screenshots, `svelte-check`, a production build. + +The Tauri 2 shell is scaffolded in [`app`](app), but **it has not been built on this machine**: the system is missing the GTK/WebKit/dbus development packages and `sudo` requires a password. The exact blocker and the install command are in [`docs/P0.md`](docs/P0.md). The browser surface is the working P0 path. + +## Quick start + +Requirements: Rust (tested on 1.99), Node 22, ALSA (present on this machine). The daemon uses the default output device; if there is none it reports offline and the surface still connects. + +```bash +./scripts/dev.sh +``` + +Open **http://127.0.0.1:5173**. The daemon listens on `ws://127.0.0.1:4317` with the development token `dev`. + +Or run the two halves separately: + +```bash +cargo run -p zhila-daemon -- --port 4317 +npm --prefix ui run dev +``` + +Controls: `space` play/stop · `s` strike the selected vein · `1`–`5` select a vein · click a lane label to select; double-click to strike · tempo `−` / `+`. + +The daemon prints the audio device, rate, buffer, sample format and session id. Use `--no-audio` for UI/protocol tests, `--port` to change the port, and `ZHILA_TOKEN` or `--token` to change the dev token. The surface reads `VITE_ZHILA_WS` and `VITE_ZHILA_TOKEN` at build time; both default to the values above. + +## Layout + +``` +crates/zhila-protocol wire types, binary meter frame, tests +crates/zhila-core session, journal, validation, engine trait +crates/zhila-engine cpal stream, voices, phrase, levels +crates/zhila-daemon WebSocket daemon, auth, broadcast +ui Svelte 5 surface (browser-first) +ui/src/lib/descent.ts the reactive canvas +app Tauri 2 shell (scaffolded, system deps missing) +docs plan, protocol, P0 notes +reference/demo the earlier design demo +``` + +## Tests + +```bash +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings +npm --prefix ui run check +npm --prefix ui run build +node scripts/wire-smoke.mjs # daemon running +npm --prefix ui run smoke # daemon + vite dev running +``` + +`npm --prefix ui run smoke` writes `preview-p0.png` and `preview-p0-playing.png` and asserts the wire, the pattern, the cilia and the live levels. + +## P0 limits (next, not hidden) + +- One fixed phrase; patterns are not editable in the surface yet. +- Five veins are fixed; the engine has per-vein gain, no sends or automation. +- The surface is read-only except transport, strike, tempo and gains. +- No session persistence, project file or archive yet. +- No agent and no MCP in this phase; the command bus and the journal are the ground for them. +- The Tauri shell is not verified; see the blocker. + +P1 is the core session and the material room: import/record, analysis, cores and faces, a zoomable waveform and a real pattern model. The plan is in [`docs/PLAN.md`](docs/PLAN.md). diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..e731309 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +src-tauri/target/ +src-tauri/gen/ diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..b6e47b0 --- /dev/null +++ b/app/README.md @@ -0,0 +1,46 @@ +# ZHILA · Tauri shell + +This is the desktop shell for the P0 surface. It is scaffolded and **not yet built** on the development machine: the GTK/WebKit/dbus development packages are missing and `sudo` requires a password. + +The shell expects the Vite surface at `http://127.0.0.1:5173` (the `devUrl` in `src-tauri/tauri.conf.json`) and the Rust daemon at `ws://127.0.0.1:4317`. The P0 window does not embed the engine yet. + +## Install the system packages (Fedora) + +```bash +sudo dnf install dbus-devel webkit2gtk4.1-devel gtk3-devel libappindicator-gtk3-devel \ + librsvg2-devel openssl-devel libsoup3-devel javascriptcoregtk4.1-devel pkgconf-pkg-config +``` + +## Run + +```bash +# terminal 1 +cd .. +cargo run -p zhila-daemon -- --port 4317 + +# terminal 2 +cd .. +npm --prefix ui run dev + +# terminal 3 +npm --prefix app install +npm --prefix app run tauri dev +``` + +## Build + +```bash +npm --prefix ui run build +npm --prefix app run tauri build -- --bundles deb +``` + +The bundle targets are `deb` and `appimage`. The icon at `src-tauri/icons/icon.png` is a P0 placeholder. + +## Observed failure before the system packages + +``` +Package 'dbus-1' was not found in the pkg-config search path. +The system library `dbus-1` required by crate `libdbus-sys` was not found. +``` + +`pkg-config` also reports missing `webkit2gtk-4.1` and `gtk+-3.0`. The exact `cargo check` output is in `docs/P0.md`. diff --git a/app/package-lock.json b/app/package-lock.json new file mode 100644 index 0000000..9cdf048 --- /dev/null +++ b/app/package-lock.json @@ -0,0 +1,232 @@ +{ + "name": "zhila-app", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zhila-app", + "version": "0.1.0", + "devDependencies": { + "@tauri-apps/cli": "^2.0.0" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.4.tgz", + "integrity": "sha512-JI9bHfudSRJikGOrjbXEm6RjzzvmKuLc+JixB1NE8hNzQkNlCCIC3pTES3uypkR9lxHrsbxzcnSwhI2IxBQphw==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.12.4", + "@tauri-apps/cli-darwin-x64": "2.12.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.12.4", + "@tauri-apps/cli-linux-arm64-musl": "2.12.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.12.4", + "@tauri-apps/cli-linux-x64-gnu": "2.12.4", + "@tauri-apps/cli-linux-x64-musl": "2.12.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.12.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.12.4", + "@tauri-apps/cli-win32-x64-msvc": "2.12.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.4.tgz", + "integrity": "sha512-OxqKSckIkoJ6vX2AjifIJpTasFC0Hbdoo7gEiCmAjauABO3U8m2MRsLWe+QTiBwDx1mIrvgA+rvI7tvDnbnHVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.4.tgz", + "integrity": "sha512-fz76/77wMMJOaaUN6n3CybRG0P/MTEjjqYk81mGPyhi9m6Ue5T+LWBnJP/AkPooNatBPI2vtulGqomyMJ44lCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.4.tgz", + "integrity": "sha512-K0w8Bof6Lt8QKLAYhRy0yy36ZH5iA/35VuSOvnxyjvCUZ+A3JKsUTlALviH/sVgAlisaPzzmeJSH8XOn4Q2HBA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.4.tgz", + "integrity": "sha512-j5rOnjmRUwWrm1oosf0zBYl4D+fqydgUROFCk0hvKZxvbTT8SOERes0QB7rBV/XWNy+kM6pCKzb3oSm2sRglzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.4.tgz", + "integrity": "sha512-aOue3Fy6Jb7+oWcfAVUsVHfeTybSCt2CZoU06aO4jv4d2Ks/KnaTPwMAK3CLEduGOvBLY1lbBBrIyomqLLH5Sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.4.tgz", + "integrity": "sha512-Fx6GShY/RvST+t6LMlTHAwd66+1tEaAAGrnU7cysVfaiRP1BKng5Vl6rVmO6rSLOA1Sp1dRzNYM4f0OWQGb7qA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.4.tgz", + "integrity": "sha512-tNzZSa79Z1nRpHxEpQ38lsAFpKlFlYvoXzjTV+lRRn/zSf3wrdUC3wnLcq2XEadchPu4ttK0zivhYzr+XGD19Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.4.tgz", + "integrity": "sha512-jmZ1pr0xEqUhuEusWtt9Yt+GF6lCnGHUmIL+KIknpbjK/XQ2qkhDBhaxK/JMHM2A8dAacVXCxXJk2EXpwhQUfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.4.tgz", + "integrity": "sha512-fGo8pgEHZxdSYgZ/J98m1usJ7NHKmKbq7k6uT0x1rpOq6Bg2YVzzpaAblWk710f5LHYBJIL5QIFS5IrD+4gmTg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.4.tgz", + "integrity": "sha512-ezl8sQIcjYsye3TUnTH5VcWmWzccZASlQm7hlZt0dYyj1XD0qpVW47u2xYVrIKui0F8uQv/7QGZixdWiClBsbg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.4.tgz", + "integrity": "sha512-DJhDeCqjLDNMWYFy7ZS8cfeAHX2RK0RbQMLiA3bMVVkXRDJ4NBYiHriGqsUhsr9iG41aTb+dJGkFPmPw1gWwVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..c656a84 --- /dev/null +++ b/app/package.json @@ -0,0 +1,12 @@ +{ + "name": "zhila-app", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "tauri": "tauri" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.0.0" + } +} diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock new file mode 100644 index 0000000..d4bbe7f --- /dev/null +++ b/app/src-tauri/Cargo.lock @@ -0,0 +1,4303 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "9.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "6.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.2", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "cargo_toml" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9" +dependencies = [ + "semver", + "serde", + "toml 1.1.8+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 3.0.9", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "defmt-parser" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.8+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "erased-serde" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e59b668157f32d3459d4bd634861a06ffa2503a461ca5f0916b9f0252605e66" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75e94d66081e4d8dcd098bd8dc5254800fea14a26389b298410158d834de167" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64 0.23.1", + "indexmap 2.14.2", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.17+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.13.2", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.3", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_vcruntime" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefd87076fd385308ee4aff597256c902f1fbb4d031de1558efc12f8ffefadd9" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5777b06fe6435126ae8d816eba269e050e1e10d302cf5461e9c49eccabc3548" +dependencies = [ + "bitflags 2.13.2", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fb0244332cfb9cee7e2a568bf5da29a36ed8fdbd717f6f47a66b6b7b358c072" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.21", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adddb0e648752aaac5da9c3c3c6a68e1e87cab55e3b757b18a9441ef8c3ac614" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "static_vcruntime", + "strsim", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb37631cf71dab5e8ab55e1bdd701e18a52653d90b78e71d0e5598b1e0a61316" +dependencies = [ + "base64 0.23.1", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.21", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc38db58854c10fb5f91a887ed8bf82d3239bb92b5d8db86eda09e96d9963d1" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-runtime" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c55766d99cea69055904d1735b93c2049302b2e09effd462120deb5581d78e9" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1aa9d8b7a120f879f44fdc1053f7b7dbf416bf0bbc6a72b714dec46b782b52f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "362ab21cd4cc713f2a02b1a9b5688198b1541cfb6907c4281e9190e0f0ef4aa3" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c52bb6904475a6ca662f15ef787f18ade61d5740a1ee12c45d3b748efdb6f796" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.8+spec-1.1.0", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b" +dependencies = [ + "icu_properties", + "regex", + "serde", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" +dependencies = [ + "thiserror 2.0.21", + "windows", + "windows-core", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "windows-sys 0.61.2", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9" +dependencies = [ + "base64 0.23.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zhila-app" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-build", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml new file mode 100644 index 0000000..ef3f17a --- /dev/null +++ b/app/src-tauri/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "zhila-app" +version = "0.1.0" +description = "ZHILA · first descent" +edition = "2021" +rust-version = "1.77.2" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[workspace] diff --git a/app/src-tauri/build.rs b/app/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/app/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json new file mode 100644 index 0000000..b8cfd4c --- /dev/null +++ b/app/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default capability for the ZHILA window", + "windows": ["main"], + "permissions": ["core:default"] +} diff --git a/app/src-tauri/icons/icon.png b/app/src-tauri/icons/icon.png new file mode 100644 index 0000000..777a2e3 Binary files /dev/null and b/app/src-tauri/icons/icon.png differ diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs new file mode 100644 index 0000000..d588524 --- /dev/null +++ b/app/src-tauri/src/main.rs @@ -0,0 +1,7 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + tauri::Builder::default() + .run(tauri::generate_context!()) + .expect("ZHILA could not open its window"); +} diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json new file mode 100644 index 0000000..d470e6e --- /dev/null +++ b/app/src-tauri/tauri.conf.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "ZHILA", + "version": "0.1.0", + "identifier": "studio.zhila.app", + "build": { + "beforeDevCommand": "", + "devUrl": "http://127.0.0.1:5173", + "beforeBuildCommand": "", + "frontendDist": "../../ui/dist" + }, + "app": { + "windows": [ + { + "label": "main", + "title": "ZHILA · first descent", + "width": 1440, + "height": 900, + "minWidth": 960, + "minHeight": 640, + "resizable": true, + "fullscreen": false, + "decorations": true + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": ["deb", "appimage"], + "icon": ["icons/icon.png"], + "category": "Music", + "shortDescription": "ZHILA", + "longDescription": "A sampling instrument where sound is terrain." + } +} diff --git a/crates/zhila-core/Cargo.toml b/crates/zhila-core/Cargo.toml new file mode 100644 index 0000000..5548459 --- /dev/null +++ b/crates/zhila-core/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "zhila-core" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +zhila-protocol = { path = "../zhila-protocol" } +serde.workspace = true +thiserror.workspace = true diff --git a/crates/zhila-core/src/lib.rs b/crates/zhila-core/src/lib.rs new file mode 100644 index 0000000..f69ac17 --- /dev/null +++ b/crates/zhila-core/src/lib.rs @@ -0,0 +1,471 @@ +//! ZHILA session core: state, journal, validation, and the commands that the +//! hand, the API and MCP will all share. +//! +//! The engine is a trait here so the core can be tested without an audio +//! device. The real engine lives in `zhila-engine`. + +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use zhila_protocol::{ + Actor, AudioInfo, Command, CommandResult, Event, LOOP_BARS, LOOP_STEPS, MAX_BPM, MAX_GAIN, + MIN_BPM, MIN_GAIN, MeterFrame, Pattern, PatternRow, STEPS_PER_BAR, State, TransportState, + VEIN_COUNT, VeinId, VeinState, +}; + +pub const INITIAL_GAINS: [f32; VEIN_COUNT] = [0.82, 0.72, 0.76, 0.62, 0.66]; + +/// The audio engine as seen by the session. Implementations must be real-time +/// safe on the audio thread and non-blocking on these methods. +pub trait Engine: Send + Sync { + fn play(&self); + fn stop(&self); + fn strike(&self, vein: VeinId, velocity: f32); + fn set_tempo(&self, bpm: f32); + fn set_gain(&self, vein: VeinId, gain: f32); + fn info(&self) -> AudioInfo; + fn meter(&self) -> MeterFrame; +} + +#[derive(Debug, Error)] +pub enum CoreError { + #[error("tempo must be between 40 and 220 bpm")] + InvalidTempo, + #[error("velocity must be between 0 and 1")] + InvalidVelocity, + #[error("gain must be between 0 and 1.2")] + InvalidGain, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct JournalEntry { + pub seq: u64, + pub revision: u64, + pub actor: Actor, + pub at_ms: u64, + pub command: Command, +} + +pub struct Session { + revision: u64, + seq: u64, + bpm: f32, + playing: bool, + gains: [f32; VEIN_COUNT], + pattern: Pattern, + journal: Vec, + engine: Arc, +} + +impl Session { + pub fn new(engine: Arc) -> Self { + Self::with_pattern(engine, default_pattern()) + } + + pub fn with_pattern(engine: Arc, pattern: Pattern) -> Self { + Self { + revision: 0, + seq: 0, + bpm: 128.0, + playing: false, + gains: INITIAL_GAINS, + pattern, + journal: Vec::new(), + engine, + } + } + + pub fn revision(&self) -> u64 { + self.revision + } + + pub fn pattern(&self) -> &Pattern { + &self.pattern + } + + pub fn journal(&self) -> &[JournalEntry] { + &self.journal + } + + pub fn snapshot(&self) -> State { + let meter = self.engine.meter(); + State { + revision: self.revision, + transport: TransportState { + playing: self.playing, + bpm: self.bpm, + bar: meter.bar, + loop_bars: LOOP_BARS, + }, + veins: VeinId::ALL + .iter() + .enumerate() + .map(|(index, vein)| VeinState { + id: *vein, + name: vein.name().to_string(), + gain: self.gains[index], + }) + .collect(), + pattern: self.pattern.clone(), + audio: self.engine.info(), + journal_len: self.journal.len(), + } + } + + pub fn apply( + &mut self, + command: Command, + actor: Actor, + ) -> Result<(CommandResult, Vec), CoreError> { + match command { + Command::GetSnapshot => Ok(( + CommandResult::Snapshot { + state: self.snapshot(), + }, + Vec::new(), + )), + Command::Play => { + if self.playing { + return Ok(( + CommandResult::State { + state: self.snapshot(), + }, + Vec::new(), + )); + } + self.playing = true; + self.engine.play(); + self.revision += 1; + Ok(( + CommandResult::State { + state: self.snapshot(), + }, + vec![Event::Transport { playing: true }], + )) + } + Command::Stop => { + if !self.playing { + return Ok(( + CommandResult::State { + state: self.snapshot(), + }, + Vec::new(), + )); + } + self.playing = false; + self.engine.stop(); + self.revision += 1; + Ok(( + CommandResult::State { + state: self.snapshot(), + }, + vec![Event::Transport { playing: false }], + )) + } + Command::Strike { vein, velocity } => { + if !velocity.is_finite() || !(0.0..=1.0).contains(&velocity) { + return Err(CoreError::InvalidVelocity); + } + self.engine.strike(vein, velocity); + Ok(( + CommandResult::State { + state: self.snapshot(), + }, + vec![Event::Struck { vein, velocity }], + )) + } + Command::SetTempo { bpm } => { + if !bpm.is_finite() || !(MIN_BPM..=MAX_BPM).contains(&bpm) { + return Err(CoreError::InvalidTempo); + } + if (self.bpm - bpm).abs() < 1e-3 { + return Ok(( + CommandResult::State { + state: self.snapshot(), + }, + Vec::new(), + )); + } + self.bpm = bpm; + self.engine.set_tempo(bpm); + self.revision += 1; + self.record(actor, Command::SetTempo { bpm }); + Ok(( + CommandResult::State { + state: self.snapshot(), + }, + vec![Event::Tempo { bpm }], + )) + } + Command::SetVeinGain { vein, gain } => { + if !gain.is_finite() || !(MIN_GAIN..=MAX_GAIN).contains(&gain) { + return Err(CoreError::InvalidGain); + } + let index = vein.index(); + if (self.gains[index] - gain).abs() < 1e-3 { + return Ok(( + CommandResult::State { + state: self.snapshot(), + }, + Vec::new(), + )); + } + self.gains[index] = gain; + self.engine.set_gain(vein, gain); + self.revision += 1; + self.record(actor, Command::SetVeinGain { vein, gain }); + Ok(( + CommandResult::State { + state: self.snapshot(), + }, + vec![Event::VeinGain { vein, gain }], + )) + } + } + } + + fn record(&mut self, actor: Actor, command: Command) { + self.seq += 1; + self.journal.push(JournalEntry { + seq: self.seq, + revision: self.revision, + actor, + at_ms: now_ms(), + command, + }); + } +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default() +} + +/// The first descent: a two-bar phrase in five veins. +pub fn default_pattern() -> Pattern { + let mut rows = Vec::with_capacity(VEIN_COUNT); + for vein in VeinId::ALL { + let mut velocities = vec![0u8; LOOP_STEPS]; + match vein { + VeinId::Bass => { + velocities[0] = 112; + velocities[3] = 86; + velocities[6] = 102; + velocities[10] = 94; + velocities[14] = 68; + } + VeinId::Crack => { + velocities[2] = 72; + velocities[7] = 108; + velocities[9] = 58; + velocities[15] = 92; + } + VeinId::Voice => { + velocities[0] = 64; + velocities[8] = 84; + } + VeinId::Noise => { + velocities[4] = 54; + velocities[11] = 62; + velocities[13] = 42; + } + VeinId::Silt => { + velocities[3] = 76; + velocities[13] = 60; + } + } + rows.push(PatternRow { vein, velocities }); + } + Pattern { + steps: LOOP_STEPS as u16, + steps_per_bar: STEPS_PER_BAR as u16, + rows, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + struct TestEngine { + calls: Mutex>, + info: AudioInfo, + meter: MeterFrame, + } + + impl TestEngine { + fn new() -> Self { + Self { + calls: Mutex::new(Vec::new()), + info: AudioInfo { + running: true, + device: "test".into(), + sample_rate: 48_000, + buffer_frames: 256, + channels: 2, + }, + meter: MeterFrame { + bar: 2.25, + master: 0.3, + veins: [0.1, 0.2, 0.3, 0.4, 0.5], + }, + } + } + + fn calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } + } + + impl Engine for TestEngine { + fn play(&self) { + self.calls.lock().unwrap().push("play".into()); + } + fn stop(&self) { + self.calls.lock().unwrap().push("stop".into()); + } + fn strike(&self, vein: VeinId, velocity: f32) { + self.calls + .lock() + .unwrap() + .push(format!("strike:{}:{velocity}", vein.name())); + } + fn set_tempo(&self, bpm: f32) { + self.calls.lock().unwrap().push(format!("tempo:{bpm}")); + } + fn set_gain(&self, vein: VeinId, gain: f32) { + self.calls + .lock() + .unwrap() + .push(format!("gain:{}:{gain}", vein.name())); + } + fn info(&self) -> AudioInfo { + self.info.clone() + } + fn meter(&self) -> MeterFrame { + self.meter + } + } + + fn session() -> (Session, Arc) { + let engine = Arc::new(TestEngine::new()); + (Session::new(engine.clone()), engine) + } + + #[test] + fn default_pattern_is_complete() { + let pattern = default_pattern(); + assert_eq!(pattern.steps as usize, LOOP_STEPS); + assert_eq!(pattern.rows.len(), VEIN_COUNT); + for row in &pattern.rows { + assert_eq!(row.velocities.len(), LOOP_STEPS); + assert!(row.velocities.iter().any(|v| *v > 0)); + } + } + + #[test] + fn play_updates_state_without_journaling() { + let (mut session, engine) = session(); + let (result, events) = session.apply(Command::Play, Actor::Hand).unwrap(); + let CommandResult::State { state } = result else { + panic!("expected state"); + }; + assert!(state.transport.playing); + assert_eq!(state.revision, 1); + assert_eq!(session.journal().len(), 0); + assert_eq!(engine.calls(), vec!["play"]); + assert_eq!(events, vec![Event::Transport { playing: true }]); + } + + #[test] + fn tempo_is_validated_and_journaled() { + let (mut session, engine) = session(); + let (result, events) = session + .apply(Command::SetTempo { bpm: 132.0 }, Actor::Hand) + .unwrap(); + let CommandResult::State { state } = result else { + panic!("expected state"); + }; + assert_eq!(state.transport.bpm, 132.0); + assert_eq!(session.journal().len(), 1); + assert_eq!(session.journal()[0].actor, Actor::Hand); + assert_eq!(session.journal()[0].revision, 1); + assert_eq!(events, vec![Event::Tempo { bpm: 132.0 }]); + assert_eq!(engine.calls(), vec!["tempo:132"]); + } + + #[test] + fn tempo_out_of_range_is_rejected() { + let (mut session, engine) = session(); + assert!(matches!( + session.apply(Command::SetTempo { bpm: 12.0 }, Actor::Hand), + Err(CoreError::InvalidTempo) + )); + assert!(engine.calls().is_empty()); + assert_eq!(session.journal().len(), 0); + } + + #[test] + fn strikes_are_performance_not_history() { + let (mut session, engine) = session(); + let (result, events) = session + .apply( + Command::Strike { + vein: VeinId::Bass, + velocity: 0.8, + }, + Actor::Hand, + ) + .unwrap(); + let CommandResult::State { state } = result else { + panic!("expected state"); + }; + assert_eq!(state.revision, 0); + assert_eq!(session.journal().len(), 0); + assert_eq!(events.len(), 1); + assert_eq!(engine.calls()[0], "strike:bass:0.8"); + } + + #[test] + fn gain_is_validated() { + let (mut session, _) = session(); + assert!(matches!( + session.apply( + Command::SetVeinGain { + vein: VeinId::Noise, + gain: 2.0 + }, + Actor::Api + ), + Err(CoreError::InvalidGain) + )); + session + .apply( + Command::SetVeinGain { + vein: VeinId::Noise, + gain: 0.55, + }, + Actor::Api, + ) + .unwrap(); + assert_eq!(session.journal().len(), 1); + assert_eq!(session.journal()[0].actor, Actor::Api); + } + + #[test] + fn snapshot_carries_meter_and_pattern() { + let (session, _) = session(); + let state = session.snapshot(); + assert_eq!(state.transport.bar, 2.25); + assert_eq!(state.transport.loop_bars, LOOP_BARS); + assert_eq!(state.veins.len(), VEIN_COUNT); + assert_eq!(state.pattern.rows.len(), VEIN_COUNT); + assert_eq!(state.audio.device, "test"); + assert_eq!(state.journal_len, 0); + } +} diff --git a/crates/zhila-daemon/Cargo.toml b/crates/zhila-daemon/Cargo.toml new file mode 100644 index 0000000..ff8176d --- /dev/null +++ b/crates/zhila-daemon/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "zhila-daemon" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "zhila-daemon" +path = "src/main.rs" + +[dependencies] +zhila-core = { path = "../zhila-core" } +zhila-engine = { path = "../zhila-engine" } +zhila-protocol = { path = "../zhila-protocol" } +tokio.workspace = true +tokio-tungstenite.workspace = true +futures-util.workspace = true +serde_json.workspace = true +anyhow.workspace = true +clap.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true diff --git a/crates/zhila-daemon/src/main.rs b/crates/zhila-daemon/src/main.rs new file mode 100644 index 0000000..904d7d2 --- /dev/null +++ b/crates/zhila-daemon/src/main.rs @@ -0,0 +1,437 @@ +//! ZHILA local daemon: one WebSocket endpoint, one session, one engine. +//! +//! Text frames are JSON control messages; meter frames are binary. The daemon +//! binds to loopback only and requires a token in the hello. + +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::Context; +use clap::Parser; +use futures_util::stream::SplitSink; +use futures_util::{SinkExt, StreamExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{Mutex, broadcast, mpsc}; +use tokio::time::{interval, timeout}; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response}; +use tokio_tungstenite::{WebSocketStream, accept_hdr_async}; +use tracing::{info, warn}; +use zhila_core::{CoreError, Engine, Session}; +use zhila_engine::{AudioEngine, NullEngine}; +use zhila_protocol::{ + Actor, ClientMessage, CommandResult, ErrorCode, PROTOCOL_VERSION, ServerMessage, +}; + +const HELLO_TIMEOUT: Duration = Duration::from_secs(5); +const METER_INTERVAL: Duration = Duration::from_millis(33); + +type WsSink = SplitSink, Message>; + +#[derive(Debug, Parser)] +#[command( + name = "zhila-daemon", + version, + about = "ZHILA local engine daemon (P0)" +)] +struct Args { + /// WebSocket port on loopback. + #[arg(long, default_value_t = 4317)] + port: u16, + /// Shared token for the hello. Dev default; change it for anything real. + #[arg(long, default_value = "dev")] + token: String, + /// Run without an output device; useful for UI tests. + #[arg(long)] + no_audio: bool, +} + +#[derive(Clone)] +enum Outbound { + Text(String), + Binary(Vec), +} + +#[derive(Clone)] +struct Shared { + session: Arc>, + out: broadcast::Sender, + token: Arc, + session_id: Arc, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "zhila_daemon=info,zhila_engine=info".into()); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .init(); + + let args = Args::parse(); + let token = std::env::var("ZHILA_TOKEN").unwrap_or(args.token); + + let pattern = zhila_core::default_pattern(); + let audio_holder = if args.no_audio { + None + } else { + match AudioEngine::start(&pattern) { + Ok(engine) => Some(engine), + Err(error) => { + warn!("audio offline: {error}"); + None + } + } + }; + let control: Arc = match &audio_holder { + Some(engine) => engine.control(), + None => Arc::new(NullEngine::new()), + }; + let shutdown = control.clone(); + let info = control.info(); + let session = Arc::new(Mutex::new(Session::with_pattern(control.clone(), pattern))); + let (out, _) = broadcast::channel::(128); + let session_id = format!("zhila-{:x}", now_ms()); + let shared = Shared { + session, + out: out.clone(), + token: Arc::new(token.clone()), + session_id: Arc::new(session_id.clone()), + }; + + let meter_engine = control.clone(); + let meter_out = out.clone(); + tokio::spawn(async move { + let mut ticker = interval(METER_INTERVAL); + loop { + ticker.tick().await; + let frame = meter_engine.meter(); + let _ = meter_out.send(Outbound::Binary(frame.encode())); + } + }); + + let listener = TcpListener::bind(("127.0.0.1", args.port)) + .await + .with_context(|| format!("could not bind 127.0.0.1:{}", args.port))?; + + print_banner(args.port, &token, &info, &session_id); + if token == "dev" { + warn!("using the development token; pass --token or ZHILA_TOKEN to change it"); + } + + loop { + tokio::select! { + accepted = listener.accept() => { + let (stream, peer) = accepted.context("accept failed")?; + let shared = shared.clone(); + tokio::spawn(async move { + if let Err(error) = handle(stream, shared).await { + warn!("connection {peer}: {error:#}"); + } + }); + } + _ = tokio::signal::ctrl_c() => { + info!("shutting down"); + break; + } + } + } + + shutdown.stop(); + Ok(()) +} + +fn print_banner(port: u16, token: &str, info: &zhila_protocol::AudioInfo, session_id: &str) { + println!(); + println!(" ZHILA · P0 engine"); + println!(" ws://127.0.0.1:{port} · token {token}"); + if info.running { + let buffer = if info.buffer_frames == 0 { + "default".to_string() + } else { + info.buffer_frames.to_string() + }; + println!( + " audio {} · {} Hz · {} ch · buffer {}", + info.device, info.sample_rate, info.channels, buffer + ); + } else { + println!(" audio offline · the surface will connect, but nothing will sound"); + } + println!(" session {session_id}"); + println!(); +} + +async fn handle(stream: TcpStream, shared: Shared) -> anyhow::Result<()> { + let peer = stream.peer_addr().ok(); + let ws = accept_hdr_async(stream, handshake) + .await + .context("websocket handshake failed")?; + + let (mut sink, mut source) = ws.split(); + + let first = match timeout(HELLO_TIMEOUT, source.next()).await { + Ok(Some(Ok(message))) => message, + Ok(Some(Err(error))) => return Err(error.into()), + Ok(None) => return Ok(()), + Err(_) => { + let _ = send_out( + &mut sink, + error_message( + None, + ErrorCode::Protocol, + "expected a hello within 5 seconds", + ), + ) + .await; + return Ok(()); + } + }; + + let Message::Text(text) = first else { + let _ = send_out( + &mut sink, + error_message(None, ErrorCode::Protocol, "the first frame must be a hello"), + ) + .await; + return Ok(()); + }; + + let hello: ClientMessage = match serde_json::from_str(text.as_str()) { + Ok(message) => message, + Err(_) => { + let _ = send_out( + &mut sink, + error_message(None, ErrorCode::BadRequest, "malformed hello"), + ) + .await; + return Ok(()); + } + }; + + let (client, protocol, token) = match hello { + ClientMessage::Hello { + client, + protocol, + token, + } => (client, protocol, token), + ClientMessage::Command { .. } => { + let _ = send_out( + &mut sink, + error_message(None, ErrorCode::Protocol, "expected hello before commands"), + ) + .await; + return Ok(()); + } + }; + + if protocol != PROTOCOL_VERSION { + let _ = send_out( + &mut sink, + error_message( + None, + ErrorCode::Protocol, + format!( + "protocol mismatch: this daemon speaks {PROTOCOL_VERSION}, the client sent {protocol}" + ), + ), + ) + .await; + return Ok(()); + } + if token.as_str() != shared.token.as_str() { + let _ = send_out( + &mut sink, + error_message(None, ErrorCode::Unauthorized, "the token was not accepted"), + ) + .await; + return Ok(()); + } + + let state = shared.session.lock().await.snapshot(); + info!(?peer, client = %client, "wire opened"); + send_out( + &mut sink, + Outbound::Text( + serde_json::to_string(&ServerMessage::Hello { + protocol: PROTOCOL_VERSION, + session: shared.session_id.as_ref().clone(), + engine: state.audio.clone(), + }) + .expect("hello serializes"), + ), + ) + .await?; + let revision = state.revision; + send_out( + &mut sink, + Outbound::Text( + serde_json::to_string(&ServerMessage::Snapshot { revision, state }) + .expect("snapshot serializes"), + ), + ) + .await?; + + let (direct_tx, mut direct_rx) = mpsc::unbounded_channel::(); + let mut events = shared.out.subscribe(); + let writer = tokio::spawn(async move { + loop { + tokio::select! { + direct = direct_rx.recv() => { + match direct { + Some(outbound) => { + if send_out(&mut sink, outbound).await.is_err() { + break; + } + } + None => break, + } + } + broadcast = events.recv() => { + match broadcast { + Ok(outbound) => { + if send_out(&mut sink, outbound).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + } + } + } + } + }); + + while let Some(frame) = source.next().await { + let message = match frame { + Ok(Message::Text(text)) => text, + Ok(Message::Close(_)) => break, + Ok(_) => continue, + Err(error) => { + warn!("wire read error: {error}"); + break; + } + }; + + let message: ClientMessage = match serde_json::from_str(message.as_str()) { + Ok(message) => message, + Err(_) => { + let _ = direct_tx.send(error_message( + None, + ErrorCode::BadRequest, + "malformed message", + )); + continue; + } + }; + + match message { + ClientMessage::Hello { .. } => { + let _ = direct_tx.send(error_message(None, ErrorCode::Protocol, "already greeted")); + } + ClientMessage::Command { id, command } => { + let outcome = { + let mut session = shared.session.lock().await; + session.apply(command, Actor::Hand) + }; + match outcome { + Ok((result, emitted)) => { + let revision = match &result { + CommandResult::Snapshot { state } | CommandResult::State { state } => { + state.revision + } + }; + let _ = direct_tx.send(Outbound::Text( + serde_json::to_string(&ServerMessage::Result { id, result }) + .expect("result serializes"), + )); + for event in emitted { + let message = ServerMessage::Event { revision, event }; + let _ = shared + .out + .send(Outbound::Text(serde_json::to_string(&message).unwrap())); + } + } + Err(error) => { + let _ = direct_tx.send(error_message( + Some(id), + error_code(&error), + error.to_string(), + )); + } + } + } + } + } + + drop(direct_tx); + let _ = writer.await; + info!(?peer, "wire closed"); + Ok(()) +} + +#[allow(clippy::result_large_err)] +fn handshake(request: &Request, response: Response) -> Result { + if let Some(origin) = request + .headers() + .get("origin") + .and_then(|value| value.to_str().ok()) + && !origin_allowed(origin) + { + return Err(ErrorResponse::new(Some( + "origin is not allowed to talk to ZHILA".into(), + ))); + } + Ok(response) +} + +fn origin_allowed(origin: &str) -> bool { + if origin == "null" { + return true; + } + let Some((scheme, rest)) = origin.split_once("://") else { + return false; + }; + if !matches!(scheme, "http" | "https" | "tauri") { + return false; + } + let authority = rest.split('/').next().unwrap_or_default(); + let host = authority.split(':').next().unwrap_or_default(); + matches!(host, "localhost" | "127.0.0.1" | "tauri.localhost") +} + +fn error_code(error: &CoreError) -> ErrorCode { + match error { + CoreError::InvalidTempo | CoreError::InvalidVelocity | CoreError::InvalidGain => { + ErrorCode::BadRequest + } + } +} + +fn error_message(id: Option, code: ErrorCode, message: impl Into) -> Outbound { + Outbound::Text( + serde_json::to_string(&ServerMessage::Error { + id, + code, + message: message.into(), + }) + .expect("error serializes"), + ) +} + +async fn send_out(sink: &mut WsSink, outbound: Outbound) -> anyhow::Result<()> { + let message = match outbound { + Outbound::Text(text) => Message::Text(text.into()), + Outbound::Binary(bytes) => Message::Binary(bytes.into()), + }; + sink.send(message).await.context("websocket send failed")?; + Ok(()) +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default() +} diff --git a/crates/zhila-engine/Cargo.toml b/crates/zhila-engine/Cargo.toml new file mode 100644 index 0000000..c323e68 --- /dev/null +++ b/crates/zhila-engine/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "zhila-engine" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +zhila-core = { path = "../zhila-core" } +zhila-protocol = { path = "../zhila-protocol" } +cpal.workspace = true +rtrb.workspace = true +thiserror.workspace = true diff --git a/crates/zhila-engine/src/lib.rs b/crates/zhila-engine/src/lib.rs new file mode 100644 index 0000000..01538c4 --- /dev/null +++ b/crates/zhila-engine/src/lib.rs @@ -0,0 +1,538 @@ +//! ZHILA audio engine, P0. +//! +//! A real cpal stream with a fixed voice pool, a two-bar phrase, per-vein +//! levels and a bar position. Everything that runs in the callback is +//! allocation-free. The control surface is lock-free on the audio side. + +use std::f32::consts::{FRAC_PI_4, TAU}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{BufferSize, FromSample, SampleFormat, SizedSample, Stream, StreamConfig}; +use rtrb::{Consumer, Producer, RingBuffer}; +use thiserror::Error; +use zhila_core::Engine as EngineControl; +use zhila_protocol::{ + AudioInfo, LOOP_STEPS, MAX_BPM, MAX_GAIN, MIN_BPM, MIN_GAIN, MeterFrame, Pattern, + STEPS_PER_BAR, VEIN_COUNT, VeinId, +}; + +const VOICES: usize = 32; + +#[derive(Debug, Clone, Copy)] +enum AudioCommand { + Play, + Stop, + Strike { vein: u8, velocity: f32 }, + SetTempo(f32), + SetGain { vein: u8, gain: f32 }, +} + +struct Levels { + master: AtomicU32, + veins: [AtomicU32; VEIN_COUNT], + bar: AtomicU32, +} + +impl Levels { + fn new() -> Self { + Self { + master: AtomicU32::new(0.0f32.to_bits()), + veins: std::array::from_fn(|_| AtomicU32::new(0.0f32.to_bits())), + bar: AtomicU32::new(1.0f32.to_bits()), + } + } + + fn store(&self, frame: &MeterFrame) { + self.master.store(frame.master.to_bits(), Ordering::Relaxed); + self.bar.store(frame.bar.to_bits(), Ordering::Relaxed); + for (level, slot) in frame.veins.iter().zip(self.veins.iter()) { + slot.store(level.to_bits(), Ordering::Relaxed); + } + } + + fn load(&self) -> MeterFrame { + let master = f32::from_bits(self.master.load(Ordering::Relaxed)); + let bar = f32::from_bits(self.bar.load(Ordering::Relaxed)); + let mut veins = [0.0; VEIN_COUNT]; + for (level, slot) in veins.iter_mut().zip(self.veins.iter()) { + *level = f32::from_bits(slot.load(Ordering::Relaxed)); + } + MeterFrame { bar, master, veins } + } +} + +struct Voice { + active: bool, + vein: usize, + phase: f32, + freq: f32, + t: f32, + attack: f32, + decay: f32, + velocity: f32, + gain_l: f32, + gain_r: f32, + lp: f32, + hp: f32, + prev: f32, + rng: u32, +} + +impl Voice { + fn inactive() -> Self { + Self { + active: false, + vein: 0, + phase: 0.0, + freq: 0.0, + t: 0.0, + attack: 0.01, + decay: 4.0, + velocity: 0.0, + gain_l: 0.707, + gain_r: 0.707, + lp: 0.0, + hp: 0.0, + prev: 0.0, + rng: 0x9e37_79b9, + } + } + + fn trigger(&mut self, vein: VeinId, velocity: f32, seed: u32) { + self.active = true; + self.vein = vein.index(); + self.phase = 0.0; + self.t = 0.0; + self.velocity = velocity.clamp(0.0, 1.0); + self.rng = seed | 1; + self.lp = 0.0; + self.hp = 0.0; + self.prev = 0.0; + let (freq, attack, decay, pan) = match vein { + VeinId::Bass => (55.0, 0.004, 3.2, 0.0), + VeinId::Crack => (0.0, 0.001, 38.0, 0.35), + VeinId::Voice => (220.0, 0.09, 1.1, -0.25), + VeinId::Noise => (0.0, 0.004, 5.0, 0.55), + VeinId::Silt => (1318.5, 0.002, 14.0, -0.45), + }; + self.freq = freq; + self.attack = attack; + self.decay = decay; + let angle = (pan + 1.0) * FRAC_PI_4; + self.gain_l = angle.cos(); + self.gain_r = angle.sin(); + } + + fn noise(&mut self) -> f32 { + self.rng = self.rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (self.rng as f32 / u32::MAX as f32) * 2.0 - 1.0 + } + + fn next(&mut self, sample_rate: f32) -> f32 { + self.t += 1.0 / sample_rate; + let attack = (self.t / self.attack).min(1.0); + let env = attack * (-self.t * self.decay).exp(); + if env < 0.0002 || self.t > 8.0 { + self.active = false; + return 0.0; + } + let sample = match self.vein { + 0 => { + let fundamental = (TAU * self.phase).sin(); + let sub = (TAU * self.phase * 0.5).sin(); + fundamental + sub * 0.45 + } + 1 => { + let white = self.noise(); + let hp = white - self.prev + 0.94 * self.hp; + self.prev = white; + self.hp = hp; + hp * 0.7 + } + 2 => { + let fundamental = (TAU * self.phase).sin(); + let overtone = (TAU * self.phase * 2.0).sin(); + fundamental + overtone * 0.18 + } + 3 => { + let white = self.noise(); + self.lp += (white - self.lp) * 0.06; + self.lp + } + _ => (TAU * self.phase).sin(), + }; + if self.freq > 0.0 { + self.phase += self.freq / sample_rate; + if self.phase >= 1.0 { + self.phase -= 1.0; + } + } + sample * env * self.velocity + } +} + +struct AudioState { + rx: Consumer, + sample_rate: f32, + channels: usize, + bpm: f32, + playing: bool, + step: usize, + pending: f64, + pattern: [[u8; LOOP_STEPS]; VEIN_COUNT], + voices: [Voice; VOICES], + gains: [f32; VEIN_COUNT], + master_env: f32, + vein_env: [f32; VEIN_COUNT], + bar: f32, + rng: u32, +} + +impl AudioState { + fn new(sample_rate: u32, channels: u16, pattern: &Pattern, rx: Consumer) -> Self { + let mut grid = [[0u8; LOOP_STEPS]; VEIN_COUNT]; + for row in &pattern.rows { + let index = row.vein.index(); + for (step, velocity) in row.velocities.iter().take(LOOP_STEPS).enumerate() { + grid[index][step] = *velocity; + } + } + Self { + rx, + sample_rate: sample_rate as f32, + channels: channels as usize, + bpm: 128.0, + playing: false, + step: 0, + pending: 0.0, + pattern: grid, + voices: std::array::from_fn(|_| Voice::inactive()), + gains: zhila_core::INITIAL_GAINS, + master_env: 0.0, + vein_env: [0.0; VEIN_COUNT], + bar: 1.0, + rng: 0x51ed_2701, + } + } + + fn step_samples(&self) -> f64 { + self.sample_rate as f64 * 60.0 / self.bpm as f64 / 2.0 + } + + fn handle(&mut self, command: AudioCommand) { + match command { + AudioCommand::Play => { + self.playing = true; + self.step = 0; + self.pending = 0.0; + self.bar = 1.0; + } + AudioCommand::Stop => { + self.playing = false; + self.step = 0; + self.pending = 0.0; + self.bar = 1.0; + self.master_env = 0.0; + self.vein_env = [0.0; VEIN_COUNT]; + for voice in &mut self.voices { + voice.active = false; + } + } + AudioCommand::Strike { vein, velocity } => { + let Some(vein) = VeinId::from_index(vein as usize) else { + return; + }; + self.trigger(vein, velocity); + } + AudioCommand::SetTempo(bpm) => { + self.bpm = bpm.clamp(MIN_BPM, MAX_BPM); + } + AudioCommand::SetGain { vein, gain } => { + if let Some(index) = VeinId::from_index(vein as usize) { + self.gains[index.index()] = gain.clamp(MIN_GAIN, MAX_GAIN); + } + } + } + } + + fn trigger(&mut self, vein: VeinId, velocity: f32) { + if velocity <= 0.0 { + return; + } + let index = self + .voices + .iter() + .position(|voice| !voice.active) + .unwrap_or_else(|| { + self.voices + .iter() + .enumerate() + .max_by(|a, b| a.1.t.total_cmp(&b.1.t)) + .map(|(index, _)| index) + .unwrap_or(0) + }); + self.rng = self.rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + self.voices[index].trigger(vein, velocity, self.rng); + } + + fn trigger_step(&mut self) { + let step = self.step; + for vein in 0..VEIN_COUNT { + let velocity = self.pattern[vein][step]; + if velocity > 0 + && let Some(vein) = VeinId::from_index(vein) + { + self.trigger(vein, velocity as f32 / 127.0); + } + } + self.bar = step as f32 / STEPS_PER_BAR as f32 + 1.0; + self.step = (step + 1) % LOOP_STEPS; + } + + fn render(&mut self, data: &mut [T], levels: &Levels) + where + T: SizedSample + FromSample, + { + while let Ok(command) = self.rx.pop() { + self.handle(command); + } + + for frame in data.chunks_mut(self.channels) { + if self.playing { + if self.pending <= 0.0 { + self.trigger_step(); + self.pending += self.step_samples(); + } + self.pending -= 1.0; + } + + let mut left = 0.0f32; + let mut right = 0.0f32; + let mut sums = [0.0f32; VEIN_COUNT]; + for voice in &mut self.voices { + if !voice.active { + continue; + } + let sample = voice.next(self.sample_rate); + let index = voice.vein; + let amplitude = sample * self.gains[index]; + left += amplitude * voice.gain_l; + right += amplitude * voice.gain_r; + sums[index] += amplitude.abs(); + } + + let peak = (left.abs() + right.abs()) * 0.5; + self.master_env += (peak - self.master_env) * 0.0015; + for (env, sum) in self.vein_env.iter_mut().zip(sums.iter()) { + *env += (*sum - *env) * 0.002; + } + + let out_l = (left * 0.9).tanh() * 0.8; + let out_r = (right * 0.9).tanh() * 0.8; + for (channel, sample) in frame.iter_mut().enumerate() { + let value = match (self.channels, channel) { + (1, _) => (out_l + out_r) * 0.5, + (_, 0) => out_l, + (_, 1) => out_r, + _ => (out_l + out_r) * 0.5, + }; + *sample = T::from_sample(value); + } + } + + levels.store(&MeterFrame { + bar: self.bar, + master: self.master_env, + veins: self.vein_env, + }); + } +} + +/// The send side of the engine. Cheap, lock-free for the audio thread and +/// safe to share with the session and the WebSocket daemon. +pub struct Control { + tx: Mutex>, + levels: Arc, + info: AudioInfo, +} + +impl EngineControl for Control { + fn play(&self) { + self.send(AudioCommand::Play); + } + + fn stop(&self) { + self.send(AudioCommand::Stop); + } + + fn strike(&self, vein: VeinId, velocity: f32) { + self.send(AudioCommand::Strike { + vein: vein.index() as u8, + velocity, + }); + } + + fn set_tempo(&self, bpm: f32) { + self.send(AudioCommand::SetTempo(bpm)); + } + + fn set_gain(&self, vein: VeinId, gain: f32) { + self.send(AudioCommand::SetGain { + vein: vein.index() as u8, + gain, + }); + } + + fn info(&self) -> AudioInfo { + self.info.clone() + } + + fn meter(&self) -> MeterFrame { + self.levels.load() + } +} + +impl Control { + fn send(&self, command: AudioCommand) { + if let Ok(mut tx) = self.tx.lock() { + let _ = tx.push(command); + } + } +} + +#[derive(Debug, Error)] +pub enum EngineError { + #[error("no default audio output device")] + NoDevice, + #[error("audio backend: {0}")] + Backend(#[from] cpal::Error), + #[error("unsupported sample format: {0:?}")] + UnsupportedFormat(SampleFormat), +} + +struct Started { + stream: Stream, + control: Arc, +} + +/// Keeps the cpal stream alive. The control handle can be shared freely; the +/// stream itself must stay on the thread that created it. +pub struct AudioEngine { + _stream: Stream, + control: Arc, +} + +impl AudioEngine { + pub fn start(pattern: &Pattern) -> Result { + let started = match Self::try_start(pattern, BufferSize::Fixed(256)) { + Ok(started) => started, + Err(EngineError::Backend(_)) => Self::try_start(pattern, BufferSize::Default)?, + Err(error) => return Err(error), + }; + Ok(Self { + _stream: started.stream, + control: started.control, + }) + } + + pub fn control(&self) -> Arc { + self.control.clone() + } + + pub fn info(&self) -> AudioInfo { + self.control.info.clone() + } + + fn try_start(pattern: &Pattern, buffer: BufferSize) -> Result { + let host = cpal::default_host(); + let device = host.default_output_device().ok_or(EngineError::NoDevice)?; + let supported = device.default_output_config()?; + let format = supported.sample_format(); + let mut config: StreamConfig = supported.into(); + config.buffer_size = buffer; + let sample_rate = config.sample_rate; + let channels = config.channels; + let device_name = device.to_string(); + let (producer, consumer) = RingBuffer::new(256); + let levels = Arc::new(Levels::new()); + let state = AudioState::new(sample_rate, channels, pattern, consumer); + let stream = match format { + SampleFormat::F32 => build_stream::(&device, config, state, levels.clone())?, + SampleFormat::I16 => build_stream::(&device, config, state, levels.clone())?, + other => return Err(EngineError::UnsupportedFormat(other)), + }; + let info = AudioInfo { + running: true, + device: device_name, + sample_rate, + buffer_frames: match buffer { + BufferSize::Fixed(frames) => frames, + BufferSize::Default => 0, + }, + channels, + }; + let control = Arc::new(Control { + tx: Mutex::new(producer), + levels, + info, + }); + Ok(Started { stream, control }) + } +} + +fn build_stream( + device: &cpal::Device, + config: StreamConfig, + mut state: AudioState, + levels: Arc, +) -> Result +where + T: SizedSample + FromSample + 'static, +{ + let stream = device.build_output_stream( + config, + move |data: &mut [T], _: &cpal::OutputCallbackInfo| { + state.render(data, &levels); + }, + |error| eprintln!("zhila audio: {error}"), + None, + )?; + stream.play()?; + Ok(stream) +} + +/// A silent engine for headless protocol tests and machines without an output +/// device. It reports itself as offline so the surface never pretends to play. +pub struct NullEngine { + info: AudioInfo, +} + +impl Default for NullEngine { + fn default() -> Self { + Self::new() + } +} + +impl NullEngine { + pub fn new() -> Self { + Self { + info: AudioInfo::offline(), + } + } +} + +impl EngineControl for NullEngine { + fn play(&self) {} + fn stop(&self) {} + fn strike(&self, _vein: VeinId, _velocity: f32) {} + fn set_tempo(&self, _bpm: f32) {} + fn set_gain(&self, _vein: VeinId, _gain: f32) {} + fn info(&self) -> AudioInfo { + self.info.clone() + } + fn meter(&self) -> MeterFrame { + MeterFrame::silent() + } +} diff --git a/crates/zhila-protocol/Cargo.toml b/crates/zhila-protocol/Cargo.toml new file mode 100644 index 0000000..734ca69 --- /dev/null +++ b/crates/zhila-protocol/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "zhila-protocol" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/zhila-protocol/src/lib.rs b/crates/zhila-protocol/src/lib.rs new file mode 100644 index 0000000..a8b0ec0 --- /dev/null +++ b/crates/zhila-protocol/src/lib.rs @@ -0,0 +1,340 @@ +//! ZHILA wire protocol, version 1. +//! +//! Text frames carry JSON control messages. Audio meters travel in a compact +//! binary frame (see [`MeterFrame`]) at ~30 Hz, never inside JSON. + +use serde::{Deserialize, Serialize}; + +pub const PROTOCOL_VERSION: u32 = 1; +pub const VEIN_COUNT: usize = 5; +pub const LOOP_STEPS: usize = 16; +pub const STEPS_PER_BAR: usize = 8; +pub const LOOP_BARS: f32 = 2.0; +pub const MIN_BPM: f32 = 40.0; +pub const MAX_BPM: f32 = 220.0; +pub const MIN_GAIN: f32 = 0.0; +pub const MAX_GAIN: f32 = 1.2; + +pub const METER_FRAME_KIND: u8 = 1; +/// `kind (1) | count (2) | bar (4) | master (4) | levels (5 * 4)` +pub const METER_FRAME_LEN: usize = 3 + 4 * (2 + VEIN_COUNT); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VeinId { + Bass, + Crack, + Voice, + Noise, + Silt, +} + +impl VeinId { + pub const ALL: [VeinId; VEIN_COUNT] = [ + VeinId::Bass, + VeinId::Crack, + VeinId::Voice, + VeinId::Noise, + VeinId::Silt, + ]; + + pub const fn index(self) -> usize { + match self { + VeinId::Bass => 0, + VeinId::Crack => 1, + VeinId::Voice => 2, + VeinId::Noise => 3, + VeinId::Silt => 4, + } + } + + pub const fn name(self) -> &'static str { + match self { + VeinId::Bass => "bass", + VeinId::Crack => "crack", + VeinId::Voice => "voice", + VeinId::Noise => "noise", + VeinId::Silt => "silt", + } + } + + pub const fn from_index(index: usize) -> Option { + match index { + 0 => Some(VeinId::Bass), + 1 => Some(VeinId::Crack), + 2 => Some(VeinId::Voice), + 3 => Some(VeinId::Noise), + 4 => Some(VeinId::Silt), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Actor { + Hand, + Api, + Mcp, + System, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ClientMessage { + Hello { + protocol: u32, + token: String, + client: String, + }, + Command { + id: u64, + command: Command, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ServerMessage { + Hello { + protocol: u32, + session: String, + engine: AudioInfo, + }, + Snapshot { + revision: u64, + state: State, + }, + Event { + revision: u64, + event: Event, + }, + Result { + id: u64, + result: CommandResult, + }, + Error { + id: Option, + code: ErrorCode, + message: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Command { + GetSnapshot, + Play, + Stop, + Strike { vein: VeinId, velocity: f32 }, + SetTempo { bpm: f32 }, + SetVeinGain { vein: VeinId, gain: f32 }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CommandResult { + Snapshot { state: State }, + State { state: State }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Event { + Transport { playing: bool }, + Tempo { bpm: f32 }, + VeinGain { vein: VeinId, gain: f32 }, + Struck { vein: VeinId, velocity: f32 }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + BadRequest, + Unauthorized, + Protocol, + Audio, + Internal, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AudioInfo { + pub running: bool, + pub device: String, + pub sample_rate: u32, + pub buffer_frames: u32, + pub channels: u16, +} + +impl AudioInfo { + pub fn offline() -> Self { + Self { + running: false, + device: "none".into(), + sample_rate: 48_000, + buffer_frames: 0, + channels: 2, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct State { + pub revision: u64, + pub transport: TransportState, + pub veins: Vec, + pub pattern: Pattern, + pub audio: AudioInfo, + pub journal_len: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct TransportState { + pub playing: bool, + pub bpm: f32, + pub bar: f32, + pub loop_bars: f32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct VeinState { + pub id: VeinId, + pub name: String, + pub gain: f32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Pattern { + pub steps: u16, + pub steps_per_bar: u16, + pub rows: Vec, +} + +impl Pattern { + pub fn row(&self, vein: VeinId) -> Option<&PatternRow> { + self.rows.iter().find(|row| row.vein == vein) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PatternRow { + pub vein: VeinId, + /// One velocity per step, 0..=127. Zero means the step is empty. + pub velocities: Vec, +} + +/// A single binary meter frame. Text frames never carry audio-rate data. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MeterFrame { + pub bar: f32, + pub master: f32, + pub veins: [f32; VEIN_COUNT], +} + +impl MeterFrame { + pub fn silent() -> Self { + Self { + bar: 1.0, + master: 0.0, + veins: [0.0; VEIN_COUNT], + } + } + + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(METER_FRAME_LEN); + out.push(METER_FRAME_KIND); + out.extend_from_slice(&(VEIN_COUNT as u16).to_le_bytes()); + out.extend_from_slice(&self.bar.to_le_bytes()); + out.extend_from_slice(&self.master.to_le_bytes()); + for level in self.veins { + out.extend_from_slice(&level.to_le_bytes()); + } + debug_assert_eq!(out.len(), METER_FRAME_LEN); + out + } + + pub fn decode(data: &[u8]) -> Option { + if data.len() != METER_FRAME_LEN || data[0] != METER_FRAME_KIND { + return None; + } + let count = u16::from_le_bytes([data[1], data[2]]) as usize; + if count != VEIN_COUNT { + return None; + } + let bar = read_f32(data, 3)?; + let master = read_f32(data, 7)?; + let mut veins = [0.0; VEIN_COUNT]; + for (index, level) in veins.iter_mut().enumerate() { + *level = read_f32(data, 11 + index * 4)?; + } + Some(Self { bar, master, veins }) + } +} + +fn read_f32(data: &[u8], offset: usize) -> Option { + let bytes: [u8; 4] = data.get(offset..offset + 4)?.try_into().ok()?; + Some(f32::from_le_bytes(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hello_roundtrip() { + let message = ClientMessage::Hello { + protocol: PROTOCOL_VERSION, + token: "dev".into(), + client: "ui".into(), + }; + let json = serde_json::to_string(&message).unwrap(); + assert!(json.contains("\"type\":\"hello\"")); + let back: ClientMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(back, message); + } + + #[test] + fn command_roundtrip() { + let message = ClientMessage::Command { + id: 7, + command: Command::Strike { + vein: VeinId::Bass, + velocity: 0.8, + }, + }; + let json = serde_json::to_string(&message).unwrap(); + let back: ClientMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(back, message); + } + + #[test] + fn meter_roundtrip_is_exact() { + let frame = MeterFrame { + bar: 2.75, + master: 0.42, + veins: [0.1, 0.2, 0.3, 0.4, 0.5], + }; + let bytes = frame.encode(); + assert_eq!(bytes.len(), METER_FRAME_LEN); + let back = MeterFrame::decode(&bytes).expect("valid frame"); + assert_eq!(back, frame); + } + + #[test] + fn meter_rejects_garbage() { + assert!(MeterFrame::decode(&[]).is_none()); + assert!(MeterFrame::decode(&[METER_FRAME_KIND; 4]).is_none()); + let mut bytes = MeterFrame::silent().encode(); + bytes[0] = 9; + assert!(MeterFrame::decode(&bytes).is_none()); + } + + #[test] + fn vein_indices_are_stable() { + for (index, vein) in VeinId::ALL.iter().enumerate() { + assert_eq!(vein.index(), index); + assert_eq!(VeinId::from_index(index), Some(*vein)); + } + assert_eq!(VeinId::from_index(VEIN_COUNT), None); + } +} diff --git a/docs/P0.md b/docs/P0.md new file mode 100644 index 0000000..a271fdc --- /dev/null +++ b/docs/P0.md @@ -0,0 +1,64 @@ +# P0 · hello engine + +The first phase proves the spine: Rust audio, a session with a journal, a protocol with two transports, and a surface that reacts to the sound. The demo in `reference/demo` is the visual reference; none of its code is used here. + +## What was built + +| Area | Deliverable | +|---|---| +| Protocol | `zhila-protocol`: JSON messages, five veins, pattern, state, binary meter frame; unit tests | +| Session | `zhila-core`: engine trait, validation, revisions, journal for tempo/gain, default sixteen-step phrase; tests | +| Engine | `zhila-engine`: cpal 0.18, 48 kHz stereo, fixed 256-frame buffer where available, 32-voice pool, five vein timbres, phrase clock, per-vein levels, master level; no allocation on the audio path | +| Daemon | `zhila-daemon`: WebSocket, hello/token/protocol/origin checks, snapshot, results, broadcast events, 30 Hz binary meters, current-thread Tokio, graceful Ctrl-C | +| Surface | Svelte 5 + Vite: audio-reactive descent, wire log, engine facts, cilia, signal trace, per-vein gain sliders, transport, keyboard | +| Verification | `cargo test`, `cargo clippy`, `svelte-check`, production build, live wire test, browser smoke + screenshots | +| Shell | Tauri 2 scaffold in `app`; build blocked by missing system development packages (below) | + +## Decisions taken in P0 + +- **cpal, not Web Audio.** The browser is a surface; sound is native Rust. The cpal 0.18 API uses a unified `cpal::Error`, `SampleRate = u32`, `Display` for the device name and a by-value `StreamConfig`. +- **JSON control + binary meters.** Control metadata is small and debuggable; meters are 31-byte frames at 30 Hz. +- **One daemon, one session.** Loopback + token + origin guard are the P0 security boundary. No production auth yet. +- **The journal is for project edits.** Play/stop/strike are performance and do not pollute undo. +- **The surface is a projection.** The server owns state; the UI applies snapshots, results and events. The canvas owns only its visual phase. +- **Every layer reacts to sound.** The terrain alpha/offset, eye size/glow/rings, city lights, dust, road bands, stone glow, playhead, lane fills, chrome hairlines, cilia, signal trace and master line all read the five vein levels and the master. No decorative animation is independent of the audio. + +## Tauri blocker + +`cargo check` in `app/src-tauri` failed on this machine because the GTK/WebKit/dbus development packages are absent and `sudo` needs a password: + +``` +Package 'dbus-1' was not found +The system library `dbus-1` required by crate `libdbus-sys` was not found. +``` + +`pkg-config` also reports missing `webkit2gtk-4.1` and `gtk+-3.0`. The shell is scaffolded, not verified. + +Install the Fedora development packages (one command, needs your password): + +```bash +sudo dnf install dbus-devel webkit2gtk4.1-devel gtk3-devel libappindicator-gtk3-devel \ + librsvg2-devel openssl-devel libsoup3-devel javascriptcoregtk4.1-devel pkgconf-pkg-config +``` + +Then: + +```bash +cargo check --manifest-path app/src-tauri/Cargo.toml +npm --prefix ui run build +npm --prefix app run tauri dev # expects the daemon and Vite to be running +``` + +The Tauri window loads `http://127.0.0.1:5173`; the daemon remains a separate process in P0. Embedding the engine in the Tauri backend is P1 work, after the engine's lifecycle is settled. + +## P0 exit criteria + +- A browser surface talks to a Rust daemon over a local protocol: **passed**. +- Real cpal output with five veins and non-zero meters: **passed** (Default Audio Device, 48 kHz, 256-frame buffer, master peak ~0.45 in the wire test). +- A session that validates and journals project edits: **passed** (Rust tests). +- A surface that visibly reacts to the five levels: **passed** (canvas, cilia, trace, gains; screenshots). +- A Tauri window on Linux: **blocked** by system development packages; the exact command is above. + +## Next + +P1: core session and material. Import/record, analysis, cores and faces, a zoomable waveform, a real pattern model, a flat precision view while the descent remains the primary arrangement, and the first journal-backed undo. The plan and cut lines are in `PLAN.md`. diff --git a/docs/PLAN.md b/docs/PLAN.md index f5247f4..39eebba 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1,7 +1,7 @@ # ZHILA — план проекта Черновик v1.1 · 1 ноября 2026 -Статус: **план**. Код проекта не начат. Демо `zhila/` — референс, не кодовая база. +Статус: **P0 выполнен** (см. `P0.md`). Демо `reference/demo/` — референс, не кодовая база. **Зафиксировано 1 ноября:** descent — основной вид; камень = паттерн; климат — на жилу (концепт погоды остаётся); платформа v1 — Linux; UI — Svelte 5. Журнал — §9. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..3f93fa8 --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,93 @@ +# ZHILA wire protocol · v1 + +Loopback only, one daemon per session. Text frames are JSON; one binary frame type carries meters. The protocol is deliberately small in P0; the revision model and the command bus are the parts meant to survive into P1. + +## Transport + +- WebSocket at `ws://127.0.0.1:4317` by default. +- The daemon binds `127.0.0.1` only. +- The first client frame must be `hello` within 5 seconds. +- If the client sends an `Origin`, it must be `http://localhost[:port]`, `http://127.0.0.1[:port]`, or `https://tauri.localhost`. Native clients may omit it. +- The dev token is `dev`. Set `ZHILA_TOKEN` or `--token` to change it. Tokens are compared in the hello, never put in the URL. + +## Client → daemon + +```json +{"type":"hello","protocol":1,"token":"dev","client":"zhila-ui"} +{"type":"command","id":7,"command":{"type":"play"}} +``` + +Commands: + +| command | args | notes | +|---|---|---| +| `get_snapshot` | — | returns a snapshot, no revision change | +| `play` | — | transport, idempotent | +| `stop` | — | transport, idempotent | +| `strike` | `vein` (bass/crack/voice/noise/silt), `velocity` 0..1 | performance; not journaled | +| `set_tempo` | `bpm` 40..220 | journaled | +| `set_vein_gain` | `vein`, `gain` 0..1.2 | journaled | + +## Daemon → client + +```json +{"type":"hello","protocol":1,"session":"zhila-1a193dfd50c","engine":{"running":true,"device":"Default Audio Device","sample_rate":48000,"buffer_frames":256,"channels":2}} +{"type":"snapshot","revision":0,"state":{...}} +{"type":"result","id":7,"result":{"kind":"state","state":{...}}} +{"type":"event","revision":1,"event":{"kind":"transport","playing":true}} +{"type":"error","id":7,"code":"bad_request","message":"tempo must be between 40 and 220 bpm"} +``` + +`state`: + +```json +{ + "revision": 1, + "transport": {"playing": true, "bpm": 128.0, "bar": 1.0, "loop_bars": 2.0}, + "veins": [{"id":"bass","name":"bass","gain":0.82}, "..."], + "pattern": {"steps":16,"steps_per_bar":8,"rows":[{"vein":"bass","velocities":[112,0,0,86,...]}]}, + "audio": {"running":true,"device":"Default Audio Device","sample_rate":48000,"buffer_frames":256,"channels":2}, + "journal_len": 0 +} +``` + +`result.kind` is `snapshot` or `state`; both carry the full state. + +Events: + +- `{"kind":"transport","playing":bool}` +- `{"kind":"tempo","bpm":number}` +- `{"kind":"vein_gain","vein":VeinId,"gain":number}` +- `{"kind":"struck","vein":VeinId,"velocity":number}` + +Errors: `bad_request` (validation), `unauthorized`, `protocol` (version/hello), `internal`. + +## Binary meter frame + +Sent at ~30 Hz, 31 bytes, little-endian: + +``` +offset size field +0 1 kind = 1 +1 2 count = 5 +3 4 bar (f32) +7 4 master level (f32) +11 20 five vein levels (f32 each) +``` + +The browser sets `binaryType = "arraybuffer"` and reads a `DataView`. The frame is deliberately separate from JSON; no meter ever goes through text frames. + +## Semantics + +- `revision` increments on every state-changing command: transport, tempo, gains. It is a state version, not the journal length. +- `journal_len` counts project edits only: tempo and gains in P0. Play, stop and strike are performance. +- The daemon broadcasts events to every client. A command's `result` goes only to the requester. +- The server is authoritative. The surface holds a projection and updates it from snapshots, results and events. +- P0 has no persistence; a daemon restart starts a fresh session at 128 bpm, not playing, journal empty. + +## Not in v1 yet + +- Binary snapshots / CRDTs. +- Project/asset commands. +- Agent tools and MCP. +- Per-client cursors or collaboration. diff --git a/preview-p0-playing.png b/preview-p0-playing.png new file mode 100644 index 0000000..6b59733 Binary files /dev/null and b/preview-p0-playing.png differ diff --git a/preview-p0.png b/preview-p0.png new file mode 100644 index 0000000..8f0c1d5 Binary files /dev/null and b/preview-p0.png differ diff --git a/reference/demo/README.md b/reference/demo/README.md new file mode 100644 index 0000000..e8de082 --- /dev/null +++ b/reference/demo/README.md @@ -0,0 +1,78 @@ +# ZHILA + +DAW, в котором звук лежит рельефом. Две комнаты: **nave** — собор, где камни укладываются и играются, и **quarry** — карьер под ним, где материал берут и режут. Сэмплирование здесь не «библиотека сэмплов», а карьер и кладка: у каждой вещи есть место, шрам, погода и клеймо. + +Демка играет и слушается агентом: 32-тактовый луп из настоящих срезов, живой транспорт на аудиочасах, правка камней, undo, сохранение, импорт/запись, рендер в WAV. + +## Запуск + +```bash +./start.sh +``` + +Открыть **http://127.0.0.1:4173**. Скрипт берёт ключ из `DEEPSEEK_API_KEY` или из уже сохранённого ключа harness (`~/.dsh/.credentials.yaml`); ключ остаётся на сервере и в браузер не попадает. Порт можно поменять: `PORT=5000 ./start.sh`. + +Без сервера `index.html` тоже открывается, но хор работает в локальном режиме (по правилам), без модели. + +## Агент + +Встроенный агент — это не чат сбоку, а тот же набор команд, что у руки. Модель (`deepseek-chat`) получает 14 инструментов и действует ими в живом проекте: + +`get_state`, `list_cores`, `extract_core`, `fracture_core`, `strike_face`, `place_stone`, `move_stone`, `delete_stone`, `set_weather`, `set_rings`, `set_tempo`, `transport`, `open_core`, `set_macros`. + +- Консоль `choir` показывает настоящий трейс: инструмент, аргументы, результат, время. Ответ — одна короткая реплика на языке пользователя. +- Агент видит состояние (камни, керны, погоду, позицию), проверяет, где уже занято, и не кладёт камень поверх того же диапазона без просьбы. +- `api live` в шапке — модель на связи; `api local` — сервера нет, работает локальный дирижёр по правилам (тоже через инструменты). +- Ключ и модель живут только на сервере; сервер слушает 127.0.0.1 и принимает CORS только с localhost. + +Примеры: «сделай темнее и добавь редкий шум на восемь тактов, потом включи», «возьми керн из лестничной клетки и нарежь его заново», «убери один камень из голоса, стало тесно», «покажи мне керн, из которого сделан бас». + +MCP для внешних агентов ещё не поднят: те же 14 команд — это и есть будущая поверхность. Локальный API уже отделён от UI, поэтому сервер MCP — следующий шаг. + +## nave + +- `space` — пуск/пауза. +- Камни: тащи вверх-вниз — время с привязкой к четверти такта; клик — выбрать; `Backspace` — закопать; `⌘/Ctrl+D` — дублировать; `⌘/Ctrl+Z` / `⇧⌘Z` — undo/redo. +- `crystal` — живая партитура событий вокруг playhead (только просмотр). +- `cilia` — уровни жил; красные точки — живой сигнал. `warmth / dust / threshold` правят мастер. +- `render` — офлайн-рендер 32 тактов в `zhila-descent-04.wav` (~62 c, 48 кГц, стерео). +- Поле «say what you want to hear» зовёт агента. Ползунки `shape` — предпочтения для следующего слоя; агент видит их в состоянии. + +## quarry + +- `core tray` — выбранный керн: настоящая волна, слои, шов, трещины. Линза читает глубину. +- `fracture` реально перерезает керн: 16 граней получают новые границы, и камни из этого керна начинают звучать иначе. +- `the face` — 16 граней. Удар звучит; `place` кладёт одиночный камень, `loop` — повторяющийся каждые 2 такта. +- `weather` — `age / pressure / air` меняют климат всего микса, `heat` расстраивает голоса, `ghost` добавляет хвост источника. `grow ring` усиливает эрозию петель. +- `extract`: `core` — новый процедурный керн, `file` — импорт, `room` — запись комнаты (`rec` тоже). +- `ledger` — кто, откуда и с каким клеймом взял. + +## Что работает по-настоящему + +- **Звук.** Web Audio: срезы кернов как сэмплы, 16 граней, кольца с эрозией, климат, лимитер, панорама, анализ мастера. +- **Транспорт.** Аудиочасы — мастер, lookahead-планировщик, луп, play/pause/stop/seek, темп на лету. +- **Правка.** Выбор, перенос, удаление, дублирование, undo/redo, автосейв. +- **Материал.** Процедурные керны, импорт, запись; поиск швов по огибающей; повторный `fracture`. +- **Агент.** Реальная модель, 14 инструментов, трейс, проверка состояния, локальный фолбэк. +- **Экспорт.** Офлайн-рендер в WAV с погодой, макросами и мастером. + +## Что всё ещё макет + +- Внешний MCP-сервер не поднят; `mcp`-чип — заявка на следующий шаг. +- Клеймо и опись — журнал, не права и не согласие. Модель `draft → seal` описана, не реализована. +- `crystal` не редактирует ноты; нет MIDI, редактора маппинга граней, trim/loop-точек, сендов/групп, дублей записи. +- Импортированные и записанные керны живут только в памяти; после перезагрузки их камни молчат. Пять жил и панорама фиксированы. + +## Файлы + +- `index.html`, `style.css` — разметка и стиль +- `engine.js` — аудиоядро: керны, срезы, голоса, климат, транспорт, рендер +- `app.js` — nave: партитура, правка, история, анализ, кристалл, командный слой +- `quarry.js` — карьер: лоток, грани, погода, кольца, опись +- `agent.js` — хор в браузере: трейс, инструменты, локальный дирижёр +- `server.js`, `agent-tools.js`, `start.sh` — локальный сервер и модель +- `terrain.js` — рельеф и город +- `preview.png`, `preview-playing.png`, `preview-agent.png`, `preview-quarry.png`, `preview-mobile.png`, `preview-quarry-mobile.png` — скриншоты +- `preview.mp3` — 62 секунды базового рендера + +Сброс проекта: очистить site data (localStorage `zhila.project.v3`). diff --git a/reference/demo/agent-tools.js b/reference/demo/agent-tools.js new file mode 100644 index 0000000..15b083b --- /dev/null +++ b/reference/demo/agent-tools.js @@ -0,0 +1,227 @@ +/* ZHILA · agent — tools + the choir's brief. Shared by the local server. */ + +const SYSTEM = `You are the Choir, the agent embedded in ZHILA — a DAW where sound is terrain and samples are stones cut from cores. + +The instrument has two rooms. "nave" is the arrangement: five veins (bass, crack, voice, noise, silt) laid as a masonry of stones over a 32-bar loop. "quarry" is the material: cores are recordings, 16 faces are cut slices of a core, rings are loops that erode per cycle, weather shapes the whole mix. + +You act only through the tools. Never claim a change that a tool did not return. Never invent ids: call get_state or list_cores when unsure. + +A good layer is material, not wallpaper: +- a low pulse or bed on bass or voice, a mid texture on crack or noise, sparse accents on silt; +- rings repeat (ring_bars > 0); 0 places a one-shot; +- use faces that list_cores ranks as loud, and vary pitch by -12..+12 semitones; +- leave space: 1–3 new stones is usually enough; do not fill every bar; +- weather changes the whole piece: age darkens, pressure saturates, air opens the room, heat detunes; +- if the user asks to hear something, use strike_face; if they ask to see material, use open_core. + +Place stones with explicit bars and lengths. start_bar is 1..32, length_bars 1..16, ring_bars 0..8. Place in the vein whose role fits the sound. + +Do not narrate tool use and do not announce what you are about to do. Call the tools, then give exactly one short final report. Answer in the user's language, terse and concrete, one or two sentences about what you took or changed. When speaking Russian, keep the instrument's field names in English (core, face, vein, ring, stone, weather, seal) instead of translating them. No markdown lists, no headings, no emoji. Do not explain the interface unless asked. + +Before placing, look at the existing stones in get_state. Do not cover the exact same vein and bar range as an existing stone unless the user asked to double or layer it. If the requested range is taken, shift, shorten, or choose another vein and say so.`; + +const TOOLS = [ + { + type: 'function', + function: { + name: 'get_state', + description: 'Current project state: tempo, transport, weather, rings, cores, every stone with its id/vein/bars/ring/core/face, and the current selection.', + parameters: { type: 'object', properties: {}, additionalProperties: false } + } + }, + { + type: 'function', + function: { + name: 'list_cores', + description: 'List cores in the archive with duration, seal, and the indices of the loudest faces.', + parameters: { type: 'object', properties: {}, additionalProperties: false } + } + }, + { + type: 'function', + function: { + name: 'extract_core', + description: 'Take a new procedural core into the archive (the choir goes out for material). Returns the new core.', + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'A short name for the place or material.' }, + seal: { type: 'string', enum: ['found', 'taken', 'gift', 'loan', 'unknown'] } + }, + additionalProperties: false + } + } + }, + { + type: 'function', + function: { + name: 'fracture_core', + description: 'Re-cut a core into sixteen new faces. Every stone cut from this core changes its slice.', + parameters: { + type: 'object', + properties: { core_id: { type: 'string' } }, + required: ['core_id'], + additionalProperties: false + } + } + }, + { + type: 'function', + function: { + name: 'strike_face', + description: 'Audition one face through the weather. Use this to let the user hear material.', + parameters: { + type: 'object', + properties: { + core_id: { type: 'string' }, + face: { type: 'integer', minimum: 0, maximum: 15 }, + velocity: { type: 'number', minimum: 0, maximum: 1 } + }, + required: ['core_id', 'face'], + additionalProperties: false + } + } + }, + { + type: 'function', + function: { + name: 'place_stone', + description: 'Place a stone in the arrangement. A stone is a face of a core, cut into a vein over bars; ring_bars 0 plays once, ring_bars 2 repeats every two bars.', + parameters: { + type: 'object', + properties: { + vein: { type: 'string', enum: ['bass', 'crack', 'voice', 'noise', 'silt'] }, + core_id: { type: 'string' }, + face: { type: 'integer', minimum: 0, maximum: 15 }, + start_bar: { type: 'number', minimum: 1, maximum: 32 }, + length_bars: { type: 'number', minimum: 1, maximum: 16 }, + ring_bars: { type: 'number', minimum: 0, maximum: 8 }, + pitch: { type: 'number', minimum: -12, maximum: 12 }, + gain: { type: 'number', minimum: 0.1, maximum: 1.2 }, + name: { type: 'string' } + }, + required: ['vein', 'core_id', 'face', 'start_bar', 'length_bars'], + additionalProperties: false + } + } + }, + { + type: 'function', + function: { + name: 'move_stone', + description: 'Move an existing stone in time, keeping its length. Get ids from get_state.', + parameters: { + type: 'object', + properties: { + stone_id: { type: 'integer' }, + start_bar: { type: 'number', minimum: 1, maximum: 32 } + }, + required: ['stone_id', 'start_bar'], + additionalProperties: false + } + } + }, + { + type: 'function', + function: { + name: 'delete_stone', + description: 'Bury a stone (remove it from the arrangement).', + parameters: { + type: 'object', + properties: { stone_id: { type: 'integer' } }, + required: ['stone_id'], + additionalProperties: false + } + } + }, + { + type: 'function', + function: { + name: 'set_weather', + description: 'Shape the climate of the whole piece: age (dark/fossil), pressure (saturation), air (room), heat (detune). Omitted values stay unchanged.', + parameters: { + type: 'object', + properties: { + age: { type: 'number', minimum: 0, maximum: 1 }, + pressure: { type: 'number', minimum: 0, maximum: 1 }, + air: { type: 'number', minimum: 0, maximum: 1 }, + heat: { type: 'number', minimum: 0, maximum: 1 } + }, + additionalProperties: false + } + } + }, + { + type: 'function', + function: { + name: 'set_rings', + description: 'Set how many growth rings the loops carry; more rings mean stronger erosion on every cycle.', + parameters: { + type: 'object', + properties: { count: { type: 'integer', minimum: 0, maximum: 12 } }, + required: ['count'], + additionalProperties: false + } + } + }, + { + type: 'function', + function: { + name: 'set_tempo', + description: 'Set the tempo in BPM (40..220).', + parameters: { + type: 'object', + properties: { bpm: { type: 'number', minimum: 40, maximum: 220 } }, + required: ['bpm'], + additionalProperties: false + } + } + }, + { + type: 'function', + function: { + name: 'transport', + description: 'Play, pause, stop or seek the transport. seek needs a bar.', + parameters: { + type: 'object', + properties: { + action: { type: 'string', enum: ['play', 'pause', 'stop', 'seek'] }, + bar: { type: 'number', minimum: 1, maximum: 32 } + }, + required: ['action'], + additionalProperties: false + } + } + }, + { + type: 'function', + function: { + name: 'open_core', + description: 'Descend to the quarry and show a core in the tray.', + parameters: { + type: 'object', + properties: { core_id: { type: 'string' } }, + required: ['core_id'], + additionalProperties: false + } + } + }, + { + type: 'function', + function: { + name: 'set_macros', + description: 'Master macros: warmth (tilt), dust (hiss), threshold (limiter). 0..1.', + parameters: { + type: 'object', + properties: { + warmth: { type: 'number', minimum: 0, maximum: 1 }, + dust: { type: 'number', minimum: 0, maximum: 1 }, + threshold: { type: 'number', minimum: 0, maximum: 1 } + }, + additionalProperties: false + } + } + } +]; + +module.exports = { SYSTEM, TOOLS }; diff --git a/reference/demo/agent.js b/reference/demo/agent.js new file mode 100644 index 0000000..89ec1c3 --- /dev/null +++ b/reference/demo/agent.js @@ -0,0 +1,470 @@ +/* ZHILA · agent + the choir: a model on the local server, acting through the same verbs as the hand. + without a server or a key it falls back to a local conductor — still using the tools. */ + +(function () { + 'use strict'; + + const $ = (s, r = document) => r.querySelector(s); + const $$ = (s, r = document) => Array.prototype.slice.call(r.querySelectorAll(s)); + const clamp = (v, a, b) => v < a ? a : v > b ? b : v; + const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const API = location.protocol === 'file:' ? 'http://127.0.0.1:4173' : ''; + + const E = window.ZHILA_ENGINE; + const Z = window.ZHILA; + const Q = window.ZHILA_QUARRY; + + const logEl = $('#log'); + const stateEl = $('#agent-state'); + const stateText = $('#agent-state-text'); + const netText = $('#net-text'); + + const TOOL_NAMES = ['get_state', 'list_cores', 'extract_core', 'fracture_core', 'strike_face', + 'place_stone', 'move_stone', 'delete_stone', 'set_weather', 'set_rings', + 'set_tempo', 'transport', 'open_core', 'set_macros']; + + let live = false; + let busy = false; + let history = []; + let welcomed = false; + + function timeNow() { + const d = new Date(); + return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); + } + + function scrollLog() { + if (logEl) logEl.scrollTop = logEl.scrollHeight; + } + + function setStatus(text, thinking) { + if (!stateText) return; + stateText.textContent = text; + if (stateEl) stateEl.classList.toggle('is-thinking', !!thinking); + } + + function addSay(text) { + if (!logEl || !text) return; + const li = document.createElement('li'); + li.className = 'call call--say'; + li.innerHTML = '

'; + li.querySelector('.call-t').textContent = timeNow(); + li.querySelector('p').textContent = text; + logEl.appendChild(li); + scrollLog(); + } + + function addYou(text) { + if (!logEl) return; + const li = document.createElement('li'); + li.className = 'call call--you'; + li.innerHTML = '

'; + li.querySelector('.call-t').textContent = timeNow(); + li.querySelector('p').textContent = '\u201C' + text + '\u201D'; + logEl.appendChild(li); + scrollLog(); + } + + function argsHTML(args) { + const keys = Object.keys(args || {}); + if (!keys.length) return '{ }'; + return '{ ' + keys.map(function (k) { + let v = args[k]; + let val; + if (typeof v === 'string') val = '"' + esc(v) + '"'; + else if (typeof v === 'object' && v !== null) val = esc(JSON.stringify(v)); + else val = esc(String(v)); + return '"' + esc(k) + '": ' + val + ''; + }).join(', ') + ' }'; + } + + function argLine(name, a) { + const pad = n => String(n).padStart(2, '0'); + const pitch = n => n ? (n > 0 ? '+' : '') + n + 'st' : ''; + switch (name) { + case 'place_stone': + return [a.vein, a.core_id + ' f' + pad(a.face), a.start_bar + '–' + (+a.start_bar + +a.length_bars), + a.ring_bars ? 'ring ' + a.ring_bars : 'once', pitch(a.pitch), a.gain ? 'gain ' + a.gain : ''].filter(Boolean).join(' · '); + case 'strike_face': + return a.core_id + ' f' + pad(a.face); + case 'extract_core': + return a.name || 'new core'; + case 'fracture_core': + case 'open_core': + return a.core_id; + case 'move_stone': + return 'stone ' + a.stone_id + ' → bar ' + a.start_bar; + case 'delete_stone': + return 'stone ' + a.stone_id; + case 'set_weather': + return ['age', 'pressure', 'air', 'heat'].filter(function (k) { return a[k] != null; }) + .map(function (k) { return k + ' ' + a[k]; }).join(' · '); + case 'set_rings': + return a.count + ' rings'; + case 'set_tempo': + return a.bpm + ' bpm'; + case 'transport': + return a.action + (a.bar ? ' · bar ' + a.bar : ''); + case 'set_macros': + return ['warmth', 'dust', 'threshold'].filter(function (k) { return a[k] != null; }) + .map(function (k) { return k + ' ' + a[k]; }).join(' · '); + default: + return ''; + } + } + + function addTool(name, args, source) { + const li = document.createElement('li'); + li.className = 'call call--tool'; + li.innerHTML = + '
' + + '' + timeNow() + '' + + '' + + '' + (source || 'api') + '' + + '…' + + '
' + + '
' + esc(argLine(name, args)) + '
'; + li.querySelector('.call-name').textContent = name; + li.title = JSON.stringify(args); + logEl.appendChild(li); + scrollLog(); + return li; + } + + function summarize(name, res) { + if (!res) return ''; + if (res.error) return 'error: ' + res.error; + switch (name) { + case 'get_state': + return (res.stones ? res.stones.length : 0) + ' stones · bar ' + res.transport.bar + ' · ' + res.cores.length + ' cores · ' + res.view; + case 'list_cores': + return res.cores.length + ' cores: ' + res.cores.map(function (c) { return c.id; }).join(', '); + case 'extract_core': + return res.name + ' · ' + res.dur + 's · ' + res.seal; + case 'fracture_core': + return res.id + ' · re-cut · ' + res.loud.slice(0, 3).join(', '); + case 'strike_face': + return 'struck ' + res.core + ' f' + String(res.face + 1).padStart(2, '0'); + case 'place_stone': + return res.vein + ' · bars ' + res.bars[0] + '–' + res.bars[1] + (res.ring ? ' · ring ' + res.ring : ' · once'); + case 'move_stone': + return 'bars ' + res.bars[0] + '–' + res.bars[1]; + case 'delete_stone': + return 'buried'; + case 'set_weather': + return Object.keys(res.weather).map(function (k) { return k + ' ' + (+res.weather[k]).toFixed(2); }).join(' · '); + case 'set_rings': + return 'rings ' + res.rings; + case 'set_tempo': + return res.bpm + ' bpm'; + case 'transport': + return (res.playing ? 'playing' : 'paused') + ' · bar ' + res.bar; + case 'open_core': + return 'quarry · ' + res.core; + case 'set_macros': + return Object.keys(res).map(function (k) { return k + ' ' + (+res[k]).toFixed(2); }).join(' · '); + default: + return JSON.stringify(res).slice(0, 140); + } + } + + function finishTool(li, ms, res) { + if (!li) return; + const t = li.querySelector('.call-ms'); + if (t) t.textContent = ms; + const name = li.querySelector('.call-name').textContent; + const read = name === 'get_state' || name === 'list_cores'; + const line = document.createElement('div'); + line.className = 'call-result'; + line.textContent = (res && res.error ? 'error · ' : (read ? '' : 'ok · ')) + summarize(name, res); + if (res && res.error) line.style.color = 'rgba(232,64,42,.8)'; + li.appendChild(line); + scrollLog(); + } + + /* ————— the verbs ————— */ + + async function exec(name, a) { + a = a || {}; + if (!E) throw new Error('no engine'); + switch (name) { + case 'get_state': { + return { + transport: { playing: E.isPlaying(), bar: +E.getPos().toFixed(2), bpm: E.getBpm() }, + weather: Q ? Q.weather() : E.weather, + rings: Q ? Q.rings() : E.rings, + cores: E.cores.map(function (c) { + return { id: c.id, name: c.name, dur: +c.dur.toFixed(1), seal: c.seal, loud: c.ranked.slice(0, 4) }; + }), + stones: Z.stones(), + shape: Z.shape(), + view: document.body.dataset.view, + selected_core: Q && Q.selectedCore() ? Q.selectedCore().id : null + }; + } + case 'list_cores': { + return { + cores: E.cores.map(function (c) { + return { + id: c.id, name: c.name, dur: +c.dur.toFixed(1), seal: c.seal, faces: 16, + loud: c.ranked.slice(0, 5), + at: c.ranked.slice(0, 5).map(function (i) { return +c.faces[i].start.toFixed(2); }) + }; + }) + }; + } + case 'extract_core': { + const c = E.newCore({ name: (a.name || 'a core from the quarry').slice(0, 40), seal: a.seal || 'found' }); + E.addLog('cores.extract', c.name, c.seal, 'api'); + return { id: c.id, name: c.name, dur: +c.dur.toFixed(1), seal: c.seal, loud: c.ranked.slice(0, 4) }; + } + case 'fracture_core': { + const c = E.refracture(a.core_id); + if (!c) throw new Error('no such core: ' + a.core_id); + if (Q) Q.refresh(); + return { id: c.id, loud: c.ranked.slice(0, 4), faces: c.faces.length }; + } + case 'strike_face': { + const core = E.byId(a.core_id); + if (!core) throw new Error('no such core: ' + a.core_id); + const face = clamp(Math.round(+a.face || 0), 0, 15); + E.strike(core.id, face, { pitch: 0, gain: 0.55 + 0.4 * (a.velocity == null ? 1 : a.velocity), ghost: true }); + return { core: core.id, face: face, struck: true }; + } + case 'place_stone': { + const r = Z.addStoneAt({ + vein: a.vein, core: a.core_id, face: a.face, + start: a.start_bar, length: a.length_bars, ring: a.ring_bars, + pitch: a.pitch, gain: a.gain, name: a.name, actor: 'api' + }); + if (r.error) throw new Error(r.error); + return r; + } + case 'move_stone': { + const r = Z.moveStoneAt(a.stone_id, a.start_bar); + if (r.error) throw new Error(r.error); + return r; + } + case 'delete_stone': { + const r = Z.deleteStoneAt(a.stone_id); + if (r.error) throw new Error(r.error); + return r; + } + case 'set_weather': { + const w = Q ? Q.setWeatherUI({ age: a.age, pressure: a.pressure, air: a.air, heat: a.heat }) : E.weather; + return { weather: w }; + } + case 'set_rings': { + return { rings: Q ? Q.setRingsUI(a.count) : E.rings }; + } + case 'set_tempo': + return { bpm: Z.setTempo(a.bpm) }; + case 'transport': + return Z.transport(a.action, a.bar); + case 'open_core': { + if (Q) { Q.setView('quarry'); Q.focusCore(a.core_id); } + return { core: a.core_id, view: 'quarry' }; + } + case 'set_macros': { + const out = {}; + ['warmth', 'dust', 'threshold'].forEach(function (k) { + if (a[k] != null) out[k] = Z.setMacroUI(k, a[k]); + }); + return out; + } + default: + throw new Error('unknown tool: ' + name); + } + } + + async function invokeTool(name, args, source) { + const li = addTool(name, args, source); + const t0 = performance.now(); + let res; + try { res = await exec(name, args); } + catch (e) { res = { error: String(e && e.message || e) }; } + finishTool(li, ((performance.now() - t0) / 1000).toFixed(1) + 's', res); + return res; + } + + /* ————— the model loop ————— */ + + function trimHistory() { + const h = history.slice(); + while (h.length > 28) { + const i = h.findIndex(function (m) { return m.role === 'user'; }); + if (i < 0) break; + h.splice(0, i + 1); + } + return h; + } + + async function modelSay(text) { + history.push({ role: 'user', content: text }); + setStatus('thinking', true); + let ran = 0; + let lastError = null; + let wireDown = false; + for (let step = 0; step < 6; step++) { + let r, j; + try { + r = await fetch(API + '/api/agent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: trimHistory() }), + signal: AbortSignal.timeout(95000) + }); + j = await r.json().catch(function () { return null; }); + } catch (e) { + lastError = e; + wireDown = true; + break; + } + if (!r.ok) { + lastError = new Error((j && j.error) || ('agent ' + r.status)); + if (j && j.mode === 'local') { wireDown = true; live = false; } + break; + } + const m = j && j.message; + if (!m) { lastError = new Error('empty reply'); break; } + history.push(m); + if (m.content && m.content.trim()) addSay(m.content.trim()); + if (!m.tool_calls || !m.tool_calls.length) { lastError = null; break; } + for (let k = 0; k < m.tool_calls.length; k++) { + const tc = m.tool_calls[k]; + const name = tc.function && tc.function.name; + let args = {}; + try { args = JSON.parse((tc.function && tc.function.arguments) || '{}'); } catch (e) {} + setStatus('calling ' + name, true); + const res = await invokeTool(name, args, 'api'); + ran++; + history.push({ role: 'tool', tool_call_id: tc.id, name: name, content: JSON.stringify(res) }); + } + } + if (lastError) { + if (wireDown) { + live = false; + document.body.classList.add('agent-offline'); + if (netText) netText.textContent = 'local'; + } + addSay('the choir lost the wire: ' + lastError.message + '.'); + if (!ran) localSay(text, true); + } + } + + /* ————— local conductor (no model) ————— */ + + async function localSay(text, afterError) { + const t = text.toLowerCase(); + const core = (Q && Q.selectedCore()) || E.cores[0]; + if (!core) { + addSay('there is no material in the archive yet.'); + return; + } + const done = []; + const wantsStop = /(stop|стоп|пауза|pause)/.test(t); + const wantsPlay = /(play|играй|пуск|запусти|start)/.test(t); + if (wantsStop) { await invokeTool('transport', { action: 'pause' }, 'local'); done.push('paused'); } + else if (wantsPlay) { await invokeTool('transport', { action: 'play' }, 'local'); done.push('the wheels are turning'); } + + if (/(fracture|cut|реж|трещин|разрез)/.test(t)) { + await invokeTool('fracture_core', { core_id: core.id }, 'local'); + done.push('re-cut ' + core.id); + } else if (/(take|bring|extract|найд|принес|достань|новый|room|поле|запись)/.test(t)) { + const c = await invokeTool('extract_core', { name: text.slice(0, 42), seal: 'found' }, 'local'); + done.push('the choir brought ' + c.name); + } else if (/(dark|темн|стар|old|fossil|истощ)/.test(t)) { + const w = await invokeTool('set_weather', { age: 0.72, air: 0.3 }, 'local'); + done.push('weather: age 0.72, air 0.30'); + } else if (/(wet|мокр|room|воздух|air|простор)/.test(t)) { + await invokeTool('set_weather', { air: 0.74, pressure: 0.3 }, 'local'); + done.push('weather: air 0.74, pressure 0.30'); + } + + const wantsPlace = /(place|add|layer|слой|добавь|постав|loop|петл|пульс|bass|низ|голос|шум|звук|дыш)/.test(t); + if (wantsPlace || !done.length) { + let vein = 'silt'; + if (/(bass|низ|бас|пол)/.test(t)) vein = 'bass'; + else if (/(voice|голос|хор|петь)/.test(t)) vein = 'voice'; + else if (/(noise|шум|воздух|ветер)/.test(t)) vein = 'noise'; + else if (/(crack|трещ|грит)/.test(t)) vein = 'crack'; + const face = E.rankFace(core.id, vein === 'bass' ? 0 : 1); + const start = Math.min(30, Math.ceil(E.getPos()) + 1); + const ring = /(once|один|раз|one)/.test(t) ? 0 : 2; + const pitch = vein === 'bass' ? -5 : 0; + const res = await invokeTool('place_stone', { + vein: vein, core_id: core.id, face: face, + start_bar: start, length_bars: 8, ring_bars: ring, pitch: pitch, gain: 0.8 + }, 'local'); + if (!res.error) done.push('laid ' + res.name + ' in ' + res.vein + ', bars ' + res.bars[0] + '–' + res.bars[1]); + } + + addSay((afterError ? 'the wire is down, so i worked by hand. ' : '') + (done.length ? done.join('. ') + '.' : 'i listened, but found nothing to change.')); + } + + async function say(text) { + text = String(text || '').trim(); + if (!text) return; + if (busy) { + addSay('one at a time — the choir is still working.'); + return; + } + busy = true; + addYou(text); + try { + if (live) { + await modelSay(text); + } else { + setStatus('local', false); + await localSay(text, false); + } + } catch (e) { + addSay('the choir failed: ' + (e && e.message || e)); + } finally { + busy = false; + setStatus(live ? 'listening' : 'local', false); + } + } + + /* ————— boot ————— */ + + async function init() { + const meta = $('.state-meta'); + if (meta) meta.textContent = TOOL_NAMES.length + ' tools'; + try { + const r = await fetch(API + '/api/agent', { signal: AbortSignal.timeout(3000) }); + const j = await r.json(); + live = !!j.live; + } catch (e) { + live = false; + } + document.body.classList.toggle('agent-offline', !live); + if (netText) netText.textContent = live ? 'live' : 'local'; + const chip = $('.chip'); + if (chip) { + chip.classList.toggle('is-live', live); + chip.title = live ? 'Embedded agent: live model on the local server' : 'Embedded agent: local conductor (no model on the wire)'; + } + setStatus(live ? 'listening' : 'local', false); + if (!welcomed) { + welcomed = true; + addSay(live + ? 'the choir is listening. say what should be heard — i can take cores, cut faces, lay stones, or change the weather.' + : 'the choir is local: no model on the wire. i can still take, cut, and lay by rule.'); + } + } + + window.ZHILA_AGENT = { + say: say, + isLive: function () { return live; }, + isBusy: function () { return busy; }, + tools: TOOL_NAMES.length + }; + + if (document.readyState === 'complete' || document.readyState === 'interactive') { + window.setTimeout(init, 250); + } else { + document.addEventListener('DOMContentLoaded', function () { window.setTimeout(init, 250); }); + } +})(); diff --git a/reference/demo/app.js b/reference/demo/app.js new file mode 100644 index 0000000..d6585e2 --- /dev/null +++ b/reference/demo/app.js @@ -0,0 +1,1764 @@ +/* ZHILA · the machine + the choir above, hands below. the surface listens. */ + +(function () { + 'use strict'; + + const $ = (s, r = document) => r.querySelector(s); + const $$ = (s, r = document) => Array.prototype.slice.call(r.querySelectorAll(s)); + const clamp = (v, a, b) => v < a ? a : v > b ? b : v; + const TAU = Math.PI * 2; + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const frac = v => v - Math.floor(v); + const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + + const state = { + pos: 7.5, + playing: false, + bpm: 128, + mode: 'choir', + sound: true, + rec: false, + generating: false, + input: 'drone', + calls: 0, + selClip: null + }; + + /* ————— the material ————— */ + + const LANES = [ + { + name: 'bass', gain: 0.72, clips: [ + { a: 1, b: 9, name: 'rubble', seal: 'taken', core: 'stairwell-03', faceRank: 0, ring: 1, pitch: -2, gain: 0.9 }, + { a: 9, b: 17, name: 'footing', seal: 'taken', core: 'stairwell-03', faceRank: 0, ring: 1, pitch: -2, gain: 0.85 }, + { a: 17, b: 25, name: 'stone', seal: 'found', core: 'stairwell-03', faceRank: 1, ring: 1, pitch: -5, gain: 0.9 }, + { a: 25, b: 32, name: 'keystone', seal: 'found', core: 'stairwell-03', faceRank: 1, ring: 1, pitch: -5, gain: 0.85 } + ] + }, + { + name: 'crack', gain: 0.54, clips: [ + { a: 1, b: 9, name: 'seam', seal: 'taken', core: 'rain-metal', faceRank: 0, ring: 0.5, pitch: 7, gain: 0.6 }, + { a: 9, b: 17, name: 'grit', seal: 'taken', core: 'rain-metal', faceRank: 0, ring: 0.5, pitch: 7, gain: 0.5 }, + { a: 17, b: 25, name: 'splinter', seal: 'found', core: 'rain-metal', faceRank: 4, ring: 0.5, pitch: 12, gain: 0.55 }, + { a: 25, b: 32, name: 'collapse', seal: 'unknown', core: 'rain-metal', faceRank: 4, ring: 0.5, pitch: 12, gain: 0.6 } + ] + }, + { + name: 'voice', gain: 0.66, clips: [ + { a: 1, b: 17, name: 'choir 06', seal: 'gift', core: 'room-04', faceRank: 0, ring: 1, pitch: -12, gain: 0.55 }, + { a: 17, b: 32, name: 'response', seal: 'gift', core: 'room-04', faceRank: 0, ring: 1, pitch: -12, gain: 0.6 } + ] + }, + { + name: 'noise', gain: 0.43, clips: [ + { a: 1, b: 17, name: 'breath', seal: 'found', core: 'rain-metal', faceRank: 6, ring: 4, pitch: 5, gain: 0.5 }, + { a: 17, b: 32, name: 'wind', seal: 'found', core: 'rain-metal', faceRank: 6, ring: 4, pitch: 5, gain: 0.55 } + ] + }, + { + name: 'silt', gain: 0.37, clips: [ + { a: 9, b: 12, name: 'dust', seal: 'loan', core: 'rail-02', faceRank: 0, ring: 1, pitch: -3, gain: 0.8 }, + { a: 19, b: 28, name: 'echo', seal: 'unknown', core: 'rail-02', faceRank: 3, ring: 4, pitch: -7, gain: 0.7 } + ] + } + ]; + + const SECTIONS = [ + { at: 1, name: 'entry' }, + { at: 9, name: 'descent' }, + { at: 17, name: 'choir' }, + { at: 25, name: 'collapse' } + ]; + + const NEW_NAMES = ['silt', 'ridge', 'hollow', 'ember', 'fall', 'shard']; + const NOTE_NAMES = ['A', 'G', 'F', 'E', 'D', 'C', 'B', 'A']; + + let clipSeq = 0; + let lastSection = ''; + + /* ————— refs ————— */ + + const prompt = $('#prompt'); + const invocation = $('#invocation'); + const voidBtn = $('#void'); + const voidWord = $('#void-word'); + const logEl = $('#log'); + const stateEl = $('#agent-state'); + const stateText = $('#agent-state-text'); + const netText = $('#net-text'); + const stage = $('#stage'); + const svg = $('#nave'); + const latticeEl = $('#lattice'); + const fadersEl = $('#faders'); + const halo = $('#halo'); + const hctx = halo.getContext('2d'); + const breath = $('#breath'); + const bctx = breath.getContext('2d'); + const fileIn = $('#file-in'); + const scope = $('#scope'); + const sctx = scope.getContext('2d'); + + let NAVE = null; + let phLine = null, phGlow = null, phMark = null; + let haloW = 0, haloH = 0, voidR = 0; + let eyeCX = 0, eyeCY = 0; + let breathW = 0, breathH = 0; + const BS = 0.6; + let peak = 0.2; + let lastClock = ''; + let lastBarKey = -1; + let lastLv = -1; + let scopeW = 0, scopeH = 0; + let hotLane = -1; + + /* ————— clock ————— */ + + function clockTime() { + const d = new Date(); + return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); + } + + /* ————— agent console (api + mcp) ————— */ + + function scrollLog() { + logEl.scrollTop = logEl.scrollHeight; + } + + function setAgentState(text, thinking) { + stateText.textContent = text; + stateEl.classList.toggle('is-thinking', !!thinking); + } + + function argsHTML(pairs) { + return '{ ' + pairs.map(function (pair) { + const val = pair[2] ? '' + pair[1] + '' + : '"' + pair[1] + '"'; + return '"' + pair[0] + '": ' + val; + }).join(', ') + ' }'; + } + + function addYou(text) { + const li = document.createElement('li'); + li.className = 'call call--you'; + li.innerHTML = '

'; + li.querySelector('.call-t').textContent = clockTime(); + li.querySelector('p').textContent = '\u201C' + text + '\u201D'; + logEl.appendChild(li); + scrollLog(); + } + + function addCall(opts) { + const li = document.createElement('li'); + li.className = 'call call--tool' + (opts.src === 'mcp' ? ' call--mcp' : ''); + li.innerHTML = + '
' + + '' + clockTime() + '' + + '' + + '' + opts.src + '' + + '' + opts.ms + '' + + '' + + '
' + + '
';
+    li.querySelector('.call-name').textContent = opts.tool;
+    li.querySelector('.call-args').innerHTML = argsHTML(opts.args);
+    const btn = li.querySelector('.call-undo');
+    if (opts.undo) {
+      btn.addEventListener('click', function () {
+        if (btn.dataset.done) return;
+        btn.dataset.done = '1';
+        btn.textContent = 'undone';
+        btn.style.borderBottomColor = 'transparent';
+        opts.undo();
+      });
+    } else {
+      btn.remove();
+    }
+    logEl.appendChild(li);
+    scrollLog();
+  }
+
+  function chooseVerb(q) {
+    const s = q.toLowerCase();
+    if (/\b(remove|only|leave|strip|mute|kill|clear|erase)\b/.test(s)) return 'remove';
+    if (/\b(level|loud|quiet|gain|db|balance|raise|lower)\b/.test(s)) return 'level';
+    return 'create';
+  }
+
+  function askAgent(text) {
+    if (window.ZHILA_AGENT) window.ZHILA_AGENT.say(text);
+    else invoke(text);
+  }
+
+  function invoke(text) {
+    if (state.generating) return;
+    const q = (typeof text === 'string' ? text : prompt.value).trim();
+    if (!q) { prompt.focus(); return; }
+    if (state.mode === 'hand') setMode('choir');
+
+    addYou(q);
+    prompt.value = '';
+    state.generating = true;
+    setAgentState('thinking', true);
+    voidBtn.classList.add('is-pulsed');
+
+    const verb = chooseVerb(q);
+    const t0 = performance.now();
+    const wait = 780 + Math.random() * 720;
+
+    window.setTimeout(function () {
+      const ms = ((performance.now() - t0) / 1000).toFixed(1) + 's';
+      state.generating = false;
+      voidBtn.classList.remove('is-pulsed');
+      setAgentState(state.mode === 'hand' ? 'muted' : 'listening', false);
+
+      if (verb === 'level') {
+        const li = state.calls % LANES.length;
+        const lane = LANES[li];
+        const before = lane.gain;
+        const db = Math.round((-3 - Math.random() * 9) * 10) / 10;
+        lane.gain = clamp(Math.pow(10, db / 20), 0.04, 1);
+        paintChannel(li);
+        addCall({
+          tool: 'level.set', src: 'api', ms: ms,
+          args: [['vein', lane.name], ['db', db, true]],
+          undo: function () { lane.gain = before; paintChannel(li); }
+        });
+      } else if (verb === 'remove') {
+        pushUndo();
+        let lane = LANES[0];
+        LANES.forEach(function (l) { if (l.clips.length > lane.clips.length) lane = l; });
+        const clip = lane.clips.pop();
+        if (clip) {
+          addCall({
+            tool: 'layers.remove', src: 'api', ms: ms,
+            args: [['vein', lane.name], ['bars', '[' + clip.a + ', ' + clip.b + ']', true]],
+            undo: function () { lane.clips.push(clip); buildNave(); commitSong(); scheduleSave(); }
+          });
+        }
+        buildNave();
+        commitSong();
+        scheduleSave();
+        if (E) E.addLog('layers.remove', lane.name + ', bar ' + (clip ? clip.a : '—'), '', 'api');
+      } else {
+        const pool = E ? E.cores : [];
+        const core = pool.length ? pool[state.calls % pool.length] : null;
+        const face = core && E ? E.rankFace(core.id, 0) : 0;
+        const density = shapeValue('s-density');
+        const depth = shapeValue('s-depth');
+        const grain = shapeValue('s-grain');
+        const ring = density > 0.66 ? 1 : density > 0.33 ? 2 : 4;
+        const pitch = -Math.round(depth * 12);
+        const name = NEW_NAMES[state.calls % NEW_NAMES.length];
+        const res = addStoneClip(name, 'taken', {
+          core: core ? core.id : undefined, face: face, ring: ring, pitch: pitch,
+          gain: 0.55 + grain * 0.45, actor: 'api'
+        });
+        addCall({
+          tool: 'layers.create', src: 'api', ms: ms,
+          args: [['core', core ? core.id : '—'], ['face', face, true], ['ring', ring, true], ['bars', '[' + res.start + ', ' + res.end + ']', true]],
+          undo: function () {
+            const f = findClip(res.clip.id);
+            if (f) {
+              f.lane.clips.splice(f.i, 1);
+              buildNave();
+              commitSong();
+              scheduleSave();
+            }
+          }
+        });
+      }
+      state.calls++;
+    }, wait);
+  }
+
+  invocation.addEventListener('submit', function (e) {
+    e.preventDefault();
+    const q = prompt.value.trim();
+    if (!q) { prompt.focus(); return; }
+    prompt.value = '';
+    askAgent(q);
+  });
+
+  invocation.addEventListener('pointerdown', function (e) {
+    if (state.mode === 'hand') {
+      setMode('choir');
+      prompt.focus();
+      if (e.target !== prompt) e.preventDefault();
+    }
+  });
+
+  voidBtn.addEventListener('click', function () {
+    if (prompt.value.trim()) { const q = prompt.value.trim(); prompt.value = ''; askAgent(q); }
+    else prompt.focus();
+    voidBtn.classList.remove('is-pulsed');
+    void requestAnimationFrame(function () { voidBtn.classList.add('is-pulsed'); });
+  });
+
+  /* ————— mode ————— */
+
+  function setMode(mode) {
+    state.mode = mode;
+    document.body.dataset.mode = mode;
+    $$('.mode-btn').forEach(function (b) {
+      const on = b.dataset.modeBtn === mode;
+      b.classList.toggle('is-on', on);
+      b.setAttribute('aria-pressed', String(on));
+    });
+    netText.textContent = mode === 'choir' ? ((window.ZHILA_AGENT && window.ZHILA_AGENT.isLive()) ? 'live' : 'local') : 'idle';
+    setAgentState(mode === 'choir' ? (state.generating ? 'thinking' : 'listening') : 'muted', state.generating);
+    prompt.disabled = mode === 'hand';
+    if (mode === 'choir') {
+      if (prompt.offsetParent !== null) prompt.focus({ preventScroll: true });
+    } else {
+      prompt.blur();
+    }
+  }
+
+  $$('.mode-btn').forEach(function (b) {
+    b.addEventListener('click', function () { setMode(b.dataset.modeBtn); });
+  });
+
+  /* ————— the nave (arrangement) ————— */
+
+  function mulberry(seed) {
+    let a = seed | 0;
+    return function () {
+      a = a + 0x6D2B79F5 | 0;
+      let t = Math.imul(a ^ a >>> 15, 1 | a);
+      t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
+      return ((t ^ t >>> 14) >>> 0) / 4294967296;
+    };
+  }
+
+  function clipSeal(x, y, kind) {
+    let inner = '';
+    if (kind === 'taken') {
+      inner = '';
+    } else if (kind === 'found') {
+      inner = '';
+    } else if (kind === 'loan') {
+      inner = '';
+    } else if (kind === 'gift') {
+      inner = '';
+    }
+    return '' + inner;
+  }
+
+  function stoneOutline(corners, seed) {
+    const rnd = mulberry(seed);
+    const pts = [];
+    for (let e = 0; e < 4; e++) {
+      const a = corners[e], b = corners[(e + 1) % 4];
+      pts.push([a[0] + (rnd() - 0.5) * 2.4, a[1] + (rnd() - 0.5) * 2.4]);
+      pts.push([
+        (a[0] + b[0]) / 2 + (rnd() - 0.5) * 7,
+        (a[1] + b[1]) / 2 + (rnd() - 0.5) * 7
+      ]);
+    }
+    return pts;
+  }
+
+  function ptsStr(pts) {
+    return pts.map(function (p) { return p[0].toFixed(1) + ',' + p[1].toFixed(1); }).join(' ');
+  }
+
+  function addStoneClip(name, seal, opts) {
+    opts = opts || {};
+    const actor = opts.actor || 'hand';
+    pushUndo();
+    let lane = LANES[0];
+    LANES.forEach(function (l) { if (l.clips.length < lane.clips.length) lane = l; });
+    const start = clamp(Math.ceil(state.pos) + 1, 1, 28);
+    let core = opts.core, face = opts.face, ring = opts.ring || 0;
+    if (!core && E) {
+      const pick = E.cores[Math.floor(Math.random() * E.cores.length)];
+      if (pick) { core = pick.id; face = E.rankFace(pick.id, 0); ring = 0; }
+    }
+    const span = ring ? 8 : 4;
+    const clip = {
+      a: start, b: Math.min(32, start + span),
+      name: name || 'stone', seal: seal || 'taken', id: ++clipSeq,
+      core: core, face: face, ring: ring, pitch: opts.pitch || 0,
+      gain: opts.gain == null ? 0.85 : opts.gain
+    };
+    lane.clips.push(clip);
+    buildNave(clip.id);
+    commitSong();
+    scheduleSave();
+    if (E) E.addLog('nave.place', (name || 'stone') + ' → ' + lane.name + ', bar ' + start, clip.seal, actor);
+    return { clip: clip, lane: lane.name, start: start, end: clip.b };
+  }
+
+  function buildNave(newClipId) {
+    if (!stage) return;
+    const W = stage.clientWidth;
+    const H = stage.clientHeight;
+    if (!W || !H) return;
+
+    const horizon = H * 0.28;
+    const band = W < 700 ? 18 : 22;
+    const bottom = H - band;
+    const vpX = W * 0.5;
+    const halfW = Math.min(W * 0.4, 460);
+    const sOfBar = function (b) { return 1 - Math.pow(clamp((b - 1) / 31, 0, 1), 1.16); };
+    const yAt = function (s) { return horizon + (bottom - horizon) * s; };
+    const qAt = function (s) { return 0.3 + 0.7 * Math.pow(s, 1.08); };
+    const xAt = function (c, s) { return vpX + c * halfW * qAt(s); };
+
+    NAVE = { W: W, H: H, horizon: horizon, bottom: bottom, vpX: vpX, halfW: halfW, sOfBar: sOfBar, yAt: yAt, xAt: xAt };
+    svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
+    svg.setAttribute('width', W);
+    svg.setAttribute('height', H);
+
+    const edges = [-1, -0.6, -0.2, 0.2, 0.6, 1];
+    const centers = [-0.8, -0.4, 0, 0.4, 0.8];
+    const cw = 0.172;
+    let out = '';
+
+    /* the road: a lit ground, then five veins */
+    const roadPoly = [
+      xAt(-1, 0).toFixed(1) + ',' + yAt(0).toFixed(1),
+      xAt(1, 0).toFixed(1) + ',' + yAt(0).toFixed(1),
+      xAt(1, 1).toFixed(1) + ',' + yAt(1).toFixed(1),
+      xAt(-1, 1).toFixed(1) + ',' + yAt(1).toFixed(1)
+    ].join(' ');
+    out += '';
+    out += '';
+    out += '';
+    out += '';
+    out += '';
+    out += '';
+    out += '';
+    out += '';
+    out += '';
+    out += '';
+    out += '';
+    out += '';
+    out += '';
+
+    /* lanes: hit areas and the hover glow */
+    LANES.forEach(function (lane, i) {
+      const c0 = edges[i], c1 = edges[i + 1];
+      const poly = [
+        xAt(c0, 0).toFixed(1) + ',' + yAt(0).toFixed(1),
+        xAt(c1, 0).toFixed(1) + ',' + yAt(0).toFixed(1),
+        xAt(c1, 1).toFixed(1) + ',' + yAt(1).toFixed(1),
+        xAt(c0, 1).toFixed(1) + ',' + yAt(1).toFixed(1)
+      ].join(' ');
+      const mid = [];
+      for (let k = 0; k <= 8; k++) {
+        const s = k / 8;
+        mid.push(xAt((c0 + c1) / 2, s).toFixed(1) + ',' + yAt(s).toFixed(1));
+      }
+      out += '';
+      out += '';
+      out += '';
+      out += '';
+    });
+
+    /* bar lines: time, compressed by the perspective */
+    for (let b = 2; b <= 32; b++) {
+      const s = sOfBar(b);
+      const y = yAt(s);
+      const op = (0.07 + 0.17 * Math.pow(s, 2.2)).toFixed(3);
+      out += '';
+    }
+
+    /* lane edges */
+    edges.forEach(function (c, i) {
+      const pts = [];
+      for (let k = 0; k <= 12; k++) {
+        const s = k / 12;
+        pts.push(xAt(c, s).toFixed(1) + ',' + yAt(s).toFixed(1));
+      }
+      out += '';
+    });
+
+    /* the gate: where the road meets the city */
+    out += '';
+
+    /* clips */
+    LANES.forEach(function (lane, i) {
+      const c = centers[i];
+      lane.clips.forEach(function (clip, ci) {
+        const s0 = sOfBar(clip.a);
+        const s1 = sOfBar(clip.b);
+        const hpx = yAt(s0) - yAt(s1);
+        const wpx = xAt(c + cw, s0) - xAt(c - cw, s0);
+        if (hpx < 2 || wpx < 3) return;
+        const BL = [xAt(c - cw, s0), yAt(s0)];
+        const TL = [xAt(c - cw, s1), yAt(s1)];
+        const TR = [xAt(c + cw, s1), yAt(s1)];
+        const BR = [xAt(c + cw, s0), yAt(s0)];
+        const bil = function (u, v) {
+          const bx = BL[0] + (BR[0] - BL[0]) * u;
+          const by = BL[1] + (BR[1] - BL[1]) * u;
+          const tx = TL[0] + (TR[0] - TL[0]) * u;
+          const ty = TL[1] + (TR[1] - TL[1]) * u;
+          return [bx + (tx - bx) * v, by + (ty - by) * v];
+        };
+        const isNew = clip.id && clip.id === newClipId;
+        const isNow = state.pos >= clip.a && state.pos < clip.b;
+        out += '';
+        const info = clip.core ? esc(clip.name) + ' · ' + clip.core + ' · face ' + String((clip.face || 0) + 1).padStart(2, '0') + (clip.ring ? ' · loop ' + clip.ring + ' bar' : '') : esc(clip.name);
+        out += '' + esc(info) + '';
+        const stone = stoneOutline([BL, TL, TR, BR], (i + 3) * 71 + clip.a * 13 + ci * 7);
+        const cx0 = stone.reduce(function (a, p) { return a + p[0]; }, 0) / stone.length;
+        const cy0 = stone.reduce(function (a, p) { return a + p[1]; }, 0) / stone.length;
+        const face = stone.map(function (p) { return [p[0] + (cx0 - p[0]) * 0.1, p[1] + (cy0 - p[1]) * 0.1]; });
+        out += '';
+        out += '';
+        const sp = bil(0.07, 0.78);
+        out += clipSeal(sp[0], sp[1], clip.seal || 'taken');
+        if (hpx > 14) {
+          for (let k = 1; k <= 2; k++) {
+            const v = k / 3;
+            const pts = [];
+            for (let j = 0; j <= 12; j++) {
+              const u = j / 12;
+              const p = bil(u * 0.98 + 0.01, clamp(v + 0.03 * Math.sin(u * 8.5 + k * 2 + ci), 0.05, 0.95));
+              pts.push(p[0].toFixed(1) + ',' + p[1].toFixed(1));
+            }
+            out += '';
+          }
+        }
+        if (hpx > 16 && wpx > 26) {
+          const lp = bil(0.04, 0.2);
+          out += '' + clip.name + '';
+          if (hpx > 54 && wpx > 90) {
+            const rp = bil(0.96, 0.2);
+            out += '' + clip.a + '–' + clip.b + '';
+          }
+          if (clip.ring && hpx > 24) {
+            const lp2 = bil(0.9, 0.8);
+            out += '';
+          }
+        }
+        out += '';
+      });
+    });
+
+    /* lane names on the near edge, like a desk in front of the road */
+    LANES.forEach(function (lane, i) {
+      const x = xAt(centers[i], 1);
+      out += '' + lane.name + '';
+    });
+
+    /* the near edge: now */
+    out += '';
+    out += '';
+
+    /* ruler */
+    if (W > 560) {
+      [1, 8, 16, 24, 32].forEach(function (b) {
+        const s = sOfBar(b);
+        const y = yAt(s);
+        const xe = xAt(1, s) + 19;
+        out += '';
+        out += '' + b + '';
+      });
+    }
+
+    /* playhead */
+    out += '';
+    out += '';
+    out += '';
+    out += '';
+    out += '';
+
+    svg.innerHTML = out;
+    markSelected();
+    phLine = svg.querySelector('.playhead-line');
+    phGlow = svg.querySelector('.playhead-glow');
+    phMark = svg.querySelector('.playhead-mark');
+    updatePlayhead(true);
+  }
+
+  function updatePlayhead(force) {
+    if (!NAVE || !phLine) return;
+    const s = NAVE.sOfBar(state.pos);
+    const y = NAVE.yAt(s);
+    const x1 = NAVE.xAt(-1, s);
+    const x2 = NAVE.xAt(1, s);
+    [phLine, phGlow].forEach(function (l) {
+      l.setAttribute('x1', x1.toFixed(1));
+      l.setAttribute('x2', x2.toFixed(1));
+      l.setAttribute('y1', y.toFixed(1));
+      l.setAttribute('y2', y.toFixed(1));
+    });
+    phMark.setAttribute('cx', x2.toFixed(1));
+    phMark.setAttribute('cy', y.toFixed(1));
+
+    const key = Math.floor(state.pos * 2);
+    if (!force && key === lastBarKey) return;
+    lastBarKey = key;
+
+    const bar = Math.floor(state.pos);
+    const beat = Math.floor((state.pos - bar) * 4) + 1;
+    const read = $('#corridor-read');
+    if (read && hotLane < 0 && !state.selClip) read.textContent = 'bar ' + bar + '.' + beat;
+
+    $$('.clip', svg).forEach(function (g) {
+      const a = +g.dataset.a, b = +g.dataset.b;
+      g.classList.toggle('is-now', state.pos >= a && state.pos < b);
+    });
+
+    const sec = SECTIONS.filter(function (x) { return state.pos >= x.at; }).pop();
+    if (sec && sec.name !== lastSection) {
+      lastSection = sec.name;
+      voidWord.textContent = sec.name;
+    }
+  }
+
+  function seekFromEvent(e) {
+    if (!NAVE) return;
+    const r = svg.getBoundingClientRect();
+    const y = clamp(e.clientY - r.top, NAVE.horizon, NAVE.bottom);
+    const s = (y - NAVE.horizon) / (NAVE.bottom - NAVE.horizon);
+    const t = Math.pow(1 - s, 1 / 1.16);
+    state.pos = clamp(1 + t * 31, 1, 32.99);
+    if (E) E.seek(state.pos);
+    updatePlayhead(true);
+    updateClock(true);
+  }
+  let drag = null;
+  let redrawQueued = false;
+
+  function scheduleRedraw() {
+    if (redrawQueued) return;
+    redrawQueued = true;
+    requestAnimationFrame(function () {
+      redrawQueued = false;
+      buildNave();
+    });
+  }
+
+  svg.addEventListener('pointerdown', function (e) {
+    if (e.target.closest('.ruler-text, .ruler-tick')) return;
+    const g = e.target.closest ? e.target.closest('.clip') : null;
+    if (g && g.dataset.id) {
+      const id = +g.dataset.id;
+      const f = findClip(id);
+      if (!f) return;
+      selectClip(id);
+      const r = svg.getBoundingClientRect();
+      drag = {
+        id: id, y0: e.clientY, bar0: barFromY(e.clientY - r.top),
+        a0: f.clip.a, b0: f.clip.b, moved: false, undid: false
+      };
+      try { svg.setPointerCapture(e.pointerId); } catch (err) {}
+      e.preventDefault();
+      e.stopPropagation();
+      return;
+    }
+    state.selClip = null;
+    markSelected();
+    seekFromEvent(e);
+  });
+
+  svg.addEventListener('pointermove', function (e) {
+    if (!drag) return;
+    const r = svg.getBoundingClientRect();
+    if (!drag.moved && Math.abs(e.clientY - drag.y0) > 3) {
+      drag.moved = true;
+      pushUndo();
+      drag.undid = true;
+    }
+    if (!drag.moved) return;
+    const bar = barFromY(e.clientY - r.top);
+    const delta = Math.round((bar - drag.bar0) * 4) / 4;
+    const f = findClip(drag.id);
+    if (!f) return;
+    const len = drag.b0 - drag.a0;
+    const a = clamp(drag.a0 + delta, 1, 32 - len);
+    f.clip.a = +a.toFixed(2);
+    f.clip.b = +(a + len).toFixed(2);
+    scheduleRedraw();
+  });
+
+  function endDrag() {
+    if (!drag) return;
+    const moved = drag.moved;
+    const id = drag.id;
+    drag = null;
+    if (moved) {
+      const f = findClip(id);
+      commitSong();
+      scheduleSave();
+      updatePlayhead(true);
+      if (E && f) E.addLog('stones.move', f.clip.name + ' → bar ' + f.clip.a.toFixed(2), f.clip.seal || '', 'hand');
+    }
+  }
+  svg.addEventListener('pointerup', endDrag);
+  svg.addEventListener('pointercancel', endDrag);
+  svg.addEventListener('pointerleave', function () { if (drag) endDrag(); });
+
+  function setHot(i) {
+    if (i === hotLane) return;
+    hotLane = i;
+    $$('.lane', svg).forEach(function (el) { el.classList.toggle('is-hot', +el.dataset.lane === i); });
+    $$('.lane-name', svg).forEach(function (el) { el.classList.toggle('is-hot', +el.dataset.lane === i); });
+    $$('.ch', fadersEl).forEach(function (el) { el.classList.toggle('is-hot', +el.dataset.lane === i); });
+    const read = $('#corridor-read');
+    if (!read) return;
+    if (i >= 0) read.textContent = LANES[i].name;
+    else {
+      const bar = Math.floor(state.pos);
+      const beat = Math.floor((state.pos - bar) * 4) + 1;
+      read.textContent = 'bar ' + bar + '.' + beat;
+    }
+  }
+  svg.addEventListener('pointermove', function (e) {
+    const el = e.target.closest ? e.target.closest('.clip, .lane') : null;
+    setHot(el ? +el.dataset.lane : -1);
+  });
+  svg.addEventListener('pointerleave', function () { setHot(-1); });
+
+  /* ————— faders ————— */
+
+  const channelEls = [];
+
+  function dbText(g) {
+    if (g <= 0.03) return '−∞';
+    return (20 * Math.log10(g)).toFixed(1).replace('-', '−');
+  }
+
+  function paintChannel(i) {
+    const ch = channelEls[i];
+    if (!ch) return;
+    const g = LANES[i].gain;
+    const p = (g * 100).toFixed(1);
+    ch.fill.style.height = p + '%';
+    if (!state.playing && !(A.level > 0.015)) ch.bead.style.bottom = 'calc(' + p + '% - 4.5px)';
+    ch.db.textContent = dbText(g);
+    ch.el.setAttribute('aria-valuenow', Math.round(g * 100));
+    if (E) E.setVeinGain(i, g);
+  }
+
+  function buildFaders() {
+    fadersEl.innerHTML = '';
+    channelEls.length = 0;
+    LANES.forEach(function (lane, i) {
+      const el = document.createElement('div');
+      el.className = 'ch';
+      el.dataset.lane = i;
+      el.tabIndex = 0;
+      el.setAttribute('role', 'slider');
+      el.setAttribute('aria-label', 'Level of vein ' + lane.name);
+      el.setAttribute('aria-valuemin', '0');
+      el.setAttribute('aria-valuemax', '100');
+      el.innerHTML = '
' + lane.name + ''; + const obj = { + el: el, + fill: el.querySelector('.ch-fill'), + bead: el.querySelector('.ch-bead'), + db: el.querySelector('.ch-db') + }; + channelEls.push(obj); + + let dragging = false; + const trackEl = el.querySelector('.ch-track'); + function setFromY(clientY) { + const r = trackEl.getBoundingClientRect(); + LANES[i].gain = clamp(1 - (clientY - r.top) / r.height, 0.02, 1); + paintChannel(i); + } + el.addEventListener('pointerdown', function (e) { + dragging = true; + el.setPointerCapture(e.pointerId); + setFromY(e.clientY); + e.preventDefault(); + }); + el.addEventListener('pointermove', function (e) { if (dragging) setFromY(e.clientY); }); + el.addEventListener('pointerup', function () { dragging = false; }); + el.addEventListener('pointercancel', function () { dragging = false; }); + el.addEventListener('pointerenter', function () { setHot(i); }); + el.addEventListener('pointerleave', function () { setHot(-1); }); + el.addEventListener('keydown', function (e) { + const step = e.shiftKey ? 0.1 : 0.02; + if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { LANES[i].gain = clamp(LANES[i].gain + step, 0.02, 1); paintChannel(i); e.preventDefault(); } + else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { LANES[i].gain = clamp(LANES[i].gain - step, 0.02, 1); paintChannel(i); e.preventDefault(); } + }); + fadersEl.appendChild(el); + paintChannel(i); + }); + } + + /* ————— crystal ————— */ + + const PC_ROW = { 0: 0, 2: 1, 3: 2, 5: 3, 7: 4, 8: 5, 10: 6, 11: 7 }; + let latticeWindow = -1; + let latPlayhead = null; + + function latticeWindowStart() { + return clamp(Math.floor(state.pos) - 1, 1, 29); + } + + function buildLattice() { + if (!latticeEl) return; + const W = latticeEl.clientWidth; + const H = latticeEl.clientHeight; + if (!W || !H) return; + const padL = 16, padR = 3, padT = 5, padB = 4; + const rows = 8, cols = 16; + const gw = (W - padL - padR) / cols; + const gh = (H - padT - padB) / rows; + const start = latticeWindowStart(); + latticeWindow = start; + const barsEl = $('#crystal-bars'); + if (barsEl) barsEl.textContent = 'bars ' + start + '–' + (start + 3); + + let out = ''; + latticeEl.innerHTML = out; + latPlayhead = latticeEl.querySelector('#lat-ph'); + } + + function updateLatticePlayhead() { + if (!latPlayhead || !E) return; + const W = latticeEl.clientWidth; + const padL = 16, padR = 3; + const ph = clamp((state.pos - latticeWindow) / 4, 0, 1) * (W - padL - padR) + padL; + latPlayhead.setAttribute('x1', ph.toFixed(1)); + latPlayhead.setAttribute('x2', ph.toFixed(1)); + } + + /* ————— knobs ————— */ + + function polar(cx, cy, r, deg) { + const a = deg * Math.PI / 180; + return [cx + r * Math.cos(a), cy + r * Math.sin(a)]; + } + + function arcPath(cx, cy, r, a0, a1) { + const p0 = polar(cx, cy, r, a0); + const p1 = polar(cx, cy, r, a1); + const large = Math.abs(a1 - a0) > 180 ? 1 : 0; + return 'M' + p0[0].toFixed(2) + ' ' + p0[1].toFixed(2) + ' A' + r + ' ' + r + ' 0 ' + large + ' 1 ' + p1[0].toFixed(2) + ' ' + p1[1].toFixed(2); + } + + function paintKnob(el) { + const v = clamp(+el.dataset.value, 0, 1); + const a0 = 135, a1 = 405; + const a = a0 + (a1 - a0) * v; + el.querySelector('.k-arc').setAttribute('d', arcPath(17, 17, 13, a0, a)); + const t0 = polar(17, 17, 10, a), t1 = polar(17, 17, 15, a); + el.querySelector('.k-tick').setAttribute('d', 'M' + t0[0].toFixed(2) + ' ' + t0[1].toFixed(2) + ' L' + t1[0].toFixed(2) + ' ' + t1[1].toFixed(2)); + el.setAttribute('aria-valuenow', Math.round(v * 100)); + if (E) E.setMacro(el.dataset.knob, v); + } + + function initKnobs() { + $$('.knob').forEach(function (el) { + el.tabIndex = 0; + el.setAttribute('role', 'slider'); + el.setAttribute('aria-label', el.dataset.knob); + el.setAttribute('aria-valuemin', '0'); + el.setAttribute('aria-valuemax', '100'); + paintKnob(el); + let dragging = false, y0 = 0, v0 = 0; + el.addEventListener('pointerdown', function (e) { + dragging = true; y0 = e.clientY; v0 = +el.dataset.value; + el.setPointerCapture(e.pointerId); + e.preventDefault(); + }); + el.addEventListener('pointermove', function (e) { + if (!dragging) return; + el.dataset.value = clamp(v0 + (y0 - e.clientY) / 110, 0, 1); + paintKnob(el); + }); + el.addEventListener('pointerup', function () { dragging = false; }); + el.addEventListener('keydown', function (e) { + const step = e.shiftKey ? 0.1 : 0.03; + if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { el.dataset.value = clamp(+el.dataset.value + step, 0, 1); paintKnob(el); e.preventDefault(); } + else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { el.dataset.value = clamp(+el.dataset.value - step, 0, 1); paintKnob(el); e.preventDefault(); } + }); + }); + } + + /* ————— halo & breath ————— */ + + function sizeHalo() { + const wrap = $('.void-wrap'); + if (!wrap) return; + const r = wrap.getBoundingClientRect(); + const dpr = Math.min(2, window.devicePixelRatio || 1); + haloW = Math.round(r.width + 176); + haloH = Math.round(r.height + 176); + halo.width = Math.round(haloW * dpr); + halo.height = Math.round(haloH * dpr); + hctx.setTransform(dpr, 0, 0, dpr, 0, 0); + voidR = r.width / 2; + eyeCX = r.left + r.width / 2; + eyeCY = r.top + r.height / 2; + } + + function sizeScope() { + if (!scope) return; + const dpr = Math.min(2, window.devicePixelRatio || 1); + const r = scope.getBoundingClientRect(); + scopeW = Math.round(r.width); + scopeH = Math.round(r.height); + scope.width = Math.max(2, Math.round(scopeW * dpr)); + scope.height = Math.max(2, Math.round(scopeH * dpr)); + sctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + + function drawScope() { + if (!scopeW) return; + sctx.clearRect(0, 0, scopeW, scopeH); + const mid = scopeH / 2; + sctx.strokeStyle = 'rgba(155,133,121,.2)'; + sctx.lineWidth = 0.7; + sctx.beginPath(); + sctx.moveTo(0, mid); + sctx.lineTo(scopeW, mid); + sctx.stroke(); + + const wave = A.wave; + if (!wave) return; + sctx.beginPath(); + for (let i = 0; i < wave.length; i++) { + const x = (i / (wave.length - 1)) * scopeW; + const y = mid - wave[i] * mid * 0.92; + if (i === 0) sctx.moveTo(x, y); else sctx.lineTo(x, y); + } + sctx.strokeStyle = 'rgba(232,64,42,.9)'; + sctx.lineWidth = 1; + sctx.stroke(); + } + + function sizeBreath() { + breathW = Math.max(2, Math.round(window.innerWidth * BS)); + breathH = Math.max(2, Math.round(window.innerHeight * BS)); + breath.width = breathW; + breath.height = breathH; + } + + function drawHalo(now) { + if (!haloW) return; + hctx.clearRect(0, 0, haloW, haloH); + const cx = haloW / 2, cy = haloH / 2, R = voidR; + const beat = (state.pos - 1) * 4; + const rings = reduceMotion ? 1 : 3; + + for (let k = 0; k < rings; k++) { + const ph = frac(beat * 0.25 + k / rings); + const rr = R * (1.08 + ph * 1.5); + hctx.beginPath(); + for (let a = 0; a <= 80; a++) { + const ang = (a / 80) * TAU; + const wob = 1 + 0.02 * Math.sin(ang * 3 + now * 0.11 + k) + 0.014 * Math.sin(ang * 7 - now * 0.07); + const x = cx + Math.cos(ang) * rr * wob; + const y = cy + Math.sin(ang) * rr * wob; + if (a === 0) hctx.moveTo(x, y); else hctx.lineTo(x, y); + } + hctx.closePath(); + hctx.strokeStyle = 'rgba(232,64,42,' + (0.17 * (1 - ph)).toFixed(3) + ')'; + hctx.lineWidth = 0.8; + hctx.stroke(); + } + + /* the spectrum, as needles */ + const N = A.spec.length; + const rot = reduceMotion ? 0 : now * 0.01; + const base = A.level; + for (let i = 0; i < N; i++) { + const ang = (i / N) * TAU - Math.PI / 2 + rot; + const v = A.spec[i]; + const len = 2 + (0.12 + v * 1.1) * (0.4 + base) * R * 0.62; + const r0 = R * (1.05 + 0.015 * Math.sin(ang * 5 + now * 0.3)); + const c = Math.cos(ang), s = Math.sin(ang); + hctx.beginPath(); + hctx.moveTo(cx + c * r0, cy + s * r0); + hctx.lineTo(cx + c * (r0 + len), cy + s * (r0 + len)); + hctx.strokeStyle = 'rgba(232,64,42,' + (0.1 + 0.4 * Math.min(1, len / (R * 0.55))).toFixed(3) + ')'; + hctx.lineWidth = 0.75; + hctx.stroke(); + } + } + + function drawBreath(now) { + if (!breathW) return; + bctx.clearRect(0, 0, breathW, breathH); + const lv = A.level; + if (lv < 0.01 && A.ripples.length === 0) return; + + const cx = eyeCX * BS, cy = eyeCY * BS, R = voidR * BS; + bctx.globalCompositeOperation = 'lighter'; + + if (lv > 0.01) { + const g = bctx.createRadialGradient(cx, cy, R * 0.8, cx, cy, R * (2.4 + 1.8 * lv)); + g.addColorStop(0, 'rgba(232,64,42,' + (0.06 + 0.18 * lv).toFixed(3) + ')'); + g.addColorStop(0.42, 'rgba(232,64,42,' + (0.02 + 0.07 * lv).toFixed(3) + ')'); + g.addColorStop(1, 'rgba(232,64,42,0)'); + bctx.fillStyle = g; + bctx.fillRect(0, 0, breathW, breathH); + } + + A.ripples.forEach(function (rip) { + const p = (now - rip.t) / 1.7; + if (p > 1) return; + const rr = R * (1.12 + p * 3.4); + bctx.beginPath(); + for (let a = 0; a <= 90; a++) { + const ang = (a / 90) * TAU; + const wob = 1 + 0.012 * Math.sin(ang * 5 + rip.seed) + 0.008 * Math.sin(ang * 11 - rip.seed); + const x = cx + Math.cos(ang) * rr * wob; + const y = cy + Math.sin(ang) * rr * wob; + if (a === 0) bctx.moveTo(x, y); else bctx.lineTo(x, y); + } + bctx.closePath(); + bctx.strokeStyle = 'rgba(232,64,42,' + (0.24 * (1 - p) * rip.amp).toFixed(3) + ')'; + bctx.lineWidth = 1.1; + bctx.stroke(); + }); + A.ripples = A.ripples.filter(function (r) { return now - r.t < 1.7; }); + bctx.globalCompositeOperation = 'source-over'; + } + + /* ————— analysis: the instrument listens ————— */ + + const E = window.ZHILA_ENGINE; + const A = { + level: 0, rms: 0, + bands: [0, 0, 0, 0, 0], + spec: new Float32Array(72), + wave: new Float32Array(120), + ripples: [], lastRipple: 0, + sr: E ? E.sr : 44100 + }; + + function analyse(now) { + if (!E) return; + const d = E.read(); + const freq = d.freq, td = d.time; + const binHz = E.sr / 2048; + const ranges = [[26, 140], [140, 400], [400, 1500], [1500, 4500], [4500, 12000]]; + const gains = [0.46, 0.72, 1.0, 1.45, 2.0]; + for (let i = 0; i < 5; i++) { + const a = Math.floor(ranges[i][0] / binHz); + const b = Math.min(freq.length - 1, Math.ceil(ranges[i][1] / binHz)); + let sum = 0, count = 0; + for (let k = a; k <= b; k++) { sum += freq[k]; count++; } + let v = count ? (sum / count) / 255 : 0; + v = Math.pow(v, 0.7) * gains[i]; + const prev = A.bands[i]; + A.bands[i] = prev + (clamp(v, 0, 1) - prev) * (v > prev ? 0.55 : 0.12); + } + + let sum = 0; + for (let i = 0; i < td.length; i++) { + const v = (td[i] - 128) / 128; + sum += v * v; + } + const rms = Math.sqrt(sum / td.length); + A.rms = A.rms + (rms - A.rms) * (rms > A.rms ? 0.5 : 0.09); + const target = clamp(A.rms * 2.8, 0, 1); + A.level = A.level + (target - A.level) * (target > A.level ? 0.6 : 0.12); + + if (A.bands[0] > 0.42 && now - A.lastRipple > 0.32) { + A.ripples.push({ t: now, amp: clamp(A.bands[0] * 1.3, 0.2, 1), seed: Math.random() * 6 }); + A.lastRipple = now; + } + + const step = td.length / A.wave.length; + for (let i = 0; i < A.wave.length; i++) { + A.wave[i] = (td[Math.floor(i * step)] - 128) / 128; + } + + for (let j = 0; j < A.spec.length; j++) { + const f = 32 * Math.pow(14000 / 32, j / (A.spec.length - 1)); + const k = Math.min(freq.length - 1, Math.round(f / binHz)); + let v = freq[k] / 255; + v = Math.pow(v, 0.75) * (0.7 + 0.85 * (j / A.spec.length)); + A.spec[j] += (clamp(v, 0, 1) - A.spec[j]) * 0.38; + } + } + + /* ————— extraction sources ————— */ + + function markSource(src) { + $$('.src').forEach(function (b) { + const on = b.dataset.src === src; + b.classList.toggle('is-on', on); + b.setAttribute('aria-pressed', String(on)); + }); + } + + function revealCore(core) { + if (!core) return; + const q = window.ZHILA_QUARRY; + if (q) { + q.setView('quarry'); + if (q.focusCore) q.focusCore(core.id); + } + } + + function extractCore(src) { + const core = E.newCore({ seal: 'taken', name: src === 'room' ? 'room, live' : undefined }); + E.addLog('cores.extract', core.name, 'taken', 'hand'); + markSource(src); + revealCore(core); + } + + function recordRoom(btn) { + if (!E || btn.dataset.busy) return; + btn.dataset.busy = '1'; + const old = btn.textContent; + btn.textContent = 'room 6'; + btn.classList.add('is-rec'); + let left = 6; + const iv = setInterval(function () { + left--; + if (left > 0) btn.textContent = 'room ' + left; + }, 1000); + return E.recordRoom(6).then(function (core) { + clearInterval(iv); + btn.textContent = old; + delete btn.dataset.busy; + btn.classList.remove('is-rec'); + markSource('room'); + E.addLog('cores.extract', core.name, 'taken', 'hand'); + revealCore(core); + }).catch(function () { + clearInterval(iv); + btn.textContent = 'no room'; + delete btn.dataset.busy; + btn.classList.remove('is-rec'); + setTimeout(function () { btn.textContent = old; }, 1400); + }); + } + + $$('.src').forEach(function (b) { + b.addEventListener('click', function () { + const src = b.dataset.src; + if (src === 'file') { fileIn.click(); return; } + if (src === 'room') { recordRoom(b); return; } + extractCore(src); + }); + }); + + fileIn.addEventListener('change', function () { + const f = fileIn.files && fileIn.files[0]; + if (!f) return; + const btn = $('.src[data-src="file"]'); + btn.textContent = 'file…'; + E.importFile(f).then(function (core) { + btn.textContent = 'file'; + markSource('file'); + E.addLog('cores.extract', core.name, 'taken', 'hand'); + revealCore(core); + }).catch(function () { + btn.textContent = 'file'; + }); + fileIn.value = ''; + }); + + /* ————— song → engine ————— */ + + function resolveClips() { + LANES.forEach(function (lane) { + lane.clips.forEach(function (c) { + if (!c.id) c.id = ++clipSeq; + if (c.id > clipSeq) clipSeq = c.id; + if (c.core && c.faceRank != null && c.face == null) { + c.face = E.rankFace(c.core, c.faceRank); + } + }); + }); + } + + function findClip(id) { + for (let li = 0; li < LANES.length; li++) { + const clips = LANES[li].clips; + for (let ci = 0; ci < clips.length; ci++) { + if (clips[ci].id === id) return { lane: LANES[li], li: li, i: ci, clip: clips[ci] }; + } + } + return null; + } + + function markSelected() { + $$('.clip', svg).forEach(function (g) { + g.classList.toggle('is-sel', +g.dataset.id === state.selClip); + }); + } + + function selectClip(id) { + state.selClip = id; + markSelected(); + const f = findClip(id); + const read = $('#corridor-read'); + if (read && f) { + const label = f.clip.core ? f.clip.core + ' · f' + String((f.clip.face || 0) + 1).padStart(2, '0') : ''; + read.textContent = f.clip.name + (f.clip.ring ? ' ↻' + f.clip.ring : '') + (label ? ' · ' + label : ''); + } + } + + function deleteSelected() { + const f = findClip(state.selClip); + if (!f) return; + pushUndo(); + const name = f.clip.name; + f.lane.clips.splice(f.i, 1); + state.selClip = null; + buildNave(); + commitSong(); + scheduleSave(); + if (E) E.addLog('stones.bury', name, f.clip.seal || '', 'hand'); + } + + function duplicateSelected() { + const f = findClip(state.selClip); + if (!f) return; + pushUndo(); + const c = Object.assign({}, f.clip); + const len = c.b - c.a; + c.a = Math.min(32 - len, c.b); + c.b = c.a + len; + c.id = ++clipSeq; + f.lane.clips.push(c); + state.selClip = c.id; + buildNave(); + commitSong(); + scheduleSave(); + if (E) E.addLog('stones.duplicate', c.name + ' → bar ' + c.a, c.seal || '', 'hand'); + } + + function barFromY(y) { + if (!NAVE) return 1; + const s = clamp((y - NAVE.horizon) / (NAVE.bottom - NAVE.horizon), 0, 1); + return 1 + Math.pow(1 - s, 1 / 1.16) * 31; + } + + function commitSong() { + if (!E) return; + const stones = []; + LANES.forEach(function (lane, i) { + lane.clips.forEach(function (c) { + if (!c.core || c.face == null) return; + stones.push({ + a: c.a, b: c.b, coreId: c.core, face: c.face, + ring: c.ring || 0, pitch: c.pitch || 0, + gain: c.gain == null ? 0.8 : c.gain, vein: i + }); + }); + }); + E.setSong(stones); + if (typeof buildLattice === 'function') buildLattice(); + } + + /* ————— history & project ————— */ + + let undoStack = []; + let redoStack = []; + let saveTimer = null; + + function snapshot() { + return JSON.stringify({ + bpm: state.bpm, + lanes: LANES.map(function (l) { return l.clips.map(function (c) { return Object.assign({}, c); }); }) + }); + } + + function pushUndo() { + undoStack.push(snapshot()); + if (undoStack.length > 40) undoStack.shift(); + redoStack.length = 0; + } + + function applySnapshot(json) { + let d; + try { d = JSON.parse(json); } catch (e) { return; } + if (!d || !Array.isArray(d.lanes)) return; + LANES.forEach(function (lane, i) { lane.clips = (d.lanes[i] || []).map(function (c) { return Object.assign({}, c); }); }); + state.bpm = clamp(d.bpm || 128, 40, 220); + const bpmEl = $('#bpm'); + if (bpmEl) bpmEl.textContent = state.bpm.toFixed(2); + if (E) E.setBpm(state.bpm); + buildNave(); + commitSong(); + scheduleSave(); + updateClock(true); + } + + function undo() { + if (!undoStack.length) return; + redoStack.push(snapshot()); + applySnapshot(undoStack.pop()); + } + + function redo() { + if (!redoStack.length) return; + undoStack.push(snapshot()); + applySnapshot(redoStack.pop()); + } + + function scheduleSave() { + clearTimeout(saveTimer); + saveTimer = setTimeout(saveProject, 450); + } + + function saveProject() { + try { localStorage.setItem('zhila.project.v3', snapshot()); } catch (e) {} + } + + function loadProject() { + let raw = null; + try { raw = localStorage.getItem('zhila.project.v3'); } catch (e) {} + if (!raw) return false; + try { + const d = JSON.parse(raw); + if (!d || !Array.isArray(d.lanes)) return false; + LANES.forEach(function (lane, i) { + lane.clips = (d.lanes[i] || []).map(function (c) { return Object.assign({}, c); }); + }); + state.bpm = clamp(d.bpm || 128, 40, 220); + return true; + } catch (e) { return false; } + } + + /* ————— transport ————— */ + + const playBtn = $('#play'); + const timeEl = $('#time'); + const barsEl = $('#bars'); + const masterDbEl = $('#master-db'); + const meterFill = $('#meter-fill'); + const meterPeak = $('#meter-peak'); + + function setPlaying(v) { + if (!E) return; + if (v) E.play(); else E.pause(); + state.playing = E.isPlaying(); + document.body.classList.toggle('is-playing', state.playing); + playBtn.setAttribute('aria-pressed', String(state.playing)); + playBtn.setAttribute('aria-label', state.playing ? 'Pause' : 'Play'); + if (!state.playing) { + state.pos = E.getPos(); + channelEls.forEach(function (ch, i) { paintChannel(i); }); + } + } + + playBtn.addEventListener('click', function () { setPlaying(!state.playing); }); + + $('#stop').addEventListener('click', function () { + if (!E) return; + E.stop(); + setPlaying(false); + state.pos = 1; + updatePlayhead(true); + updateClock(true); + }); + + $('#rec').addEventListener('click', function () { + if (!E || this.dataset.busy) return; + const self = this; + self.dataset.busy = '1'; + self.setAttribute('aria-pressed', 'true'); + voidBtn.classList.add('is-pulsed'); + recordRoom($('.src[data-src="room"]')).then(function () { + self.setAttribute('aria-pressed', 'false'); + delete self.dataset.busy; + voidBtn.classList.remove('is-pulsed'); + }); + }); + + $('#snd').addEventListener('click', function () { + state.sound = !state.sound; + this.setAttribute('aria-pressed', String(state.sound)); + this.setAttribute('aria-label', state.sound ? 'Audio on' : 'Audio off'); + if (E) E.setMuted(!state.sound); + }); + + function updateClock(force) { + const barSec = 60 / state.bpm * 4; + const sec = Math.max(0, (state.pos - 1) * barSec); + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + const cs = Math.floor((sec % 1) * 100); + const html = String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0') + '.' + String(cs).padStart(2, '0') + ''; + if (force || html !== lastClock) { + timeEl.innerHTML = html; + lastClock = html; + } + const bar = Math.floor(state.pos); + const beat = Math.floor((state.pos - bar) * 4) + 1; + const b = bar + '.' + beat; + if (barsEl.textContent !== b) barsEl.textContent = b; + } + + /* ————— tempo drag ————— */ + + (function () { + const el = $('#bpm'); + let dragging = false, y0 = 0, v0 = 0; + el.addEventListener('pointerdown', function (e) { + dragging = true; y0 = e.clientY; v0 = state.bpm; + el.setPointerCapture(e.pointerId); + e.preventDefault(); + }); + el.addEventListener('pointermove', function (e) { + if (!dragging) return; + state.bpm = clamp(Math.round((v0 + (y0 - e.clientY) * 0.25) * 100) / 100, 40, 220); + el.textContent = state.bpm.toFixed(2); + if (E) E.setBpm(state.bpm); + updateClock(true); + }); + el.addEventListener('pointerup', function () { dragging = false; }); + el.addEventListener('dblclick', function () { state.bpm = 128; el.textContent = '128.00'; if (E) E.setBpm(128); updateClock(true); }); + })(); + + /* ————— levels ————— */ + + function updateLevels(now) { + const lv = A.level; + meterFill.style.transform = 'scaleX(' + lv.toFixed(3) + ')'; + peak = Math.max(lv, peak - 0.006); + meterPeak.style.left = (peak * 100).toFixed(2) + '%'; + + const db = 20 * Math.log10(Math.max(A.rms, 1e-5)); + masterDbEl.textContent = (A.rms > 0.0008 ? (db > -0.05 ? '0.0' : db.toFixed(1)) : '−∞').replace('-', '−') + ' dB'; + + channelEls.forEach(function (ch, i) { + const g = LANES[i].gain; + const live = (A.level > 0.015 || state.playing) ? clamp(A.bands[i], 0.004, 1) : g; + ch.bead.style.bottom = 'calc(' + (live * 100).toFixed(1) + '% - 4.5px)'; + }); + + if (Math.abs(lv - lastLv) > 0.006) { + lastLv = lv; + document.documentElement.style.setProperty('--lv', lv.toFixed(3)); + } + } + + /* ————— loop ————— */ + + function frame(now) { + if (E && E.isPlaying()) state.pos = E.getPos(); + analyse(now / 1000); + updatePlayhead(false); + updateClock(false); + if (E && E.isPlaying() && latticeEl.clientWidth > 0 && latticeWindow !== latticeWindowStart()) buildLattice(); + updateLatticePlayhead(); + updateLevels(now / 1000); + drawHalo(now / 1000); + drawBreath(now / 1000); + drawScope(); + requestAnimationFrame(frame); + } + + /* ————— build ————— */ + + function buildAll() { + resolveClips(); + state.bpm = E ? E.getBpm() : state.bpm; + if (loadProject()) resolveClips(); + commitSong(); + if (E) E.setBpm(state.bpm); + const bpmEl = $('#bpm'); + if (bpmEl) bpmEl.textContent = state.bpm.toFixed(2); + buildNave(); + buildLattice(); + buildFaders(); + initKnobs(); + sizeHalo(); + sizeBreath(); + sizeScope(); + if (window.ZHILA_TERRAIN) { + window.ZHILA_TERRAIN.measure(); + window.ZHILA_TERRAIN.render(); + window.ZHILA_TERRAIN.drawSpires(); + } + logEl.scrollTop = logEl.scrollHeight; + updateClock(true); + updatePlayhead(true); + } + + let fontsSettled = false; + + function relayout() { + buildNave(); + buildLattice(); + sizeHalo(); + sizeBreath(); + sizeScope(); + if (window.ZHILA_TERRAIN) { + window.ZHILA_TERRAIN.measure(); + window.ZHILA_TERRAIN.render(); + window.ZHILA_TERRAIN.drawSpires(); + } + } + + let resizeTimer = null; + window.addEventListener('resize', function () { + window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(function () { + buildNave(); + buildLattice(); + sizeHalo(); + sizeBreath(); + sizeScope(); + if (window.ZHILA_TERRAIN) { + window.ZHILA_TERRAIN.measure(); + window.ZHILA_TERRAIN.render(); + window.ZHILA_TERRAIN.drawSpires(); + } + }, 200); + }); + + document.addEventListener('keydown', function (e) { + const tag = (e.target && e.target.tagName) || ''; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + if (e.code === 'Space') { + e.preventDefault(); + setPlaying(!state.playing); + } + const mod = e.metaKey || e.ctrlKey; + if (mod && e.key.toLowerCase() === 'z') { + e.preventDefault(); + if (e.shiftKey) redo(); else undo(); + } + if (mod && e.key.toLowerCase() === 'd') { + e.preventDefault(); + duplicateSelected(); + } + if ((e.key === 'Backspace' || e.key === 'Delete') && state.selClip) { + e.preventDefault(); + deleteSelected(); + } + }); + + const layerBtn = $('.tp--end .ghost'); + if (layerBtn) layerBtn.addEventListener('click', function () { askAgent('one more layer, four bars'); }); + + const outBtn = $('.ghost--ember'); + if (outBtn) outBtn.addEventListener('click', function () { + if (outBtn.dataset.busy || !E) return; + outBtn.dataset.busy = '1'; + outBtn.textContent = 'rendering'; + E.download().then(function () { + outBtn.textContent = 'done'; + window.setTimeout(function () { + outBtn.textContent = 'render'; + delete outBtn.dataset.busy; + }, 1800); + }).catch(function () { + outBtn.textContent = 'render'; + delete outBtn.dataset.busy; + }); + }); + + /* ————— command layer (hand and agents use the same verbs) ————— */ + + function stonesSummary() { + const out = []; + LANES.forEach(function (lane, i) { + lane.clips.forEach(function (c) { + out.push({ + id: c.id, vein: lane.name, name: c.name, + bars: [+(+c.a).toFixed(2), +(+c.b).toFixed(2)], + ring: c.ring || 0, core: c.core, face: c.face, + pitch: c.pitch || 0, gain: c.gain == null ? 0.8 : c.gain, + seal: c.seal + }); + }); + }); + return out; + } + + function addStoneAt(opts) { + opts = opts || {}; + if (!E) return { error: 'no engine' }; + const coreId = opts.core && E.byId(opts.core) ? opts.core : (E.cores[0] && E.cores[0].id); + if (!coreId) return { error: 'no cores' }; + let vi = typeof opts.vein === 'number' ? opts.vein : LANES.findIndex(function (l) { return l.name === opts.vein; }); + if (vi < 0) vi = 0; + const lane = LANES[clamp(vi, 0, 4)]; + const face = opts.face != null ? clamp(Math.round(opts.face), 0, 15) : E.rankFace(coreId, 0); + const a = clamp(+opts.start || 1, 1, 32); + const len = clamp(+opts.length || 4, 0.25, 32); + const b = Math.min(32, a + len); + const ring = clamp(+opts.ring || 0, 0, 8); + const pitch = clamp(Math.round(+opts.pitch || 0), -12, 12); + const gain = clamp(opts.gain == null ? 0.85 : +opts.gain, 0.1, 1.2); + pushUndo(); + const clip = { + a: +a.toFixed(2), b: +b.toFixed(2), + name: opts.name || 'face ' + String(face + 1).padStart(2, '0'), + seal: opts.seal || 'taken', core: coreId, face: face, + ring: ring, pitch: pitch, gain: gain, id: ++clipSeq + }; + lane.clips.push(clip); + lane.clips.sort(function (x, y) { return x.a - y.a; }); + buildNave(clip.id); + commitSong(); + scheduleSave(); + E.addLog('stones.place', clip.name + ' → ' + lane.name + ', bar ' + clip.a, clip.seal, opts.actor || 'api'); + return { + id: clip.id, vein: lane.name, name: clip.name, core: coreId, face: face, + bars: [clip.a, clip.b], ring: ring, pitch: pitch, gain: gain + }; + } + + function moveStoneAt(id, start) { + const f = findClip(id); + if (!f) return { error: 'no such stone' }; + pushUndo(); + const len = f.clip.b - f.clip.a; + const a = clamp(+start || 1, 1, Math.max(1, 32 - len)); + f.clip.a = +a.toFixed(2); + f.clip.b = +(a + len).toFixed(2); + buildNave(); + commitSong(); + scheduleSave(); + if (E) E.addLog('stones.move', f.clip.name + ' → bar ' + f.clip.a, f.clip.seal || '', 'api'); + return { id: id, bars: [f.clip.a, f.clip.b] }; + } + + function deleteStoneAt(id) { + const f = findClip(id); + if (!f) return { error: 'no such stone' }; + pushUndo(); + const name = f.clip.name; + f.lane.clips.splice(f.i, 1); + if (state.selClip === id) state.selClip = null; + buildNave(); + commitSong(); + scheduleSave(); + if (E) E.addLog('stones.bury', name, f.clip.seal || '', 'api'); + return { id: id, deleted: true }; + } + + function setTempoValue(bpm) { + state.bpm = clamp(+bpm || 128, 40, 220); + if (E) E.setBpm(state.bpm); + const el = $('#bpm'); + if (el) el.textContent = state.bpm.toFixed(2); + updateClock(true); + return state.bpm; + } + + function transportAt(action, bar) { + if (!E) return { error: 'no engine' }; + if (action === 'play') setPlaying(true); + else if (action === 'pause') setPlaying(false); + else if (action === 'stop') { + E.stop(); + setPlaying(false); + state.pos = 1; + updatePlayhead(true); + updateClock(true); + } else if (action === 'seek') { + state.pos = clamp(+bar || 1, 1, 32); + E.seek(state.pos); + updatePlayhead(true); + updateClock(true); + } + return { playing: E.isPlaying(), bar: +E.getPos().toFixed(2) }; + } + + function setMacroUI(name, value) { + const el = document.querySelector('.knob[data-knob="' + name + '"]'); + if (!el) return null; + el.dataset.value = clamp(+value, 0, 1); + paintKnob(el); + return +el.dataset.value; + } + + window.ZHILA = { + state: state, + engine: E, + setMode: setMode, + addStoneClip: addStoneClip, + buildNave: buildNave, + buildLattice: buildLattice, + stones: stonesSummary, + addStoneAt: addStoneAt, + moveStoneAt: moveStoneAt, + deleteStoneAt: deleteStoneAt, + setTempo: setTempoValue, + transport: transportAt, + setMacroUI: setMacroUI, + shape: function () { + return { density: +($('#s-density') || {}).value / 100, depth: +($('#s-depth') || {}).value / 100, grain: +($('#s-grain') || {}).value / 100 }; + }, + undo: undo, + redo: redo, + commitSong: commitSong + }; + + /* the first light */ + function shapeValue(id) { + const el = $('#' + id); + return el ? +el.value / 100 : 0.5; + } + + function boot() { + $$('.shape .slider input').forEach(function (el) { + const out = el.parentNode.querySelector('output'); + if (out) out.textContent = (+el.value / 100).toFixed(2); + el.addEventListener('input', function () { + if (out) out.textContent = (+el.value / 100).toFixed(2); + }); + }); + buildAll(); + requestAnimationFrame(function () { + document.body.classList.add('is-ready'); + requestAnimationFrame(frame); + }); + if (document.fonts && document.fonts.ready) { + document.fonts.ready.then(function () { + if (fontsSettled) return; + fontsSettled = true; + relayout(); + }); + } + } + + if (document.readyState === 'complete' || document.readyState === 'interactive') { + window.setTimeout(boot, 0); + } else { + document.addEventListener('DOMContentLoaded', boot); + } +})(); diff --git a/reference/demo/engine.js b/reference/demo/engine.js new file mode 100644 index 0000000..14e1864 --- /dev/null +++ b/reference/demo/engine.js @@ -0,0 +1,825 @@ +/* ZHILA · engine + audio clock, sample slices, voices, climate, transport, render. + one graph for listening and for offline bounce. */ + +(function () { + 'use strict'; + + const clamp = (v, a, b) => v < a ? a : v > b ? b : v; + const mulberry = function (seed) { + let a = seed | 0; + return function () { + a = a + 0x6D2B79F5 | 0; + let t = Math.imul(a ^ a >>> 15, 1 | a); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; + }; + + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const sr = ctx.sampleRate; + + const E = { + ctx: ctx, + sr: sr, + cores: [], + song: { bpm: 128, bars: 32, stones: [] }, + weather: { age: 0.34, pressure: 0.52, air: 0.41, heat: 0.28 }, + rings: 4, + muted: false, + playing: false, + log: [], + listeners: { cores: [], log: [], render: [] } + }; + + /* ————— graph ————— */ + + function impulse(c, seconds) { + const n = Math.floor(c.sampleRate * seconds); + const buf = c.createBuffer(2, n, c.sampleRate); + for (let ch = 0; ch < 2; ch++) { + const d = buf.getChannelData(ch); + for (let i = 0; i < n; i++) { + d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, 2.6); + } + } + return buf; + } + + function graph(c) { + const master = c.createGain(); + master.gain.value = E.muted ? 0 : 0.85; + + const limiter = c.createDynamicsCompressor(); + limiter.threshold.value = -7; + limiter.knee.value = 8; + limiter.ratio.value = 10; + limiter.attack.value = 0.004; + limiter.release.value = 0.22; + + const analyser = c.createAnalyser(); + analyser.fftSize = 2048; + analyser.smoothingTimeConstant = 0.72; + + const climateIn = c.createGain(); + const age = c.createBiquadFilter(); + age.type = 'lowpass'; + age.Q.value = 0.6; + const tilt = c.createBiquadFilter(); + tilt.type = 'highshelf'; + tilt.frequency.value = 1800; + tilt.gain.value = (macros.warmth - 0.5) * 10; + const drive = c.createGain(); + const shaper = c.createWaveShaper(); + const curve = new Float32Array(1024); + for (let i = 0; i < 1024; i++) { + const x = (i * 2) / 1024 - 1; + curve[i] = Math.tanh(x * 2.1); + } + shaper.curve = curve; + shaper.oversample = '2x'; + const post = c.createGain(); + const dry = c.createGain(); + const verb = c.createConvolver(); + verb.buffer = impulse(c, 2.4); + const wet = c.createGain(); + + climateIn.connect(age); + age.connect(tilt); + tilt.connect(drive); + drive.connect(shaper); + shaper.connect(post); + post.connect(dry); + dry.connect(limiter); + post.connect(verb); + verb.connect(wet); + wet.connect(limiter); + limiter.connect(master); + master.connect(analyser); + analyser.connect(c.destination); + + const veins = []; + const pans = [-0.34, -0.16, 0, 0.16, 0.34]; + for (let i = 0; i < 5; i++) { + const g = c.createGain(); + g.gain.value = 0.6; + const p = c.createStereoPanner ? c.createStereoPanner() : null; + if (p) { + p.pan.value = pans[i]; + g.connect(p); + p.connect(climateIn); + } else { + g.connect(climateIn); + } + veins.push(g); + } + const strikeBus = c.createGain(); + strikeBus.gain.value = 0.8; + strikeBus.connect(climateIn); + + // dust: a permanent, quiet hiss in the climate + const hissBuf = c.createBuffer(1, Math.floor(c.sampleRate * 2), c.sampleRate); + const hd = hissBuf.getChannelData(0); + for (let i = 0; i < hd.length; i++) hd[i] = Math.random() * 2 - 1; + const hiss = c.createBufferSource(); + hiss.buffer = hissBuf; + hiss.loop = true; + const hissF = c.createBiquadFilter(); + hissF.type = 'highpass'; + hissF.frequency.value = 1200; + const hissGain = c.createGain(); + hissGain.gain.value = macros.dust * 0.03; + hiss.connect(hissF); hissF.connect(hissGain); hissGain.connect(tilt); + hiss.start(); + + limiter.threshold.value = -20 + macros.threshold * 20; + + return { + master: master, limiter: limiter, analyser: analyser, climateIn: climateIn, + age: age, tilt: tilt, drive: drive, post: post, dry: dry, wet: wet, verb: verb, + veins: veins, strikeBus: strikeBus, hiss: hissGain + }; + } + + const macros = { warmth: 0.58, dust: 0.34, threshold: 0.72 }; + const G = graph(ctx); + const freqData = new Uint8Array(G.analyser.frequencyBinCount); + const timeData = new Uint8Array(G.analyser.fftSize); + + function applyClimate(target, w) { + const t = target.ctx ? target.ctx.currentTime : 0; + target.age.frequency.setTargetAtTime(650 + (1 - w.age) * 11500, t, 0.08); + target.drive.gain.setTargetAtTime(1 + w.pressure * 4.2, t, 0.08); + target.post.gain.setTargetAtTime(0.72 / (1 + w.pressure * 1.1), t, 0.08); + target.wet.gain.setTargetAtTime(0.035 + w.air * 0.52, t, 0.08); + target.dry.gain.setTargetAtTime(1 - w.air * 0.28, t, 0.08); + } + G.ctx = ctx; + applyClimate(G, E.weather); + + E.setMacro = function (name, value) { + macros[name] = clamp(+value, 0, 1); + if (name === 'warmth') G.tilt.gain.setTargetAtTime((macros.warmth - 0.5) * 10, ctx.currentTime, 0.06); + if (name === 'dust') G.hiss.gain.setTargetAtTime(macros.dust * 0.03, ctx.currentTime, 0.1); + if (name === 'threshold') G.limiter.threshold.setTargetAtTime(-20 + macros.threshold * 20, ctx.currentTime, 0.1); + }; + E.getMacro = function (name) { return macros[name]; }; + + /* ————— sound of the material ————— */ + + function synth(kind, seed) { + const rnd = mulberry(seed * 131 + 7); + const dur = kind === 'room' ? 15 : kind === 'rail' ? 12 : kind === 'rain' ? 7.6 : 9.6; + const n = Math.floor(dur * sr); + const buf = ctx.createBuffer(1, n, sr); + const d = buf.getChannelData(0); + let peak = 0; + + if (kind === 'stair') { + let lp = 0; + for (let i = 0; i < n; i++) { + const w = Math.random() * 2 - 1; + lp += (w - lp) * 0.012; + d[i] = lp * 0.9; + peak = Math.max(peak, Math.abs(d[i])); + } + const hits = 11 + Math.floor(rnd() * 5); + for (let k = 0; k < hits; k++) { + const at = Math.floor((0.04 + rnd() * 0.88) * n); + const f = 46 + rnd() * 46; + const amp = 0.26 + rnd() * 0.34; + const dec = 0.28 + rnd() * 0.75; + const max = Math.min(n - at, Math.floor(dec * 3 * sr)); + for (let i = 0; i < max; i++) { + const e = Math.exp(-i / (dec * sr)); + d[at + i] += Math.sin(TAU() * f * i / sr) * amp * e; + } + const tn = Math.min(n - at, Math.floor(0.05 * sr)); + let nlp = 0; + for (let i = 0; i < tn; i++) { + const w = Math.random() * 2 - 1; + nlp += (w - nlp) * 0.2; + d[at + i] += nlp * 0.32 * Math.exp(-i / (0.018 * sr)); + } + } + } else if (kind === 'rain') { + let lp = 0; + for (let i = 0; i < n; i++) { + const w = Math.random() * 2 - 1; + lp += (w - lp) * 0.52; + d[i] = (w - lp) * 0.018; + } + const ticks = 640; + for (let k = 0; k < ticks; k++) { + const at = Math.floor(rnd() * (n - 800)); + const amp = 0.04 + rnd() * 0.2; + const len = 40 + Math.floor(rnd() * 260); + for (let i = 0; i < len; i++) { + d[at + i] += (Math.random() * 2 - 1) * amp * Math.exp(-i / (0.0022 * sr)); + } + } + for (let k = 0; k < 14; k++) { + const at = Math.floor(rnd() * (n - sr)); + const f = 900 + rnd() * 2800; + const amp = 0.025 + rnd() * 0.045; + const dec = 0.16 + rnd() * 0.4; + for (let i = 0; i < Math.min(n - at, Math.floor(dec * 3 * sr)); i++) { + d[at + i] += Math.sin(TAU() * f * i / sr) * amp * Math.exp(-i / (dec * sr)); + } + } + } else if (kind === 'room') { + let brown = 0; + for (let i = 0; i < n; i++) { + const w = Math.random() * 2 - 1; + brown = (brown + w * 0.02) * 0.999; + const swell = 0.55 + 0.45 * Math.sin(TAU() * 0.055 * i / sr + seed); + d[i] = brown * 5.2 * swell + Math.sin(TAU() * 41 * i / sr) * 0.012; + } + } else { + let lp = 0; + for (let i = 0; i < n; i++) { + const w = Math.random() * 2 - 1; + lp += (w - lp) * 0.006; + d[i] = lp * 1.7; + } + const squeals = 3 + Math.floor(rnd() * 3); + for (let k = 0; k < squeals; k++) { + const at = Math.floor((0.1 + rnd() * 0.75) * n); + const f0 = 320 + rnd() * 280; + const f1 = f0 * (1.15 + rnd() * 0.5); + const amp = 0.03 + rnd() * 0.035; + const len = Math.min(n - at, Math.floor((0.9 + rnd() * 1.4) * sr)); + let ph = 0; + for (let i = 0; i < len; i++) { + const u = i / len; + const f = f0 + (f1 - f0) * u; + ph += TAU() * f / sr; + d[at + i] += Math.sin(ph) * amp * Math.sin(Math.PI * Math.pow(u, 0.6)) * Math.exp(-u * 1.3); + } + } + for (let k = 0; k < 4; k++) { + const at = Math.floor(rnd() * 0.9 * n); + const f = 38 + rnd() * 30; + for (let i = 0; i < Math.min(n - at, Math.floor(1.2 * sr)); i++) { + d[at + i] += Math.sin(TAU() * f * i / sr) * 0.16 * Math.exp(-i / (0.4 * sr)); + } + } + } + + for (let i = 0; i < n; i++) peak = Math.max(peak, Math.abs(d[i])); + const norm = 0.58 / (peak || 1); + const fade = Math.floor(0.03 * sr); + for (let i = 0; i < n; i++) { + let g = norm; + if (i < fade) g *= i / fade; + if (i > n - fade) g *= (n - i) / fade; + d[i] *= g; + } + return buf; + } + + function TAU() { return Math.PI * 2; } + + function envelope(buffer, buckets) { + const ch = buffer.getChannelData(0); + const n = ch.length; + const env = new Float32Array(buckets); + const step = n / buckets; + for (let i = 0; i < buckets; i++) { + let m = 0; + const from = Math.floor(i * step); + const to = Math.min(n, Math.floor((i + 1) * step)); + for (let k = from; k < to; k += 2) { + const v = Math.abs(ch[k]); + if (v > m) m = v; + } + env[i] = m; + } + const mx = env.reduce(function (a, b) { return Math.max(a, b); }, 0.0001); + for (let i = 0; i < buckets; i++) env[i] = Math.min(1, env[i] / mx); + return env; + } + + function rmsOf(buffer, startSec, lenSec) { + const ch = buffer.getChannelData(0); + const a = Math.floor(startSec * buffer.sampleRate); + const b = Math.min(ch.length, Math.floor((startSec + lenSec) * buffer.sampleRate)); + let s = 0, c = 0; + for (let i = a; i < b; i += 3) { s += ch[i] * ch[i]; c++; } + return c ? Math.sqrt(s / c) : 0; + } + + function makeFaces(buffer, variant) { + const total = buffer.duration; + const env = envelope(buffer, 400); + const bounds = [0]; + for (let i = 1; i < 16; i++) { + const base = Math.floor((i / 16) * 400); + const span = 10; + let best = base, bestV = Infinity; + for (let k = Math.max(1, base - span); k < Math.min(399, base + span); k++) { + if (env[k] < bestV) { bestV = env[k]; best = k; } + } + bounds.push(best / 400); + } + if (variant) { + const rnd = mulberry(variant * 997 + 13); + for (let i = 1; i < 16; i++) { + const shift = (rnd() - 0.5) * 0.045; + bounds[i] = clamp(bounds[i] + shift, bounds[i - 1] + 0.004, 0.995); + } + } + bounds.push(1); + const faces = []; + for (let i = 0; i < 16; i++) { + const s = bounds[i] * total; + const e = bounds[i + 1] * total; + faces.push({ start: s, len: Math.max(0.03, e - s), rms: rmsOf(buffer, s, Math.max(0.03, e - s)) }); + } + const ranked = faces.map(function (f, i) { return i; }).sort(function (a, b) { return faces[b].rms - faces[a].rms; }); + const cuts = [faces[5].start, faces[10].start, faces[14].start]; + return { faces: faces, ranked: ranked, cuts: cuts }; + } + + function addCore(opts) { + const buffer = opts.buffer || synth(opts.kind || 'stair', opts.seed || 1); + const met = makeFaces(buffer, opts.variant || 0); + const core = { + id: opts.id || 'core-' + (E.cores.length + 1), + name: opts.name || 'untitled core', + seal: opts.seal || 'taken', + kind: opts.kind || 'stair', + seed: opts.seed || 1, + dur: buffer.duration, + buffer: buffer, + env: envelope(buffer, 220), + faces: met.faces, + ranked: met.ranked, + cuts: met.cuts + }; + E.cores.push(core); + E.listeners.cores.forEach(function (fn) { fn(core); }); + return core; + } + + function rankFace(coreId, rank) { + const c = byId(coreId); + if (!c) return 0; + return c.ranked[clamp(rank | 0, 0, 15)]; + } + + function byId(id) { + for (let i = 0; i < E.cores.length; i++) if (E.cores[i].id === id) return E.cores[i]; + return null; + } + + /* ————— voices ————— */ + + const voices = []; + + function playSlice(core, face, opts) { + if (!core || !face) return; + const when = opts.when || (ctx.currentTime + 0.01); + const pitch = opts.pitch || 0; + const rate = Math.pow(2, pitch / 12) * (1 + E.weather.heat * 0.03); + const vol = clamp(opts.gain == null ? 0.8 : opts.gain, 0, 2); + const dur = Math.max(0.04, face.len / rate); + const src = ctx.createBufferSource(); + src.buffer = core.buffer; + src.playbackRate.value = rate; + const g = ctx.createGain(); + const atk = 0.004; + const rel = Math.min(0.03, dur * 0.3); + g.gain.setValueAtTime(0, when); + g.gain.linearRampToValueAtTime(vol, when + atk); + g.gain.setValueAtTime(vol, Math.max(when + atk, when + dur - rel)); + g.gain.linearRampToValueAtTime(0, when + dur); + src.connect(g); + const target = opts.bus || 0; + if (opts.bus) g.connect(opts.bus); + else g.connect(G.veins[clamp(target, 0, 4)] || G.veins[0]); + src.start(when, clamp(face.start, 0, core.dur - 0.01)); + src.stop(when + dur + 0.01); + const voice = { src: src, g: g, end: when + dur }; + voices.push(voice); + src.onended = function () { + const i = voices.indexOf(voice); + if (i >= 0) voices.splice(i, 1); + }; + if (voices.length > 30) { + const old = voices.shift(); + try { + old.g.gain.cancelScheduledValues(ctx.currentTime); + old.g.gain.setTargetAtTime(0, ctx.currentTime, 0.01); + old.src.stop(ctx.currentTime + 0.05); + } catch (e) {} + } + } + + function strike(coreId, faceIdx, opts) { + opts = opts || {}; + resume(); + const core = byId(coreId); + if (!core) return; + const face = core.faces[faceIdx]; + if (!face) return; + playSlice(core, face, { + pitch: opts.pitch || 0, + gain: opts.gain || 0.9, + bus: G.strikeBus + }); + if (opts.ghost) { + const when = ctx.currentTime + 0.02; + const src = ctx.createBufferSource(); + src.buffer = core.buffer; + src.playbackRate.value = Math.pow(2, (opts.pitch || 0) / 12) * 0.5; + const f = ctx.createBiquadFilter(); + f.type = 'lowpass'; + f.frequency.value = 900; + const g = ctx.createGain(); + const dur = Math.min(1.2, face.len * 2.4); + g.gain.setValueAtTime(0, when); + g.gain.linearRampToValueAtTime(0.09, when + 0.05); + g.gain.exponentialRampToValueAtTime(0.001, when + dur); + src.connect(f); f.connect(g); g.connect(G.strikeBus); + src.start(when, clamp(face.start, 0, core.dur - 0.01)); + src.stop(when + dur + 0.05); + } + } + + function stopVoices() { + const now = ctx.currentTime; + voices.splice(0).forEach(function (v) { + try { + v.g.gain.cancelScheduledValues(now); + v.g.gain.setTargetAtTime(0, now, 0.015); + v.src.stop(now + 0.08); + } catch (e) {} + }); + } + + /* ————— song & scheduler ————— */ + + let events = []; + let pass = 0, cursor = 0, nextTime = Infinity, timer = null; + let transportStart = 0; + let pausedPos = 7.5; + + function barSec() { return 60 / E.song.bpm * 4; } + function bps() { return E.song.bpm / 60 / 4; } + function loopDur() { return E.song.bars * barSec(); } + + function compile() { + events = []; + E.song.stones.forEach(function (s) { + const core = byId(s.coreId); + if (!core) return; + const face = core.faces[s.face | 0]; + if (!face) return; + const a = clamp(+s.a || 1, 1, E.song.bars); + const b = clamp(+s.b || a + 1, a + 0.01, E.song.bars); + const ring = Math.max(0, +s.ring || 0); + const pitch = +s.pitch || 0; + const gain = s.gain == null ? 0.8 : +s.gain; + const vein = clamp(+s.vein || 0, 0, 4); + if (ring > 0) { + let cycle = 0; + const endBar = b >= E.song.bars - 1e-6 ? b + 1e-6 : b - 1e-6; + for (let bar = a; bar < endBar; bar += ring) { + events.push({ bar: bar, core: core, face: face, pitch: pitch, gain: gain, vein: vein, cycle: cycle++ }); + } + } else { + events.push({ bar: a, core: core, face: face, pitch: pitch, gain: gain, vein: vein, cycle: 0 }); + } + }); + events.sort(function (p, q) { return p.bar - q.bar; }); + } + + function resetScheduler() { + pass = 0; cursor = 0; + nextTime = events.length + ? transportStart + (events[0].bar - 1) * barSec() + : Infinity; + } + + function tick() { + if (!E.playing || !events.length) return; + const now = ctx.currentTime; + const until = now + 0.18; + let guard = 0; + while (nextTime < until && guard++ < 800) { + if (nextTime >= now - 0.02) { + const ev = events[cursor]; + const growth = 1 + ev.cycle * 0.0006 * E.rings; + playSlice(ev.core, ev.face, { + when: nextTime, + pitch: ev.pitch + ev.cycle * 0.01 * E.rings, + gain: ev.gain * (1 - Math.min(0.35, ev.cycle * 0.004 * E.rings)) * growth, + vein: ev.vein + }); + } + cursor++; + if (cursor >= events.length) { cursor = 0; pass++; } + nextTime = transportStart + pass * loopDur() + (events[cursor].bar - 1) * barSec(); + } + } + + function resume() { + if (ctx.state === 'suspended') ctx.resume(); + } + + E.play = function () { + resume(); + if (E.playing) return; + E.playing = true; + transportStart = ctx.currentTime - (pausedPos - 1) / bps() + 0.06; + resetScheduler(); + clearInterval(timer); + timer = setInterval(tick, 25); + tick(); + }; + + E.pause = function () { + if (!E.playing) return; + pausedPos = E.getPos(); + E.playing = false; + clearInterval(timer); + timer = null; + stopVoices(); + }; + + E.stop = function () { + E.playing = false; + clearInterval(timer); + timer = null; + pausedPos = 1; + stopVoices(); + }; + + E.seek = function (pos) { + pausedPos = clamp(pos, 1, E.song.bars); + if (E.playing) { + stopVoices(); + transportStart = ctx.currentTime - (pausedPos - 1) / bps() + 0.04; + resetScheduler(); + } + }; + + E.getPos = function () { + if (!E.playing) return pausedPos; + const p = 1 + (ctx.currentTime - transportStart) * bps(); + const loop = E.song.bars; + return ((p - 1) % loop + loop) % loop + 1; + }; + + E.setBpm = function (bpm) { + const pos = E.getPos(); + E.song.bpm = clamp(bpm, 40, 220); + if (E.playing) { + transportStart = ctx.currentTime - (pos - 1) / bps(); + resetScheduler(); + } + pausedPos = pos; + }; + + E.setSong = function (stones) { + E.song.stones = (stones || []).slice(); + compile(); + if (E.playing) { + transportStart = ctx.currentTime - (E.getPos() - 1) / bps(); + resetScheduler(); + } + }; + + E.setWeather = function (w) { + Object.assign(E.weather, w || {}); + applyClimate(G, E.weather); + }; + + E.setRings = function (n) { E.rings = clamp(n, 0, 24); }; + + E.setVeinGain = function (i, v) { + const g = G.veins[clamp(i, 0, 4)]; + if (g) g.gain.setTargetAtTime(clamp(v, 0, 1.4), ctx.currentTime, 0.03); + }; + + E.setMuted = function (muted) { + E.muted = !!muted; + G.master.gain.setTargetAtTime(E.muted ? 0 : 0.85, ctx.currentTime, 0.08); + }; + + E.getMuted = function () { return E.muted; }; + E.isPlaying = function () { return E.playing; }; + E.getBpm = function () { return E.song.bpm; }; + E.getEvents = function () { return events; }; + E.pausedPos = function () { return pausedPos; }; + + /* ————— analysis ————— */ + + E.strike = strike; + + E.read = function () { + G.analyser.getByteFrequencyData(freqData); + G.analyser.getByteTimeDomainData(timeData); + return { freq: freqData, time: timeData, sr: sr }; + }; + + /* ————— cores ————— */ + + E.byId = byId; + E.rankFace = rankFace; + E.refracture = function (coreId) { + const core = byId(coreId); + if (!core) return null; + core.variant = (core.variant || 0) + 1; + const met = makeFaces(core.buffer, core.variant); + core.faces = met.faces; + core.ranked = met.ranked; + core.cuts = met.cuts; + compile(); + if (E.playing) { + transportStart = ctx.currentTime - (E.getPos() - 1) / bps(); + resetScheduler(); + } + return core; + }; + + E.newCore = function (opts) { + opts = opts || {}; + const kinds = ['stair', 'rain', 'room', 'rail']; + const kind = opts.kind || kinds[E.cores.length % kinds.length]; + return addCore({ + name: opts.name || kind + ' core ' + (E.cores.length + 1), + seal: opts.seal || 'taken', + kind: kind, + seed: opts.seed || (Math.floor(Math.random() * 900) + 17), + id: opts.id + }); + }; + + E.addBufferCore = function (opts) { + return addCore({ + name: opts.name, + seal: opts.seal || 'taken', + kind: 'import', + buffer: opts.buffer, + id: opts.id + }); + }; + + E.onCores = function (fn) { E.listeners.cores.push(fn); }; + E.onLog = function (fn) { E.listeners.log.push(fn); }; + + E.addLog = function (tool, detail, seal, by) { + E.log.unshift({ t: nowTime(), tool: tool, detail: detail, seal: seal || '', by: by || 'hand' }); + if (E.log.length > 40) E.log.pop(); + E.listeners.log.forEach(function (fn) { fn(E.log); }); + }; + + function nowTime() { + const d = new Date(); + return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); + } + + /* ————— import & record ————— */ + + E.importFile = function (file) { + return file.arrayBuffer().then(function (ab) { + return ctx.decodeAudioData(ab); + }).then(function (buffer) { + const name = file.name.replace(/\.[a-z0-9]+$/i, '').slice(0, 34) || 'imported'; + const core = addCore({ name: name, seal: 'taken', kind: 'import', buffer: buffer }); + E.addLog('cores.extract', name, 'taken', 'hand'); + return core; + }); + }; + + E.recordRoom = function (seconds) { + seconds = seconds || 6; + return navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) { + return new Promise(function (resolve, reject) { + const types = ['audio/webm;codecs=opus', 'audio/webm', '']; + let rec = null; + for (let i = 0; i < types.length; i++) { + try { rec = new MediaRecorder(stream, types[i] ? { mimeType: types[i] } : undefined); break; } catch (e) {} + } + if (!rec) { stream.getTracks().forEach(function (t) { t.stop(); }); reject(new Error('no recorder')); return; } + const chunks = []; + rec.ondataavailable = function (e) { if (e.data && e.data.size) chunks.push(e.data); }; + rec.onstop = function () { + stream.getTracks().forEach(function (t) { t.stop(); }); + const blob = new Blob(chunks, { type: chunks[0] ? chunks[0].type : 'audio/webm' }); + blob.arrayBuffer().then(function (ab) { return ctx.decodeAudioData(ab); }).then(function (buffer) { + const name = 'room, ' + nowTime(); + const core = addCore({ name: name, seal: 'taken', kind: 'import', buffer: buffer }); + E.addLog('cores.extract', name, 'taken', 'hand'); + resolve(core); + }).catch(reject); + }; + rec.start(); + setTimeout(function () { if (rec.state !== 'inactive') rec.stop(); }, seconds * 1000); + }); + }); + }; + + /* ————— render ————— */ + + function encodeWav(buffer) { + const channels = Math.min(2, buffer.numberOfChannels); + const len = buffer.length; + const bytes = 44 + len * channels * 2; + const ab = new ArrayBuffer(bytes); + const view = new DataView(ab); + function str(off, s) { for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); } + str(0, 'RIFF'); + view.setUint32(4, bytes - 8, true); + str(8, 'WAVE'); + str(12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, channels, true); + view.setUint32(24, buffer.sampleRate, true); + view.setUint32(28, buffer.sampleRate * channels * 2, true); + view.setUint16(32, channels * 2, true); + view.setUint16(34, 16, true); + str(36, 'data'); + view.setUint32(40, len * channels * 2, true); + const chans = []; + for (let c = 0; c < channels; c++) chans.push(buffer.getChannelData(c)); + let off = 44; + for (let i = 0; i < len; i++) { + for (let c = 0; c < channels; c++) { + const s = clamp(chans[c][i], -1, 1); + view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true); + off += 2; + } + } + return new Blob([ab], { type: 'audio/wav' }); + } + + E.render = function () { + const duration = loopDur() + 2.4; + const off = new OfflineAudioContext(2, Math.ceil(duration * sr), sr); + const go = graph(off); + go.ctx = off; + Object.assign(go, { ctx: off }); + applyClimate(go, E.weather); + go.tilt.gain.value = (macros.warmth - 0.5) * 10; + go.hiss.gain.value = macros.dust * 0.03; + go.limiter.threshold.value = -20 + macros.threshold * 20; + for (let i = 0; i < 5; i++) { + go.veins[i].gain.value = G.veins[i].gain.value; + } + off.destination.channelCount = 2; + const startAt = off.currentTime; + events.forEach(function (ev) { + const when = startAt + (ev.bar - 1) * barSec() + 0.01; + const rate = Math.pow(2, (ev.pitch + ev.cycle * 0.01 * E.rings) / 12) * (1 + E.weather.heat * 0.03); + const dur = Math.max(0.04, ev.face.len / rate); + const src = off.createBufferSource(); + src.buffer = ev.core.buffer; + src.playbackRate.value = rate; + const g = off.createGain(); + const vol = clamp(ev.gain * (1 - Math.min(0.35, ev.cycle * 0.004 * E.rings)), 0, 2); + g.gain.setValueAtTime(0, when); + g.gain.linearRampToValueAtTime(vol, when + 0.004); + g.gain.setValueAtTime(vol, Math.max(when + 0.004, when + dur - 0.03)); + g.gain.linearRampToValueAtTime(0, when + dur); + src.connect(g); + g.connect(go.veins[ev.vein] || go.veins[0]); + src.start(when, clamp(ev.face.start, 0, ev.core.dur - 0.01)); + src.stop(when + dur + 0.01); + }); + return off.startRendering().then(function (buf) { + return encodeWav(buf); + }); + }; + + E.download = function () { + return E.render().then(function (blob) { + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'zhila-descent-04.wav'; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function () { URL.revokeObjectURL(a.href); }, 20000); + return blob; + }); + }; + + /* ————— default cores ————— */ + + addCore({ id: 'stairwell-03', name: 'stairwell, 3rd floor', seal: 'taken', kind: 'stair', seed: 3 }); + addCore({ id: 'rain-metal', name: 'rain on metal', seal: 'found', kind: 'rain', seed: 7 }); + addCore({ id: 'room-04', name: 'room tone 04', seal: 'gift', kind: 'room', seed: 11 }); + addCore({ id: 'rail-02', name: 'rail, night', seal: 'loan', kind: 'rail', seed: 5 }); + + E.log = [ + { t: '21:52', tool: 'cores.extract', detail: 'stairwell, 3rd floor', seal: 'taken', by: 'hand' }, + { t: '21:53', tool: 'faces.fracture', detail: 'stairwell-03 · 16 faces', seal: '', by: 'hand' }, + { t: '21:55', tool: 'rings.grow', detail: 'ring 04 · young', seal: '', by: 'mcp' } + ]; + + window.ZHILA_ENGINE = E; +})(); diff --git a/reference/demo/index.html b/reference/demo/index.html new file mode 100644 index 0000000..608553f --- /dev/null +++ b/reference/demo/index.html @@ -0,0 +1,285 @@ + + + + + +Zhila · descent 04 + + + + + + + + + + + + + + + + +
+ +
+ ZHILA0.4 +
+ descent 04 + + A minor4/45 veins +
+
+ + + +
+
+ + + +
+
+ + api + + connecting +
+
+ +
+
+
+ + +
+
+ + + +
+

⏎ call space play

+

drag cilia click the nave

+
+
+ +
+ + + +
+
+

nave

+ 5 veinsbar 7.3 +
+
+ + + +
+
+ + + +
+ +
+
+
+

core tray

+ stairwell, 3rd floor +
+
+ taken + + 12.4 s + + 0.84 s + + +
+
+
+ + + +
+
+ +
+ + + +
+
+

the face

+ 16 faces +
+
+
+ strike a stone + + + + +
+
+ + + +
+
+ ledger + the memory of taking +
+
    +
    + +
    + +
    + +
    + + + +
    + +
    + bar + 7.3 + 00:12.66 +
    + +
    + tempo +
    + + 4/4A minor +
    +
    + +
    +
    warmth
    +
    dust
    +
    threshold
    +
    + +
    + extract +
    + + + +
    +
    + +
    + signal + +
    + +
    +
    + master + −∞ dB + +
    + +
    + +
    + + +
    +
    + +
    + + + + + + + + diff --git a/reference/demo/preview-agent.png b/reference/demo/preview-agent.png new file mode 100644 index 0000000..05a6a35 Binary files /dev/null and b/reference/demo/preview-agent.png differ diff --git a/reference/demo/preview-mobile.png b/reference/demo/preview-mobile.png new file mode 100644 index 0000000..ffc10fe Binary files /dev/null and b/reference/demo/preview-mobile.png differ diff --git a/reference/demo/preview-playing.png b/reference/demo/preview-playing.png new file mode 100644 index 0000000..b7fae38 Binary files /dev/null and b/reference/demo/preview-playing.png differ diff --git a/reference/demo/preview-quarry-mobile.png b/reference/demo/preview-quarry-mobile.png new file mode 100644 index 0000000..5f1ce1f Binary files /dev/null and b/reference/demo/preview-quarry-mobile.png differ diff --git a/reference/demo/preview-quarry.png b/reference/demo/preview-quarry.png new file mode 100644 index 0000000..06a1dc8 Binary files /dev/null and b/reference/demo/preview-quarry.png differ diff --git a/reference/demo/preview.mp3 b/reference/demo/preview.mp3 new file mode 100644 index 0000000..6fd9625 Binary files /dev/null and b/reference/demo/preview.mp3 differ diff --git a/reference/demo/preview.png b/reference/demo/preview.png new file mode 100644 index 0000000..a211c88 Binary files /dev/null and b/reference/demo/preview.png differ diff --git a/reference/demo/quarry.js b/reference/demo/quarry.js new file mode 100644 index 0000000..19ef873 --- /dev/null +++ b/reference/demo/quarry.js @@ -0,0 +1,956 @@ +/* ZHILA · the quarry + a core is a place, a face is a cut, a ring is a year. + the choir goes out and takes; the hand cuts and sets. */ + +(function () { + 'use strict'; + + const $ = (s, r = document) => r.querySelector(s); + const $$ = (s, r = document) => Array.prototype.slice.call(r.querySelectorAll(s)); + const clamp = (v, a, b) => v < a ? a : v > b ? b : v; + const TAU = Math.PI * 2; + const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + + const tray = $('#tray'); + const tctx = tray.getContext('2d'); + const overlay = $('#tray-overlay'); + const stage = $('#tray-stage'); + const wall = $('#face-wall'); + const storeEl = $('#store'); + const ledgerEl = $('#ledger'); + + const E = window.ZHILA_ENGINE; + const cores = E ? E.cores : []; + const SEMIS = [-12, -9, -7, -5, 0, 2, 3, 5, 7, 8, 10, 12, 14, 15, 17, 19]; + + const state = { + sel: 0, + lens: 0.84, + fracture: 0, + faceSeed: 7, + selFace: -1, + rings: 4, + ghost: true, + weather: { age: 0.34, pressure: 0.52, air: 0.41, heat: 0.28 } + }; + + const HAND_BANK = ['underpass, 2am', 'wet brick', 'empty pool', 'ventilation shaft', 'far room', 'snow on tin', 'lift cable']; + let handBank = HAND_BANK.slice(); + let lensLine = null, lensHandle = null, lensText = null; + + /* ————— cores ————— */ + + function mulberry(seed) { + let a = seed | 0; + return function () { + a = a + 0x6D2B79F5 | 0; + let t = Math.imul(a ^ a >>> 15, 1 | a); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; + } + + /* ————— ledger ————— */ + + let entries = E ? E.log : []; + + function sealHTML(kind, small) { + if (!kind) return ''; + return '' + kind + ''; + } + + function renderLedger() { + entries = E ? E.log : entries; + ledgerEl.innerHTML = entries.slice(0, 3).map(function (e) { + return '
  1. ' + + '
    ' + e.t + '' + + '' + esc(e.tool) + '' + + '' + e.by + '
    ' + + '
    ' + esc(e.detail) + ' ' + sealHTML(e.seal) + '
    ' + + '
  2. '; + }).join(''); + } + + function nowTime() { + const d = new Date(); + return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); + } + + function log(tool, detail, seal, by) { + if (E) E.addLog(tool, detail, seal, by); + } + + /* ————— core store ————— */ + + function drawThumb(canvas, core) { + const g = canvas.getContext('2d'); + const W = canvas.width, H = canvas.height; + g.clearRect(0, 0, W, H); + const x = W * 0.18, y = H * 0.04, w = W * 0.64, h = H * 0.92, r = w / 2; + const p = new Path2D(); + p.moveTo(x + r, y); + p.lineTo(x + w - r, y); + p.arc(x + w - r, y + r, r, -Math.PI / 2, Math.PI / 2); + p.lineTo(x + r, y + h); + p.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI * 1.5); + p.closePath(); + + g.save(); + g.clip(p); + const grd = g.createLinearGradient(x, y, x + w, y); + grd.addColorStop(0, '#1a0d0a'); + grd.addColorStop(0.45, '#2b1611'); + grd.addColorStop(1, '#0b0605'); + g.fillStyle = grd; + g.fillRect(x, y, w, h); + + const rnd = mulberry(core.seed * 31 + 3); + for (let i = 0; i < 8; i++) { + const yy = y + h * ((i + 0.6) / 8.6); + const amp = 1 + rnd() * 3; + g.beginPath(); + for (let k = 0; k <= 10; k++) { + const u = k / 10; + const px = x + u * w; + const py = yy + Math.sin(u * 6 + i) * amp; + if (k === 0) g.moveTo(px, py); else g.lineTo(px, py); + } + g.strokeStyle = i % 2 ? 'rgba(190,130,105,.16)' : 'rgba(80,45,35,.5)'; + g.lineWidth = 1; + g.stroke(); + } + // the seam of sound + g.beginPath(); + for (let i = 0; i < core.env.length; i++) { + const px = x + (i / (core.env.length - 1)) * w; + const py = y + h / 2 - core.env[i] * h * 0.3; + if (i === 0) g.moveTo(px, py); else g.lineTo(px, py); + } + g.strokeStyle = 'rgba(255,105,75,.55)'; + g.lineWidth = 1; + g.stroke(); + g.restore(); + + g.strokeStyle = 'rgba(239,228,212,.16)'; + g.lineWidth = 1; + g.stroke(p); + g.beginPath(); + g.ellipse(x + r, y + h / 2, r * 0.55, h * 0.3, 0, 0, TAU); + g.strokeStyle = 'rgba(239,228,212,.1)'; + g.stroke(); + } + + function buildStore() { + storeEl.innerHTML = ''; + cores.forEach(function (core, i) { + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'core-item' + (i === state.sel ? ' is-on' : ''); + item.dataset.core = i; + item.innerHTML = + '' + + '' + + '' + esc(core.name) + '' + + '' + core.dur.toFixed(1) + ' s ' + sealHTML(core.seal) + '' + + ''; + storeEl.appendChild(item); + drawThumb(item.querySelector('canvas'), core); + }); + } + + storeEl.addEventListener('click', function (e) { + const item = e.target.closest('.core-item'); + if (!item) return; + selectCore(+item.dataset.core); + }); + + function highlightStore() { + $$('.core-item', storeEl).forEach(function (el, i) { + const on = i === state.sel; + el.classList.toggle('is-on', on); + if (on && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' }); + }); + } + + /* ————— tray ————— */ + + function trayGeom() { + const W = stage.clientWidth, H = stage.clientHeight; + const coreH = clamp(H * 0.55, 96, 172); + const top = Math.max(36, (H - coreH) / 2 - 10); + const pad = 24; + return { W: W, H: H, x0: pad + 10, x1: W - pad - 10, top: top, bottom: top + coreH, coreH: coreH }; + } + + function capsule(g, x0, top, x1, bottom) { + const p = new Path2D(); + if (p.roundRect) p.roundRect(x0, top, x1 - x0, bottom - top, 2.5); + else p.rect(x0, top, x1 - x0, bottom - top); + return p; + } + + function drawTray() { + const g0 = trayGeom(); + if (!g0.W || !g0.H) return; + const dpr = Math.min(2, window.devicePixelRatio || 1); + tray.width = Math.round(g0.W * dpr); + tray.height = Math.round(g0.H * dpr); + tctx.setTransform(dpr, 0, 0, dpr, 0, 0); + tctx.clearRect(0, 0, g0.W, g0.H); + + const core = cores[state.sel]; + const { x0, x1, top, bottom, coreH } = g0; + const mid = (top + bottom) / 2; + const path = capsule(tctx, x0, top, x1, bottom); + const rnd = mulberry(core.seed * 131 + 7); + + // shadow under the core + tctx.save(); + tctx.filter = 'blur(14px)'; + tctx.fillStyle = 'rgba(0,0,0,.75)'; + tctx.beginPath(); + tctx.ellipse((x0 + x1) / 2, bottom + 10, (x1 - x0) * 0.48, coreH * 0.22, 0, 0, TAU); + tctx.fill(); + tctx.restore(); + + tctx.save(); + tctx.clip(path); + const grd = tctx.createLinearGradient(0, top, 0, bottom); + grd.addColorStop(0, '#26130d'); + grd.addColorStop(0.16, '#170b08'); + grd.addColorStop(0.55, '#100705'); + grd.addColorStop(1, '#040202'); + tctx.fillStyle = grd; + tctx.fillRect(x0, top, x1 - x0, coreH); + + // strata: sediment layers + const bands = 10; + for (let i = 0; i < bands; i++) { + const yy = top + coreH * ((i + 0.55) / (bands + 0.9)) + (rnd() - 0.5) * 4; + const amp = 2 + rnd() * 7; + const freq = 1.2 + rnd() * 2.4; + const phase = rnd() * TAU; + const pts = []; + for (let k = 0; k <= 32; k++) { + const u = k / 32; + const px = x0 + u * (x1 - x0); + const py = yy + Math.sin(u * TAU * freq + phase) * amp + Math.sin(u * TAU * 5.3 + phase * 2) * 1.4; + pts.push([px, py]); + } + tctx.beginPath(); + pts.forEach(function (p, k) { if (k === 0) tctx.moveTo(p[0], p[1]); else tctx.lineTo(p[0], p[1]); }); + tctx.lineTo(x1, bottom); + tctx.lineTo(x0, bottom); + tctx.closePath(); + tctx.fillStyle = i % 2 ? 'rgba(150,60,40,.05)' : 'rgba(0,0,0,.18)'; + tctx.fill(); + tctx.beginPath(); + pts.forEach(function (p, k) { if (k === 0) tctx.moveTo(p[0], p[1]); else tctx.lineTo(p[0], p[1]); }); + tctx.strokeStyle = i % 3 === 0 ? 'rgba(232,64,42,.16)' : 'rgba(190,130,105,.1)'; + tctx.lineWidth = 0.8; + tctx.stroke(); + } + + // the seam of sound, inside the stone + const env = core.env; + const ampY = coreH * 0.3; + if (state.ghost) { + tctx.beginPath(); + for (let i = 0; i < env.length; i++) { + const px = x0 + (i / (env.length - 1)) * (x1 - x0); + const py = mid - clamp(env[i] * 1.45, 0, 1) * ampY * 1.25; + if (i === 0) tctx.moveTo(px, py); else tctx.lineTo(px, py); + } + tctx.strokeStyle = 'rgba(232,64,42,.14)'; + tctx.lineWidth = 0.9; + tctx.stroke(); + } + tctx.beginPath(); + for (let i = 0; i < env.length; i++) { + const px = x0 + (i / (env.length - 1)) * (x1 - x0); + const py = mid - env[i] * ampY; + if (i === 0) tctx.moveTo(px, py); else tctx.lineTo(px, py); + } + for (let i = env.length - 1; i >= 0; i--) { + const px = x0 + (i / (env.length - 1)) * (x1 - x0); + tctx.lineTo(px, mid + env[i] * ampY * 0.78); + } + tctx.closePath(); + tctx.fillStyle = 'rgba(232,64,42,.13)'; + tctx.fill(); + tctx.save(); + tctx.shadowColor = 'rgba(232,64,42,.75)'; + tctx.shadowBlur = 14; + tctx.beginPath(); + for (let i = 0; i < env.length; i++) { + const px = x0 + (i / (env.length - 1)) * (x1 - x0); + const py = mid - env[i] * ampY; + if (i === 0) tctx.moveTo(px, py); else tctx.lineTo(px, py); + } + tctx.strokeStyle = 'rgba(255,140,105,.95)'; + tctx.lineWidth = 1.5; + tctx.stroke(); + tctx.restore(); + + // pits and hairlines in the stone + for (let k = 0; k < 16; k++) { + const px = x0 + rnd() * (x1 - x0); + const py = top + rnd() * coreH; + const rr = 0.7 + rnd() * 2.2; + tctx.beginPath(); + tctx.ellipse(px, py, rr * 1.9, rr, 0, 0, TAU); + tctx.fillStyle = 'rgba(0,0,0,.4)'; + tctx.fill(); + tctx.beginPath(); + tctx.ellipse(px, py + 0.7, rr * 1.5, rr * 0.55, 0, 0, TAU); + tctx.strokeStyle = 'rgba(232,64,42,.16)'; + tctx.lineWidth = 0.6; + tctx.stroke(); + } + for (let k = 0; k < 3; k++) { + let cx = x0 + (0.12 + rnd() * 0.74) * (x1 - x0); + let cy = top + rnd() * coreH * 0.55; + tctx.beginPath(); + tctx.moveTo(cx, cy); + for (let s2 = 0; s2 < 6; s2++) { + cx += (rnd() - 0.5) * 12; + cy += 4 + rnd() * 7; + tctx.lineTo(cx, cy); + } + tctx.strokeStyle = 'rgba(0,0,0,.55)'; + tctx.lineWidth = 1.2; + tctx.stroke(); + tctx.strokeStyle = 'rgba(232,64,42,.15)'; + tctx.lineWidth = 0.5; + tctx.stroke(); + } + + // fractures, gashes in the core + core.cuts.forEach(function (cut) { + const x = x0 + clamp(cut / core.dur, 0, 1) * (x1 - x0); + const jag = mulberry(Math.round(cut * 100) + core.seed); + const pts = []; + for (let y = top; y <= bottom; y += 7) pts.push([x + (jag() - 0.5) * 3.2, y]); + tctx.beginPath(); + pts.forEach(function (p, k) { if (k === 0) tctx.moveTo(p[0], p[1]); else tctx.lineTo(p[0], p[1]); }); + tctx.strokeStyle = 'rgba(0,0,0,.8)'; + tctx.lineWidth = 5; + tctx.stroke(); + tctx.strokeStyle = 'rgba(255,110,80,.62)'; + tctx.lineWidth = 1.15; + tctx.stroke(); + }); + + // surface light + const hi = tctx.createLinearGradient(0, top, 0, top + coreH * 0.3); + hi.addColorStop(0, 'rgba(255,205,175,.1)'); + hi.addColorStop(1, 'rgba(255,205,175,0)'); + tctx.fillStyle = hi; + tctx.fillRect(x0, top, x1 - x0, coreH * 0.3); + for (let k = 0; k < 6; k++) { + const cx = x0 + 8 + rnd() * (x1 - x0 - 16); + const w = 3 + rnd() * 11; + const d = 1 + rnd() * 3.4; + tctx.beginPath(); + tctx.moveTo(cx - w / 2, top); + tctx.lineTo(cx + w / 2, top); + tctx.lineTo(cx + (rnd() - 0.5) * 4, top + d); + tctx.closePath(); + tctx.fillStyle = 'rgba(2,1,1,.75)'; + tctx.fill(); + } + tctx.restore(); + + tctx.strokeStyle = 'rgba(239,228,212,.2)'; + tctx.lineWidth = 1; + tctx.stroke(path); + + // the cut ends of the bore + [x0 + 6, x1 - 6].forEach(function (cx) { + tctx.beginPath(); + tctx.ellipse(cx, mid, 6, coreH / 2 - 1, 0, 0, TAU); + tctx.fillStyle = 'rgba(3,2,2,.55)'; + tctx.fill(); + tctx.strokeStyle = 'rgba(239,228,212,.2)'; + tctx.lineWidth = 0.9; + tctx.stroke(); + tctx.beginPath(); + tctx.ellipse(cx, mid, 2.4, coreH * 0.34, 0, 0, TAU); + tctx.strokeStyle = 'rgba(239,228,212,.08)'; + tctx.lineWidth = 0.7; + tctx.stroke(); + }); + } + + function buildOverlay() { + const g = trayGeom(); + if (!g.W || !g.H) return; + const core = cores[state.sel]; + const { x0, x1, top, bottom } = g; + let s = ''; + s += ''; + + core.cuts.forEach(function (cut, i) { + const x = x0 + clamp(cut / core.dur, 0, 1) * (x1 - x0); + s += ''; + s += '0' + (i + 1) + ''; + }); + + const ry = bottom + 30; + s += ''; + const step = core.dur > 16 ? 4 : core.dur > 10 ? 2 : 1; + for (let t = 0; t <= core.dur + 0.01; t += step) { + const x = x0 + clamp(t / core.dur, 0, 1) * (x1 - x0); + const major = t % (step * 2) === 0; + s += ''; + if (major) { + s += '' + t.toFixed(0) + 's'; + } + } + + s += '' + + '' + + '' + + '' + + ''; + overlay.setAttribute('viewBox', '0 0 ' + g.W + ' ' + g.H); + overlay.innerHTML = s; + lensLine = overlay.querySelector('.tray-lens'); + lensHandle = overlay.querySelector('.tray-lens-handle'); + lensText = overlay.querySelector('.tray-lens-text'); + updateLensOnly(); + } + + function updateLensOnly() { + if (!lensLine || !lensText) return; + const g = trayGeom(); + if (!g.W) return; + const core = cores[state.sel]; + const lx = g.x0 + clamp(state.lens / core.dur, 0, 1) * (g.x1 - g.x0); + lensLine.setAttribute('x1', lx.toFixed(1)); + lensLine.setAttribute('x2', lx.toFixed(1)); + lensLine.setAttribute('y1', (g.top - 16).toFixed(1)); + lensLine.setAttribute('y2', (g.bottom + 16).toFixed(1)); + lensHandle.setAttribute('x', (lx - 2).toFixed(1)); + lensHandle.setAttribute('y', (g.top - 24).toFixed(1)); + lensText.setAttribute('x', Math.min(lx + 8, g.x1 - 36).toFixed(1)); + lensText.setAttribute('y', (g.top - 12).toFixed(1)); + lensText.textContent = state.lens.toFixed(2) + 's'; + const pos = $('#tray-pos'); + if (pos) pos.textContent = state.lens.toFixed(2) + ' s'; + } + + /* ————— faces ————— */ + + function stoneShape(cell, rnd) { + const inset = 1.4; + const x0 = cell.x + inset, y0 = cell.y + inset; + const x1 = cell.x + cell.w - inset, y1 = cell.y + cell.h - inset; + const corners = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; + const pts = []; + for (let e = 0; e < 4; e++) { + const a = corners[e], b = corners[(e + 1) % 4]; + pts.push([a[0] + (rnd() - 0.5) * 2.5, a[1] + (rnd() - 0.5) * 2.5]); + pts.push([ + (a[0] + b[0]) / 2 + (rnd() - 0.5) * 9, + (a[1] + b[1]) / 2 + (rnd() - 0.5) * 9 + ]); + } + return pts; + } + + function centroid(pts) { + const n = pts.length; + return [pts.reduce((a, p) => a + p[0], 0) / n, pts.reduce((a, p) => a + p[1], 0) / n]; + } + + function ptsStr(pts) { + return pts.map(function (p) { return p[0].toFixed(1) + ',' + p[1].toFixed(1); }).join(' '); + } + + function sealMark(x, y, kind) { + let inner = ''; + if (kind === 'taken') inner = ''; + else if (kind === 'found') inner = ''; + else if (kind === 'loan') inner = ''; + else if (kind === 'gift') inner = ''; + return '' + inner; + } + + function buildFaces(seed) { + if (!wall || !wall.clientWidth || !wall.clientHeight) return; + state.faceSeed = seed; + const W = wall.clientWidth, H = wall.clientHeight; + const core = cores[state.sel]; + const rnd = mulberry(seed); + const rows = 4, gap = 8; + let hs = [0, 0, 0, 0].map(function () { return 0.78 + rnd() * 0.44; }); + const hsum = hs.reduce(function (a, b) { return a + b; }, 0); + const availH = H - gap * (rows - 1) - 2; + let y = 1; + const rowRects = []; + hs.forEach(function (h) { + const hh = availH * h / hsum; + rowRects.push({ y: y, h: hh }); + y += hh + gap; + }); + + const faces = []; + let idx = 0; + let svg = ''; + ['A', 'B', 'C', 'D'].forEach(function (t, ti) { + svg += '' + + '' + + '' + + '' + + ''; + }); + svg += ''; + rowRects.forEach(function (row, r) { + let ws = [0, 0, 0, 0].map(function () { return 0.72 + rnd() * 0.56; }); + const wsum = ws.reduce(function (a, b) { return a + b; }, 0); + const availW = W - gap * 3 - 2; + let x = 1; + ws.forEach(function (w, c) { + const ww = availW * w / wsum; + const cell = { x: x, y: row.y, w: ww, h: row.h }; + const pts = stoneShape(cell, rnd); + const cen = centroid(pts); + const inner = pts.map(function (p) { return [p[0] + (cen[0] - p[0]) * 0.09, p[1] + (cen[1] - p[1]) * 0.09]; }); + const inner2 = pts.map(function (p) { return [p[0] + (cen[0] - p[0]) * 0.2, p[1] + (cen[1] - p[1]) * 0.2]; }); + const tone = ['a', 'b', 'c', 'd'][Math.floor(rnd() * 4)]; + const lip = [pts[7], pts[0], pts[1], pts[2]]; + const foot = [pts[3], pts[4], pts[5], pts[6]]; + const grain = []; + const gy = cell.y + cell.h * 0.58; + for (let k = 0; k <= 8; k++) { + const u = k / 8; + grain.push([cell.x + 7 + u * (cell.w - 14), gy + Math.sin(u * 5 + rnd() * 2) * 3.4]); + } + const grain2 = []; + const gy2 = cell.y + cell.h * 0.79; + for (let k = 0; k <= 6; k++) { + const u = k / 6; + grain2.push([cell.x + 11 + u * (cell.w - 22), gy2 + Math.sin(u * 4 + rnd() * 2) * 2.6]); + } + const crack = []; + let cx = cell.x + cell.w * (0.45 + rnd() * 0.3), cy = cell.y + 6; + crack.push([cx, cy]); + for (let k = 0; k < 4; k++) { + cx += (rnd() - 0.5) * 12; + cy += 4 + rnd() * 8; + crack.push([cx, cy]); + } + const pits = [0, 1, 2].map(function () { + return [cell.x + 12 + rnd() * (cell.w - 24), cell.y + 12 + rnd() * (cell.h - 24), 0.7 + rnd() * 1.3]; + }); + const strata = []; + for (let k = 0; k <= 6; k++) { + const u = k / 6; + strata.push([cell.x + 8 + u * (cell.w - 16), cell.y + cell.h * 0.32 + Math.sin(u * 4 + r) * 2.4]); + } + const num = String(idx + 1).padStart(2, '0'); + const slice = core.faces[idx]; + const face = { + i: idx, + semis: SEMIS[(3 - r) * 4 + c], + seal: core.seal, + t0: slice ? +slice.start.toFixed(2) : 0, + t1: slice ? +(slice.start + slice.len).toFixed(2) : 0 + }; + faces.push(face); + const isSel = state.selFace === idx; + svg += '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + pits.map(function (p) { + return ''; + }).join('') + + '' + num + '' + + sealMark(cell.x + cell.w - 14, cell.y + cell.h - 13, core.seal) + + ''; + idx++; + x += ww + gap; + }); + }); + svg += ''; + wall.innerHTML = svg; + state.faces = faces; + // keep the selection valid + if (state.selFace >= faces.length) state.selFace = -1; + } + + function indexDur(i) { + const core = cores[state.sel]; + return core.dur / 16; + } + + wall.addEventListener('pointerdown', function (e) { + const g = e.target.closest ? e.target.closest('.stone') : null; + if (!g) return; + const i = +g.dataset.i; + if (state.selFace !== i) { + state.selFace = i; + $$('.stone', wall).forEach(function (el) { + el.classList.toggle('is-sel', +el.dataset.i === i); + }); + faceStatus(); + $('#place').disabled = false; + const lb = $('#place-ring'); if (lb) lb.disabled = false; + } + const face = state.faces[i]; + strike(face); + g.classList.remove('is-struck'); + requestAnimationFrame(function () { g.classList.add('is-struck'); }); + window.setTimeout(function () { g.classList.remove('is-struck'); }, 520); + }); + + function faceStatus() { + const el = $('#face-status'); + if (!el) return; + if (state.selFace < 0 || !state.faces[state.selFace]) { + el.textContent = 'strike a stone'; + return; + } + const f = state.faces[state.selFace]; + el.textContent = 'face ' + String(f.i + 1).padStart(2, '0') + ' · ' + + f.t0.toFixed(2) + '–' + f.t1.toFixed(2) + ' s · seal ' + f.seal; + } + + /* ————— weather ————— */ + + function climateText() { + const w = state.weather; + const age = w.age < 0.4 ? 'young' : w.age < 0.7 ? 'middle' : 'old'; + const air = w.air < 0.35 ? 'dry' : w.air < 0.7 ? 'damp' : 'wet'; + const heat = w.heat < 0.35 ? 'cold' : w.heat < 0.7 ? 'warm' : 'hot'; + return age + ', ' + air + ', ' + heat; + } + + function updateClimate() { + const el = $('#climate'); + if (el) el.textContent = climateText(); + } + + function initWeather() { + ['age', 'pressure', 'air', 'heat'].forEach(function (k) { + const el = $('#w-' + k); + if (!el) return; + state.weather[k] = +el.value / 100; + const out = el.parentNode.querySelector('output'); + out.textContent = state.weather[k].toFixed(2); + el.addEventListener('input', function () { + state.weather[k] = +el.value / 100; + out.textContent = state.weather[k].toFixed(2); + updateClimate(); + if (E) E.setWeather(state.weather); + }); + }); + updateClimate(); + if (E) E.setWeather(state.weather); + } + + /* ————— rings ————— */ + + function drawRings() { + const box = $('#ring-visual'); + if (!box) return; + const W = box.clientWidth, H = box.clientHeight; + if (!W || !H) return; + const cx = W / 2, cy = H / 2; + let s = ''; + const n = Math.min(state.rings, 12); + const maxR = Math.min(W * 0.46, H * 0.46); + for (let i = 0; i < n; i++) { + const r = maxR * (i + 1) / Math.max(n, 4); + const off = (i % 2 ? 1 : -1) * (1 + i * 0.3); + s += ''; + } + s += ''; + box.innerHTML = s; + const count = $('#ring-count'); + if (count) count.textContent = String(state.rings).padStart(2, '0'); + } + + /* ————— sound ————— */ + + function strike(face) { + if (!E || !face) return; + const core = cores[state.sel]; + if (!core) return; + E.strike(core.id, face.i, { pitch: face.semis, gain: 0.9, ghost: state.ghost }); + } + + /* ————— view, core select, fracture, place ————— */ + + function updateHeader() { + const core = cores[state.sel]; + const name = $('#tray-name'); + const dur = $('#tray-dur'); + const sealEl = $('#tray-seal'); + if (name) name.textContent = core.name; + if (dur) dur.textContent = core.dur.toFixed(1) + ' s'; + if (sealEl) { + sealEl.className = 'seal seal--' + core.seal; + const t = sealEl.querySelector('span'); + if (t) t.textContent = core.seal; + } + } + + function selectCore(i, keepSeed) { + state.sel = clamp(i, 0, cores.length - 1); + state.selFace = -1; + $('#place').disabled = true; + const lb = $('#place-ring'); if (lb) lb.disabled = true; + const core = cores[state.sel]; + state.lens = core.cuts[0] || 0; + updateHeader(); + highlightStore(); + if (!keepSeed) state.faceSeed = core.seed * 13 + state.fracture * 7; + redraw(); + faceStatus(); + } + + function redraw() { + drawTray(); + buildOverlay(); + buildFaces(state.faceSeed); + drawRings(); + } + + function fractureCore() { + const core = cores[state.sel]; + state.fracture++; + if (E) E.refracture(core.id); + state.faceSeed = core.seed * 13 + state.fracture * 7; + state.selFace = -1; + state.lens = core.cuts[0]; + $('#place').disabled = true; + const lb2 = $('#place-ring'); if (lb2) lb2.disabled = true; + redraw(); + faceStatus(); + if (wall.animate) { + wall.animate([{ opacity: 0.25 }, { opacity: 1 }], { duration: 420, easing: 'ease-out' }); + } + log('faces.fracture', core.id + ' · 16 faces', '', 'hand'); + } + + function placeFace(loop) { + if (state.selFace < 0 || !state.faces[state.selFace]) return; + const core = cores[state.sel]; + if (!core || !window.ZHILA || !window.ZHILA.addStoneClip) return; + const f = state.faces[state.selFace]; + const label = 'face ' + String(f.i + 1).padStart(2, '0'); + const res = window.ZHILA.addStoneClip(label, f.seal, { + core: core.id, face: f.i, ring: loop ? 2 : 0, pitch: 0, gain: 0.85 + }); + const st = $('#face-status'); + if (res) { + if (st) st.textContent = (loop ? 'looping in ' : 'placed in ') + res.lane + ', bar ' + res.start + '–' + res.end; + } + } + + function extract(by, query) { + if (!E) return; + const name = by === 'mcp' + ? (query.length > 26 ? query.slice(0, 26) + '…' : query) + : (handBank.length ? handBank.shift() : 'new core'); + const core = E.newCore({ name: name, seal: by === 'mcp' ? 'found' : 'taken' }); + log('cores.extract', core.name, core.seal, by); + } + + /* ————— view switch ————— */ + + function setView(v) { + document.body.dataset.view = v; + $$('.view-btn').forEach(function (b) { + const on = b.dataset.viewBtn === v; + b.classList.toggle('is-on', on); + b.setAttribute('aria-pressed', String(on)); + }); + if (v === 'quarry') { + requestAnimationFrame(function () { + selectCore(state.sel, true); + }); + } else { + requestAnimationFrame(function () { + if (window.ZHILA && window.ZHILA.buildNave) window.ZHILA.buildNave(); + if (window.ZHILA && window.ZHILA.buildLattice) window.ZHILA.buildLattice(); + }); + } + } + + $$('.view-btn').forEach(function (b) { + b.addEventListener('click', function () { setView(b.dataset.viewBtn); }); + }); + + /* ————— tray scrubbing ————— */ + + let scrubbing = false; + function scrub(e) { + const r = stage.getBoundingClientRect(); + const g = trayGeom(); + if (!g.W) return; + const x = clamp(e.clientX - r.left, g.x0, g.x1); + const core = cores[state.sel]; + state.lens = +(((x - g.x0) / (g.x1 - g.x0)) * core.dur).toFixed(2); + updateLensOnly(); + } + stage.addEventListener('pointerdown', function (e) { + scrubbing = true; + stage.setPointerCapture(e.pointerId); + scrub(e); + e.preventDefault(); + }); + stage.addEventListener('pointermove', function (e) { if (scrubbing) scrub(e); }); + stage.addEventListener('pointerup', function () { scrubbing = false; }); + stage.addEventListener('pointercancel', function () { scrubbing = false; }); + + /* ————— wiring ————— */ + + $('#fracture').addEventListener('click', fractureCore); + $('#place').addEventListener('click', function () { placeFace(false); }); + const loopBtn = $('#place-ring'); + if (loopBtn) loopBtn.addEventListener('click', function () { placeFace(true); }); + const updatePlace = function () { + const on = state.selFace >= 0; + $('#place').disabled = !on; + if (loopBtn) loopBtn.disabled = !on; + }; + + $('#ghost').addEventListener('click', function () { + state.ghost = !state.ghost; + this.setAttribute('aria-pressed', String(state.ghost)); + this.textContent = state.ghost ? 'on' : 'off'; + }); + + $('#grow').addEventListener('click', function () { + state.rings = Math.min(state.rings + 1, 12); + if (E) E.setRings(state.rings); + drawRings(); + log('rings.grow', 'ring ' + String(state.rings).padStart(2, '0') + ' · ' + climateText(), '', 'hand'); + }); + + $('#extract').addEventListener('click', function () { + extract('hand'); + }); + + const choirForm = $('#choir-call'); + const choirInput = $('#choir-input'); + choirForm.addEventListener('submit', function (e) { + e.preventDefault(); + const q = choirInput.value.trim(); + if (!q || document.body.dataset.mode === 'hand') return; + choirInput.value = ''; + choirForm.classList.add('is-out'); + window.setTimeout(function () { + extract('mcp', q); + choirForm.classList.remove('is-out'); + }, 950); + }); + + function syncChoir() { + const hand = document.body.dataset.mode === 'hand'; + choirInput.disabled = hand; + choirInput.placeholder = hand ? 'the choir is resting' : 'send the choir for…'; + } + new MutationObserver(syncChoir).observe(document.body, { attributes: true, attributeFilter: ['data-mode'] }); + choirForm.addEventListener('pointerdown', function () { + if (document.body.dataset.mode === 'hand') { + const btn = document.querySelector('[data-mode-btn="choir"]'); + if (btn) btn.click(); + window.setTimeout(function () { choirInput.focus(); }, 40); + } + }); + + let rt = null; + window.addEventListener('resize', function () { + window.clearTimeout(rt); + rt = window.setTimeout(function () { + if (document.body.dataset.view !== 'quarry') return; + drawTray(); + buildOverlay(); + buildFaces(state.faceSeed); + drawRings(); + }, 180); + }); + + /* ————— boot ————— */ + + function setWeatherUI(w) { + ['age', 'pressure', 'air', 'heat'].forEach(function (k) { + if (w[k] == null) return; + state.weather[k] = clamp(+w[k], 0, 1); + const el = $('#w-' + k); + if (el) { + el.value = Math.round(state.weather[k] * 100); + const out = el.parentNode.querySelector('output'); + if (out) out.textContent = state.weather[k].toFixed(2); + } + }); + if (E) E.setWeather(state.weather); + updateClimate(); + return state.weather; + } + + function setRingsUI(n) { + state.rings = clamp(Math.round(+n || 0), 0, 12); + if (E) E.setRings(state.rings); + drawRings(); + return state.rings; + } + + function refresh() { + buildStore(); + selectCore(state.sel, true); + } + + function focusCore(id) { + const idx = cores.findIndex(function (c) { return c.id === id; }); + if (idx >= 0) selectCore(idx); + else selectCore(state.sel, true); + } + + function boot() { + if (E) { + E.onCores(function (core) { + buildStore(); + const idx = E.cores.indexOf(core); + if (idx >= 0) { + state.sel = idx; + if (document.body.dataset.view === 'quarry') selectCore(idx); + else updateHeader(); + } + }); + E.onLog(renderLedger); + E.setRings(state.rings); + } + buildStore(); + initWeather(); + renderLedger(); + selectCore(0, false); + syncChoir(); + } + + if (document.readyState === 'complete' || document.readyState === 'interactive') { + window.setTimeout(boot, 0); + } else { + document.addEventListener('DOMContentLoaded', boot); + } + + window.ZHILA_QUARRY = { + setView: setView, state: state, cores: cores, focusCore: focusCore, + setWeatherUI: setWeatherUI, setRingsUI: setRingsUI, refresh: refresh, + weather: function () { return state.weather; }, + rings: function () { return state.rings; }, + selectedCore: function () { return cores[state.sel] || null; } + }; +})(); diff --git a/reference/demo/server.js b/reference/demo/server.js new file mode 100644 index 0000000..19904ce --- /dev/null +++ b/reference/demo/server.js @@ -0,0 +1,230 @@ +/* ZHILA · local server + serves the instrument and runs the choir: a model with tools that act in the page. */ + +'use strict'; + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const { SYSTEM, TOOLS } = require('./agent-tools'); + +const ROOT = __dirname; +const PORT = Number(process.env.PORT || 4173); +const HOST = '127.0.0.1'; +const MODEL = process.env.ZHILA_MODEL || 'deepseek-chat'; +const BASE = process.env.ZHILA_BASE_URL || 'https://api.deepseek.com'; +const KEY = process.env.DEEPSEEK_API_KEY || ''; + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.svg': 'image/svg+xml', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.woff2': 'font/woff2', + '.ico': 'image/x-icon', + '.md': 'text/plain; charset=utf-8' +}; + +function send(res, code, type, body) { + res.writeHead(code, { + 'Content-Type': type, + 'Cache-Control': 'no-store', + 'Access-Control-Allow-Headers': 'content-type', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS' + }); + res.end(body); +} + +function json(res, code, obj) { + send(res, code, 'application/json; charset=utf-8', JSON.stringify(obj)); +} + +function serveStatic(req, res, pathname) { + let rel = pathname === '/' ? '/index.html' : pathname; + try { rel = decodeURIComponent(rel); } catch (e) { return send(res, 400, 'text/plain', 'bad path'); } + const file = path.resolve(ROOT, '.' + rel); + if (!file.startsWith(ROOT + path.sep) || file === ROOT) return send(res, 403, 'text/plain', 'forbidden'); + if (!MIME[path.extname(file).toLowerCase()]) return send(res, 404, 'text/plain', 'not found'); + fs.readFile(file, function (err, data) { + if (err) return send(res, 404, 'text/plain', 'not found'); + send(res, 200, MIME[path.extname(file).toLowerCase()], data); + }); +} + +let cachedKey = KEY; + +/* The key may be exported to the launching environment; otherwise the start script injects it. */ +if (!cachedKey) { + console.warn('[zhila] no DEEPSEEK_API_KEY — the agent will run in local mode'); +} + +function normaliseMessages(input) { + if (!Array.isArray(input)) throw new Error('messages must be an array'); + if (input.length > 80) throw new Error('conversation too long'); + let chars = 0; + return input.map(function (m) { + if (!m || typeof m !== 'object') throw new Error('bad message'); + const role = m.role; + if (!['user', 'assistant', 'tool'].includes(role)) throw new Error('bad role'); + const out = { role: role }; + if (typeof m.content === 'string') { + if (m.content.length > 12000) throw new Error('message too long'); + chars += m.content.length; + out.content = m.content; + } else if (m.content === null) { + out.content = null; + } else { + out.content = ''; + } + if (role === 'tool') { + if (typeof m.tool_call_id !== 'string') throw new Error('tool message without id'); + out.tool_call_id = m.tool_call_id; + if (m.name) out.name = String(m.name).slice(0, 80); + } + if (role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) { + out.tool_calls = m.tool_calls.map(function (tc) { + if (!tc || tc.type !== 'function' || !tc.function || typeof tc.function.name !== 'string') { + throw new Error('bad tool call'); + } + return { + id: String(tc.id || '').slice(0, 80), + type: 'function', + function: { + name: tc.function.name, + arguments: typeof tc.function.arguments === 'string' ? tc.function.arguments.slice(0, 8000) : '{}' + } + }; + }); + } + return out; + }).filter(function (m) { + return m.content && m.content.length || m.tool_calls && m.tool_calls.length || m.role === 'tool'; + }).map(function (m) { + if (chars > 200000) throw new Error('conversation too large'); + return m; + }); +} + +async function callModel(messages) { + if (!cachedKey) { + const err = new Error('no API key'); + err.code = 'NO_KEY'; + throw err; + } + const body = { + model: MODEL, + messages: [{ role: 'system', content: SYSTEM }].concat(messages), + tools: TOOLS, + tool_choice: 'auto', + temperature: 0.75, + max_tokens: 900, + stream: false + }; + const res = await fetch(BASE.replace(/\/+$/, '') + '/chat/completions', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + cachedKey, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(90000) + }); + const text = await res.text(); + let data; + try { data = JSON.parse(text); } catch (e) { throw new Error('model returned non-JSON (' + res.status + ')'); } + if (!res.ok) { + const msg = data && data.error && data.error.message ? data.error.message : 'model error ' + res.status; + throw new Error(msg); + } + const choice = data.choices && data.choices[0]; + if (!choice || !choice.message) throw new Error('model returned no message'); + const m = choice.message; + return { + role: 'assistant', + content: typeof m.content === 'string' ? m.content : '', + tool_calls: Array.isArray(m.tool_calls) && m.tool_calls.length ? m.tool_calls : null + }; +} + +function readBody(req) { + return new Promise(function (resolve, reject) { + let size = 0; + const chunks = []; + req.on('data', function (c) { + size += c.length; + if (size > 400000) { reject(new Error('body too large')); req.destroy(); return; } + chunks.push(c); + }); + req.on('end', function () { + try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); } + catch (e) { reject(new Error('bad JSON')); } + }); + req.on('error', reject); + }); +} + +function originAllowed(req) { + const o = req.headers.origin; + if (!o) return null; + if (o === 'null') return 'null'; + if (/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(o)) return o; + return false; +} + +const server = http.createServer(async function (req, res) { + const url = new URL(req.url, 'http://' + req.headers.host); + const origin = originAllowed(req); + if (origin) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } + if (req.method === 'OPTIONS') return send(res, 204, 'text/plain', ''); + if (url.pathname.startsWith('/api/') && origin === false) { + return json(res, 403, { error: 'origin not allowed' }); + } + + if (url.pathname === '/api/agent' && req.method === 'GET') { + return json(res, 200, { + live: !!cachedKey, + mode: cachedKey ? 'model' : 'local', + model: cachedKey ? MODEL : null + }); + } + + if (url.pathname === '/api/agent' && req.method === 'POST') { + let body; + try { body = await readBody(req); } + catch (e) { return json(res, 400, { error: e.message }); } + let messages; + try { messages = normaliseMessages(body.messages); } + catch (e) { return json(res, 400, { error: e.message }); } + try { + const message = await callModel(messages); + return json(res, 200, { message: message, model: MODEL }); + } catch (e) { + if (e.code === 'NO_KEY') { + return json(res, 503, { error: 'agent offline: no API key on the server', mode: 'local' }); + } + console.error('[zhila] model error:', e.message); + return json(res, 502, { error: e.message || 'model error' }); + } + } + + if (url.pathname.startsWith('/api/')) return json(res, 404, { error: 'no such endpoint' }); + + if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'text/plain', 'method not allowed'); + serveStatic(req, res, url.pathname); +}); + +server.listen(PORT, HOST, function () { + console.log(''); + console.log(' ZHILA · http://' + HOST + ':' + PORT); + console.log(' agent · ' + (cachedKey ? MODEL + ' (live)' : 'local conductor (no key)')); + console.log(''); +}); diff --git a/reference/demo/start.sh b/reference/demo/start.sh new file mode 100755 index 0000000..cb490cd --- /dev/null +++ b/reference/demo/start.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" + +if [ -z "${DEEPSEEK_API_KEY:-}" ]; then + DEEPSEEK_API_KEY="$(python3 - <<'PY' +import os +try: + import yaml + d = yaml.safe_load(open(os.path.expanduser('~/.dsh/.credentials.yaml'))) + print((d.get('refs') or {}).get('DEEPSEEK_API_KEY', '')) +except Exception: + print('') +PY +)" + export DEEPSEEK_API_KEY +fi + +if [ -z "${DEEPSEEK_API_KEY}" ]; then + echo "no DEEPSEEK_API_KEY — the choir will run in local mode" +fi + +exec node server.js diff --git a/reference/demo/style.css b/reference/demo/style.css new file mode 100644 index 0000000..4a4e305 --- /dev/null +++ b/reference/demo/style.css @@ -0,0 +1,1224 @@ +/* ———————————————————————————————————————————————— + ZHILA · sound lies as relief + palette sampled from the reference: resin, ember, blood, bone, ash + one surface: no cards, no radii, no shadows — black pools and hairlines + ———————————————————————————————————————————————— */ + +:root{ + --resin:#0c0908; + --soot:#060404; + --ember:#e8432a; + --blood:#a8271a; + --bone:#efe4d4; + --ash:#9b8579; + --ash-2:#6d5b52; + --line:rgba(232,64,42,.20); + --line-soft:rgba(232,64,42,.12); + --hair:rgba(239,228,212,.10); + --mono:"JetBrains Mono",ui-monospace,SFMono-Regular,monospace; + --serif:"Cormorant",Georgia,"Times New Roman",serif; + --rail-l:240px; + --rail-r:216px; + --pad:20px; +} + +*,*::before,*::after{box-sizing:border-box;} +html,body{height:100%;} + +body{ + margin:0; + background:var(--resin); + color:var(--bone); + font-family:var(--mono); + font-weight:300; + font-size:11px; + line-height:1.5; + -webkit-font-smoothing:antialiased; + text-rendering:optimizeLegibility; + overflow:hidden; +} + +::selection{background:var(--ember);color:#0a0706;} + +button,input{font:inherit;color:inherit;} +button{background:none;border:0;padding:0;cursor:pointer;} +h1,h2,h3,p,ol,ul,pre{margin:0;padding:0;} +ol,ul{list-style:none;} +pre{font-family:inherit;} +svg{display:block;} + +:focus-visible{outline:1px solid rgba(239,228,212,.75);outline-offset:3px;} + +.sr{ + position:absolute;width:1px;height:1px;padding:0;margin:-1px; + overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0; +} + +/* ————— surface ————— */ + +#terrain{ + position:fixed;inset:0; + width:100%;height:100%; + z-index:0; + opacity:0; + transform:scale(1.015); + transition:opacity 1.5s ease, transform 2.6s cubic-bezier(.2,.6,.2,1); + pointer-events:none; +} +body.is-ready #terrain{opacity:1;transform:scale(1);} + +#breath{ + position:fixed;inset:0; + width:100%;height:100%; + z-index:1; + pointer-events:none; + transition:opacity .8s ease; +} +body[data-view="quarry"] #breath{opacity:0;} +.quarry-dim{ + position:fixed;inset:0;z-index:2;pointer-events:none; + opacity:0;transition:opacity .8s ease; + background: + radial-gradient(56% 38% at 50% 12%, rgba(232,64,42,.08), transparent 72%), + radial-gradient(120% 100% at 50% 45%, rgba(5,3,3,.5), rgba(3,2,2,.82)); +} +body[data-view="quarry"] .quarry-dim{opacity:1;} + +.vignette{ + position:fixed;inset:0;z-index:2;pointer-events:none; + background: + radial-gradient(130% 95% at 50% 32%, rgba(0,0,0,0) 42%, rgba(0,0,0,.55) 100%), + linear-gradient(to bottom, rgba(0,0,0,.3) 0%, rgba(0,0,0,0) 14%); +} + +.grain{ + position:fixed;inset:0;z-index:3;pointer-events:none; + opacity:.05; + background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.86' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +.shell{ + position:relative;z-index:4; + height:100dvh; + display:grid; + grid-template-rows:46px clamp(280px,33vh,366px) minmax(0,1fr) 76px; +} + +/* ————— top rail ————— */ + +.rail--top{ + display:flex;align-items:center;gap:22px; + padding:0 var(--pad); + background:linear-gradient(to bottom, rgba(8,5,4,.9), rgba(8,5,4,.3)); + border-bottom:1px solid var(--line-soft); + backdrop-filter:blur(3px); +} + +.brand{ + font-family:var(--serif); + font-weight:400; + font-size:17px; + line-height:1; + letter-spacing:.3em; + color:var(--bone); + text-decoration:none; + white-space:nowrap; + position:relative; + top:-1px; + margin-right:-2px; +} +.brand-v{ + font-family:var(--mono); + font-size:7px; + letter-spacing:.08em; + color:var(--ash-2); + margin-left:4px; + position:relative;top:-7px; +} + +.doc{display:flex;align-items:center;gap:14px;min-width:0;} +.doc-name{font-size:10px;letter-spacing:.05em;color:rgba(239,228,212,.78);white-space:nowrap;} +.doc-rule{width:1px;height:11px;background:var(--hair);} +.doc-meta{ + display:flex;align-items:center;gap:8px; + font-size:8.5px;letter-spacing:.05em;color:rgba(155,133,121,.8); + white-space:nowrap; +} +.doc-dot{display:inline-block;width:1px;height:9px;background:rgba(239,228,212,.13);} + +.views{ + margin-left:auto; + display:flex;align-items:center;gap:9px; + padding-right:18px; + border-right:1px solid rgba(239,228,212,.09); +} +.view-btn{ + font-size:9.5px;letter-spacing:.16em; + color:rgba(239,228,212,.4); + padding:4px 0;position:relative; + transition:color .35s ease; +} +.view-btn:hover{color:var(--bone);} +.view-btn.is-on{color:var(--bone);} +.view-btn.is-on::after{ + content:"";position:absolute;left:0;right:0;bottom:-1px;height:1px; + background:var(--ember); +} +.mode{ + display:flex;align-items:center;gap:11px; + padding-left:18px; + border-left:1px solid rgba(239,228,212,.09); +} +.mode-btn{ + font-size:10px;letter-spacing:.17em; + color:rgba(239,228,212,.4); + padding:4px 0; + transition:color .35s ease; + position:relative; +} +.mode-btn:hover{color:var(--bone);} +.mode-btn.is-on{color:var(--bone);} +.mode-btn.is-on::before{ + content:"";position:absolute;left:50%;top:-2px; + width:3px;height:3px;background:var(--ember); + transform:translateX(-50%); + box-shadow:0 0 8px rgba(232,64,42,.9); +} +.mode-slash{color:rgba(109,91,82,.55);font-size:9px;} + +.net{ + display:flex;align-items:center;gap:8px; + font-size:8.5px;letter-spacing:.1em; + color:rgba(155,133,121,.8); + min-width:122px; + white-space:nowrap; +} +.net-tag{color:rgba(239,228,212,.62);letter-spacing:.14em;} +.net-rule{width:1px;height:8px;background:rgba(239,228,212,.14);} +.led{ + width:3px;height:3px;background:var(--ember);flex:0 0 auto; + box-shadow:0 0 8px rgba(232,64,42,.9); +} +body:not(.is-ready) .led{opacity:.2} +body.is-playing .led{animation:breathe 2.4s ease-in-out infinite;} +body[data-mode="hand"] .led{background:var(--ash-2);box-shadow:none;animation:none;} + +@keyframes breathe{0%,100%{opacity:.3}50%{opacity:1}} + +/* ————— sky ————— */ + +.sky{ + position:relative; + display:flex;flex-direction:column;align-items:center;justify-content:center; + padding:0 24px 26px; + min-height:0; +} +.sky-inner{ + display:flex;flex-direction:column;align-items:center; + width:min(580px,72vw); + position:relative; + top:-4%; +} + +.void-wrap{ + position:relative; + width:clamp(112px,17vh,168px); + aspect-ratio:1; + margin-bottom:clamp(16px,2.6vh,30px); +} +#halo{ + position:absolute;inset:-88px; + width:calc(100% + 176px);height:calc(100% + 176px); + pointer-events:none; +} +.void{ + position:absolute;inset:0; + width:100%;height:100%; + border-radius:50%; + background: + radial-gradient(circle at 50% 43%, #000 0%, #030202 55%, #0d0706 88%, #1a0c08 100%); + border:1px solid rgba(239,228,212,.24); + color:var(--bone); + display:flex;flex-direction:column;align-items:center;justify-content:center; + transition:border-color .4s ease, box-shadow .6s ease; +} +.void::after{ + content:"";position:absolute;inset:6px;border-radius:50%; + border:1px solid rgba(232,64,42,.13); + pointer-events:none; +} +.void:hover,.void:focus-visible{ + border-color:rgba(232,64,42,.55); + box-shadow:0 0 60px rgba(232,64,42,.18), inset 0 0 40px rgba(232,64,42,.06); +} +.void-word{ + font-family:var(--serif);font-weight:300; + font-size:clamp(21px,3vh,30px); + line-height:1;letter-spacing:.04em; + color:rgba(239,228,212,.92); + margin-bottom:7px; +} +.void-label{ + font-size:7.5px;letter-spacing:.26em;color:var(--ash-2); +} +.void-hint{ + position:absolute;inset:0;border-radius:50%; + display:grid;place-items:center; + background:radial-gradient(circle,rgba(2,1,1,.94) 0 46%,rgba(2,1,1,.6) 100%); + font-size:9px;letter-spacing:.24em;color:var(--ember); + opacity:0;transition:opacity .28s ease; +} +.void:hover .void-hint,.void:focus-visible .void-hint{opacity:1;} +.void.is-pulsed{animation:void-pulse .9s cubic-bezier(.2,.7,.2,1);} +@keyframes void-pulse{ + 0%{box-shadow:0 0 0 0 rgba(232,64,42,.5), inset 0 0 30px rgba(232,64,42,.2);border-color:rgba(232,64,42,.9);} + 100%{box-shadow:0 0 0 34px rgba(232,64,42,0), inset 0 0 0 rgba(232,64,42,0);border-color:rgba(239,228,212,.16);} +} + +.invocation{ + display:flex;align-items:flex-end;gap:16px; + width:100%; + padding-bottom:7px; + border-bottom:1px solid rgba(232,64,42,.3); + position:relative; + transition:border-color .4s ease; +} +.invocation::before{ + content:"";position:absolute;left:-7%;right:-7%;top:-34px;bottom:-30px; + background:radial-gradient(ellipse at 50% 55%, rgba(6,4,3,.94) 0%, rgba(6,4,3,.72) 46%, rgba(6,4,3,0) 76%); + z-index:-1;pointer-events:none; +} +.invocation:focus-within{border-color:rgba(232,64,42,.85);} +.invocation::after{ + content:"";position:absolute;left:50%;bottom:-1px;height:1px;width:0; + background:var(--ember);transition:width .5s cubic-bezier(.2,.7,.2,1); +} +.invocation:focus-within::after{width:100%;left:0;} + +#prompt{ + flex:1;min-width:0; + background:none;border:0;outline:none; + padding:2px 0 1px; + font-family:var(--serif);font-weight:300;font-style:italic; + font-size:clamp(20px,2.8vh,26px); + line-height:1.25;letter-spacing:.005em; + color:var(--bone); + caret-color:var(--ember); +} +#prompt::placeholder{color:rgba(186,166,152,.8);font-style:italic;} +#prompt:disabled{cursor:default;} +.invoke{ + font-size:9.5px;letter-spacing:.2em; + color:var(--ash); + padding-bottom:4px; + position:relative; + transition:color .3s ease; + white-space:nowrap; +} +.invoke::after{ + content:"";position:absolute;right:0;bottom:0;height:1px;width:100%; + background:var(--ember);opacity:.35;transition:opacity .3s ease; +} +.invoke:hover{color:var(--bone);} +.invoke:hover::after{opacity:.9;} + +.hint{ + display:flex;align-items:center;gap:9px; + margin-top:14px; + font-size:8px;letter-spacing:.11em; + color:rgba(155,133,121,.85); + white-space:nowrap; +} +.hint kbd{font:inherit;color:rgba(239,228,212,.72);} +.hint-rule{width:14px;height:1px;background:rgba(239,228,212,.16);} +.hint--hand{display:none;} +body[data-mode="hand"] .hint--choir{display:none;} +body[data-mode="hand"] .hint--hand{display:flex;} +body[data-mode="hand"] .invocation{border-color:rgba(232,64,42,.12);} +body[data-mode="hand"] .hint{opacity:.5;} + +/* ————— deck ————— */ + +.deck{ + display:grid; + grid-template-columns:var(--rail-l) minmax(0,1fr) var(--rail-r); + min-height:0; + position:relative; +} + +.rail{padding:16px var(--pad) 14px;min-height:0;position:relative;} + +.head{ + display:flex;align-items:baseline;justify-content:space-between;gap:10px; + padding-bottom:8px; + border-bottom:1px solid var(--line); +} +.title{ + font-family:var(--serif);font-weight:300; + font-size:23px;line-height:1;letter-spacing:.04em; + color:var(--bone); +} +.sub{ + font-size:8px;line-height:1;letter-spacing:.14em; + color:rgba(155,133,121,.8); + white-space:nowrap; +} +.sub--tag{ + color:rgba(232,64,42,.8); + letter-spacing:.2em; + font-size:7.5px; +} +.chips{display:flex;align-items:center;gap:9px;font-size:7px;letter-spacing:.13em;line-height:1;} +.chip{color:rgba(155,133,121,.7);position:relative;} +.chip + .chip{padding-left:9px;} +.chip + .chip::before{ + content:"";position:absolute;left:0;top:50%;width:1px;height:6px; + background:rgba(239,228,212,.16);transform:translateY(-50%); +} +.chip:first-child{color:rgba(232,64,42,.85);} + +.agent::after,.side::before{ + content:"";position:absolute;top:0;bottom:0;width:1px; + background:linear-gradient(to bottom, + rgba(232,64,42,0) 0%, + rgba(232,64,42,.22) 16%, + rgba(232,64,42,.22) 72%, + rgba(232,64,42,0) 100%); + pointer-events:none; +} +.agent::after{right:0;} +.side::before{left:0;} + +/* ————— choir ————— */ + +.agent{display:flex;flex-direction:column;min-height:0;} + +.state{ + display:flex;align-items:center;gap:7px; + margin:9px 0 2px; + font-size:8px;letter-spacing:.13em; + color:var(--ash); +} +.state-meta{margin-left:auto;color:rgba(109,91,82,.9);letter-spacing:.06em;} +body[data-mode="hand"] .state{color:var(--ash-2);} +.state.is-thinking .led{animation:breathe .9s ease-in-out infinite;} + +.log{ + flex:1;min-height:0; + overflow-y:auto;overscroll-behavior:contain; + margin:3px -8px 0 0;padding:0 8px 10px 0; + scrollbar-width:thin; + scrollbar-color:rgba(232,64,42,.25) transparent; +} +.log::-webkit-scrollbar{width:3px;} +.log::-webkit-scrollbar-thumb{background:rgba(232,64,42,.25);} + +.call{ + padding:9px 0 8px; + border-bottom:1px solid rgba(239,228,212,.055); + animation:rise .45s cubic-bezier(.2,.7,.2,1) both; +} +@keyframes rise{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}} + +.call--you{display:grid;grid-template-columns:32px minmax(0,1fr);gap:8px;align-items:baseline;} +.call--you p{ + font-family:var(--serif);font-style:italic;font-weight:300; + font-size:15px;line-height:1.28; + color:rgba(239,228,212,.86); + letter-spacing:.005em; +} +.call-t{ + font-size:7px;letter-spacing:.03em; + color:rgba(109,91,82,.9); + font-variant-numeric:tabular-nums; + padding-top:2px; +} +.call-head{display:flex;align-items:baseline;gap:8px;} +.call-head .call-t{flex:0 0 32px;} +.call-name{ + font-size:10px;letter-spacing:.01em; + color:rgba(239,228,212,.9); + flex:1;min-width:0; + white-space:nowrap;overflow:hidden;text-overflow:ellipsis; +} +.call-src{ + font-size:6.5px;letter-spacing:.12em; + color:rgba(155,133,121,.6); + text-transform:lowercase; +} +.call-src:only-of-type{color:var(--ash-2);} +.call--mcp .call-src{color:rgba(232,64,42,.8);} +.call-ms{ + font-size:6.5px;letter-spacing:.04em; + color:rgba(109,91,82,.95); + font-variant-numeric:tabular-nums; +} +.call-undo{ + font-size:6.5px;letter-spacing:.08em; + color:rgba(155,133,121,.85); + border-bottom:1px solid rgba(155,133,121,.35); + display:none; +} +.call:hover .call-ms{display:none;} +.call:hover .call-undo{display:inline;} +.call-args{ + margin:4px 0 0 40px; + font-size:8px;line-height:1.6; + color:rgba(155,133,121,.95); + white-space:pre-wrap; + overflow-wrap:anywhere; + animation:fade .5s ease both; +} +@keyframes fade{from{opacity:0}to{opacity:1}} +.call-args .p{color:rgba(155,133,121,.42);} +.call--say{display:grid;grid-template-columns:32px minmax(0,1fr);gap:8px;align-items:baseline;} +.call--say p{ + font-family:var(--mono);font-size:9.5px;line-height:1.65; + color:rgba(239,228,212,.88); + border-left:1px solid rgba(232,64,42,.5); + padding-left:8px; +} +.call-args--line{ + margin:3px 0 0 40px; + font-size:8px;line-height:1.55; + color:rgba(155,133,121,.82); + white-space:normal; +} +.call-args--line:empty{display:none;} +.call-result{ + margin:3px 0 0 40px; + font-size:7px;line-height:1.5; + color:rgba(109,91,82,.95); + white-space:pre-wrap;overflow-wrap:anywhere; +} +.chip.is-live{color:rgba(232,64,42,1);} +body.agent-offline .net .led{background:var(--ash-2);box-shadow:none;animation:none;} +.call-args .k{color:rgba(155,133,121,.95);} +.call-args .v{color:rgba(239,228,212,.72);} + +.shape{ + border-top:1px solid var(--line-soft); + padding-top:9px; + margin-top:2px; + flex:0 0 auto; +} +.shape-head{ + display:flex;justify-content:space-between; + font-size:7px;letter-spacing:.15em;color:rgba(109,91,82,.95); + margin-bottom:4px; +} +.slider{ + display:grid;grid-template-columns:58px minmax(0,1fr) 30px; + align-items:center;gap:8px; + padding:3px 0; + cursor:ew-resize; +} +.slider-name{font-size:8.5px;letter-spacing:.02em;color:var(--ash);white-space:nowrap;} +.slider output{ + font-size:7px;text-align:right;color:rgba(155,133,121,.85); + font-variant-numeric:tabular-nums; +} + +input[type="range"]{ + -webkit-appearance:none;appearance:none; + width:100%;height:14px; + background:transparent;border:0;margin:0;padding:0; + cursor:ew-resize; +} +input[type="range"]::-webkit-slider-runnable-track{height:1px;background:rgba(155,133,121,.3);} +input[type="range"]::-webkit-slider-thumb{ + -webkit-appearance:none;appearance:none; + width:2px;height:11px;margin-top:-5px; + background:var(--bone);border:0;border-radius:0; +} +input[type="range"]::-moz-range-track{height:1px;background:rgba(155,133,121,.3);} +input[type="range"]::-moz-range-thumb{width:2px;height:11px;background:var(--bone);border:0;border-radius:0;} +input[type="range"]:focus-visible{outline:1px solid rgba(239,228,212,.55);outline-offset:4px;} + +/* ————— nave ————— */ + +.corridor{ + display:grid;grid-template-rows:auto minmax(0,1fr); + min-height:0;min-width:0; + padding-top:16px; +} +.corridor-head{padding:0 var(--pad) 8px;border-bottom:1px solid var(--line-soft);} +.corridor-sub{display:flex;align-items:center;gap:7px;font-variant-numeric:tabular-nums;} +.sub-rule{width:1px;height:7px;background:rgba(239,228,212,.16);} + +.stage{position:relative;min-height:0;overflow:hidden;} +#spires{ + position:absolute;left:0;top:0;width:100%;height:40%; + pointer-events:none; + mask-image:linear-gradient(to bottom,#000 0%,#000 60%,rgba(0,0,0,.2) 92%,transparent 100%); + -webkit-mask-image:linear-gradient(to bottom,#000 0%,#000 60%,rgba(0,0,0,.2) 92%,transparent 100%); +} +#nave{ + position:absolute;inset:0; + width:100%;height:100%; + overflow:visible; + cursor:crosshair; +} +.now-label{ + position:absolute;left:var(--pad);bottom:5px; + font-size:7px;letter-spacing:.22em;color:rgba(155,133,121,.5); + pointer-events:none; +} + +.road-base,.road-floor,.gate-line{pointer-events:none;} +.road-base{fill:url(#roadFade);} +.road-floor{fill:url(#roadFloor);} +.gate-line{stroke:rgba(232,64,42,.4);stroke-width:1;shape-rendering:crispEdges;} +.now-glow{stroke:var(--ember);stroke-width:7;opacity:.16;filter:blur(4px);pointer-events:none;} +.lane-edge{stroke:rgba(232,64,42,.36);stroke-width:.85;fill:none;pointer-events:none;} +.lane-edge--outer{stroke:rgba(232,64,42,.62);} +.lane-glow{stroke:rgba(232,64,42,.55);stroke-width:3;fill:none;opacity:0;filter:blur(3px);} +.lane-hit{fill:transparent;pointer-events:all;transition:fill .35s ease;} +.lane.is-hot .lane-hit{fill:rgba(232,64,42,.035);} +.lane.is-hot .lane-glow{opacity:.55;} +.lane-name{ + font-family:var(--mono);font-size:8.5px;letter-spacing:.07em; + fill:rgba(155,133,121,.8); + pointer-events:none; + text-anchor:middle; + transition:fill .3s ease; +} +.lane-name.is-hot{fill:var(--bone);} +.bar-line{stroke:rgba(232,64,42,.3);stroke-width:.55;pointer-events:none;} +.bar-line--major{stroke:rgba(232,64,42,.42);stroke-width:.7;} +.now-line{stroke:rgba(232,64,42,.9);stroke-width:1;pointer-events:none;shape-rendering:crispEdges;} + +.clip{transition:opacity .4s ease;} +.clip-body{ + fill:rgba(5,2,2,.82); + stroke:rgba(232,64,42,.62); + stroke-width:.75; + transition:fill .3s ease, stroke .3s ease; +} +.clip-strata{fill:none;stroke:rgba(232,64,42,.28);stroke-width:.6;} +.clip-face{fill:none;stroke:rgba(239,228,212,.16);stroke-width:.7;pointer-events:none;} +.clip-loop circle:first-child{fill:none;stroke:rgba(239,228,212,.4);stroke-width:.8;} +.clip-loop-dot{fill:rgba(232,64,42,.85);stroke:none;} +.clip-name{ + font-family:var(--mono);font-size:9.5px;letter-spacing:.03em; + fill:rgba(239,228,212,.7); +} +.clip-era{ + font-family:var(--mono);font-size:6.5px;letter-spacing:.04em; + fill:rgba(155,133,121,.6); + text-anchor:end; +} +.clip:hover .clip-body,.clip.is-hot .clip-body{ + fill:rgba(30,10,7,.92); + stroke:rgba(239,228,212,.6); +} +.clip:hover .clip-name,.clip.is-hot .clip-name{fill:var(--bone);} +.clip.is-now .clip-body{stroke:rgba(232,64,42,.95);fill:rgba(30,11,8,.55);} +.clip.is-sel .clip-body{stroke:rgba(239,228,212,.95);stroke-width:1.3;fill:rgba(22,11,8,.92);} +.clip.is-sel .clip-name{fill:var(--bone);} +.clip--new .clip-body{animation:ignite 1.8s ease-out both;} +@keyframes ignite{ + 0%{stroke:rgba(239,228,212,.95);fill:rgba(232,64,42,.4);} + 100%{stroke:rgba(232,64,42,.42);fill:rgba(7,4,4,.84);} +} +.clip--new .clip-name{animation:fade-name 1.8s ease-out both;} +@keyframes fade-name{0%{fill:var(--bone)}100%{fill:rgba(239,228,212,.7)}} + +.playhead-line{stroke:var(--ember);stroke-width:1.4;shape-rendering:crispEdges;filter:drop-shadow(0 0 6px rgba(232,64,42,.7));} +.playhead-glow{ + stroke:var(--ember); + stroke-width:calc(9px + var(--lv,0) * 15px); + opacity:calc(.14 + var(--lv,0) * .16); + filter:blur(6px); +} +.playhead-mark{fill:var(--ember);filter:drop-shadow(0 0 6px rgba(232,64,42,.9));} +.ruler-tick{stroke:rgba(155,133,121,.32);stroke-width:.75;} +.ruler-text{ + font-family:var(--mono);font-size:7px; + fill:rgba(155,133,121,.6); + letter-spacing:.04em; +} + +/* ————— crystal + cilia ————— */ + +.side{ + display:grid; + grid-template-rows:minmax(0,1.06fr) minmax(0,.94fr); + min-height:0; + position:relative; +} +.side .rail{padding-left:18px;padding-right:16px;} + +.crystal{display:flex;flex-direction:column;min-height:0;} +.crystal-meta{ + display:flex;justify-content:space-between;gap:8px; + padding:7px 0 5px; + font-size:7px;letter-spacing:.06em;color:rgba(155,133,121,.7); +} +.lattice{flex:1;min-height:74px;position:relative;} +.lattice svg{width:100%;height:100%;} +.lat-grid{stroke:rgba(239,228,212,.13);stroke-width:.6;} +.lat-grid--bar{stroke:rgba(232,64,42,.28);} +.lat-key{font-family:var(--mono);font-size:6.5px;fill:rgba(155,133,121,.7);} +.lat-note{ + fill:rgba(10,6,5,.92); + stroke:rgba(239,228,212,.95); + stroke-width:.9; +} +.lat-note--lone{stroke:rgba(232,64,42,.9);fill:rgba(46,13,9,.5);} +.lat-playhead{stroke:rgba(232,64,42,.6);stroke-width:.75;} + +.cilia{border-top:1px solid var(--line-soft);display:flex;flex-direction:column;min-height:0;} + +.faders{ + flex:1;min-height:0; + display:flex;align-items:stretch;gap:9px; + padding:12px 2px 4px; +} +.ch{ + flex:1;min-width:0; + display:flex;flex-direction:column;align-items:center;gap:5px; + cursor:ns-resize; + outline-offset:2px; +} +.ch-db{ + font-size:7px;letter-spacing:.01em;color:rgba(155,133,121,.7); + height:9px;font-variant-numeric:tabular-nums; +} +.ch-track{flex:1;width:100%;min-height:46px;position:relative;display:flex;justify-content:center;} +.ch-track::before{ + content:"";position:absolute;top:0;bottom:0;width:1px; + background:linear-gradient(to bottom,rgba(155,133,121,.16),rgba(155,133,121,.04)); +} +.ch-fill{ + position:absolute;bottom:0;width:1px; + background:linear-gradient(to top,rgba(239,228,212,.95),rgba(239,228,212,.32)); + pointer-events:none; +} +.ch-bead{ + position:absolute;width:3px;height:9px; + background:var(--bone); + box-shadow:0 0 9px rgba(239,228,212,.3); + pointer-events:none; + transition:background .3s ease; +} +body.is-playing .ch-bead{background:var(--ember);box-shadow:0 0 10px rgba(232,64,42,.65);} +.ch-name{ + font-size:7.5px;letter-spacing:.02em;color:rgba(155,133,121,.78); + height:10px;white-space:nowrap;transition:color .3s ease; + max-width:100%;overflow:hidden;text-overflow:ellipsis; +} +.ch:hover .ch-name{color:var(--bone);} +.ch.is-hot .ch-fill{background:linear-gradient(to top,rgba(239,228,212,1),rgba(239,228,212,.4));} +.ch.is-hot .ch-bead{background:var(--bone);} + +/* ————— transport ————— */ + +.rail--transport{ + display:flex;align-items:center;gap:clamp(12px,1.8vw,28px); + padding:0 var(--pad); + border-top:1px solid rgba(232,64,42,.22); + background:linear-gradient(to top, rgba(5,3,3,.95), rgba(5,3,3,.4)); + backdrop-filter:blur(3px); + min-width:0; +} +.tp{display:flex;align-items:center;gap:9px;min-width:0;} +.tp--go{gap:4px;} + +.play{ + width:38px;height:38px;flex:0 0 auto; + border:1px solid rgba(239,228,212,.22); + border-radius:50%; + display:grid;place-items:center; + color:var(--bone); + transition:border-color .35s ease,color .35s ease,background .35s ease; +} +.play svg{width:19px;height:19px;fill:none;stroke:currentColor;stroke-width:1;stroke-linejoin:round;stroke-linecap:round;} +.play:hover{border-color:rgba(239,228,212,.6);} +.is-playing .play{border-color:rgba(232,64,42,.9);color:var(--ember);background:rgba(232,64,42,.06);} +.i-pause{display:none;} +.is-playing .i-play{display:none;} +.is-playing .i-pause{display:block;} + +.icon{ + width:30px;height:30px;display:grid;place-items:center; + color:rgba(155,133,121,.75); + transition:color .3s ease; +} +.icon svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1;} +.icon:hover{color:var(--bone);} +.icon--rec svg circle{fill:transparent;transition:fill .3s ease,stroke .3s ease;} +.icon--rec:hover{color:var(--ember);} +.icon--rec[aria-pressed="true"]{color:var(--ember);} +.icon--rec[aria-pressed="true"] svg circle{fill:var(--ember);stroke:var(--ember);} + +.tp--clock{gap:12px;} +.tp-label{font-size:7px;letter-spacing:.16em;color:rgba(109,91,82,.95);white-space:nowrap;} +.tp-bars{font-size:11px;color:var(--ash);font-variant-numeric:tabular-nums;min-width:32px;} +.tp-time{ + font-family:var(--mono);font-weight:200; + font-size:22px;line-height:1;letter-spacing:.01em; + color:var(--bone); + font-variant-numeric:tabular-nums; + white-space:nowrap; +} +.tp-time .ms{color:rgba(155,133,121,.7);} + +.tp--tempo{gap:9px;padding-left:2px;} +.tempo-stack{display:flex;flex-direction:column;align-items:flex-start;gap:2px;} +.tp-num{ + font-size:12px;letter-spacing:.02em;color:var(--bone); + font-variant-numeric:tabular-nums; + border-bottom:1px solid rgba(232,64,42,.3); + padding:0 1px 1px; + cursor:ns-resize; +} +.tp-num:hover{border-color:rgba(232,64,42,.8);} +.tp-sub{font-size:7px;letter-spacing:.07em;color:rgba(155,133,121,.7);display:flex;align-items:center;gap:6px;white-space:nowrap;} +.tp-rule{width:7px;height:1px;background:rgba(239,228,212,.16);} + +.tp--knobs{gap:12px;padding-left:4px;} +.knob{width:44px;display:grid;justify-items:center;gap:3px;cursor:ns-resize;} +.knob svg{width:32px;height:32px;overflow:visible;} +.k-track{fill:none;stroke:rgba(155,133,121,.22);stroke-width:.9;} +.k-arc{fill:none;stroke:var(--ember);stroke-width:1;opacity:.9;} +.k-tick{stroke:var(--bone);stroke-width:1.1;stroke-linecap:round;} +.knob-name{font-size:6.5px;letter-spacing:.05em;color:rgba(155,133,121,.7);white-space:nowrap;} +.knob:hover .knob-name{color:var(--bone);} + +.tp--in{gap:7px;padding-left:2px;} +.srcs{display:flex;align-items:center;gap:9px;} +.src{ + font-size:7.5px;letter-spacing:.05em; + color:rgba(155,133,121,.5); + padding:3px 0; + position:relative; + transition:color .3s ease; +} +.src:hover{color:var(--bone);} +.src.is-on{color:rgba(239,228,212,.9);} +.src.is-on::before{ + content:"";position:absolute;left:50%;top:-2px;width:3px;height:3px; + background:var(--ember);transform:translateX(-50%); + box-shadow:0 0 7px rgba(232,64,42,.9); +} +.src.is-denied{color:rgba(232,64,42,.7);} + +.tp--scope{flex:1 1 160px;min-width:110px;max-width:360px;gap:9px;align-items:center;} +#scope{width:100%;height:26px;display:block;opacity:.95;} +.tp--master{flex:0 1 210px;flex-direction:column;align-items:stretch;gap:5px;min-width:120px;max-width:210px;} +.master-top{display:flex;align-items:baseline;gap:7px;} +.tp-db{font-size:7.5px;color:rgba(155,133,121,.85);margin-left:auto;font-variant-numeric:tabular-nums;} +.snd{ + font-size:7px;letter-spacing:.1em;color:rgba(155,133,121,.5); + border-bottom:1px solid transparent;padding-bottom:1px;margin-left:6px; +} +.snd[aria-pressed="true"]{color:rgba(239,228,212,.8);border-color:rgba(232,64,42,.4);} +.snd:hover{color:var(--bone);} +.meter{position:relative;height:2px;background:rgba(155,133,121,.16);overflow:hidden;} +.meter-fill{ + position:absolute;left:0;top:0;bottom:0;width:100%; + background:linear-gradient(to right,rgba(168,39,26,.9),var(--ember)); + transform:scaleX(0);transform-origin:left center; +} +.meter-peak{ + position:absolute;top:-2px;bottom:-2px;width:1px; + background:rgba(239,228,212,.85); + left:0; +} + +.tp--end{margin-left:auto;gap:16px;} +.ghost{ + font-size:8px;letter-spacing:.13em; + color:rgba(155,133,121,.85); + padding:2px 0; + border-bottom:1px solid rgba(155,133,121,.3); + transition:color .3s ease,border-color .3s ease; +} +.ghost:hover{color:var(--bone);border-color:rgba(239,228,212,.6);} +.ghost--ember{color:var(--ember);border-color:rgba(232,64,42,.45);} +.ghost--ember:hover{color:var(--bone);border-color:var(--bone);} + +/* ————— responsive ————— */ + +@media (max-width:1340px){ + :root{--rail-l:222px;--rail-r:204px;--pad:17px;} + .tp--knobs{display:none;} + .tp--master{max-width:190px;} + .doc-meta{display:none;} +} + +@media (max-width:1160px){ + :root{--rail-l:208px;--rail-r:192px;} + .tp--in{display:none;} + .mode{padding-left:14px;} + .tp--master{max-width:190px;} +} + +@media (max-width:980px){ + .sky{min-height:280px;} + .tp--end{display:none;} + .tp--clock{gap:9px;} + .tp-time{font-size:20px;} +} + +@media (max-width:860px){ + html,body{height:auto;min-height:100%;overflow-x:hidden;} + body{overflow-y:auto;} + .shell{height:auto;min-height:100dvh;grid-template-rows:46px auto auto auto auto;} + .sky{padding:26px 20px 22px;min-height:0;} + .sky-inner{top:0;width:min(520px,88vw);} + .void-wrap{margin-bottom:20px;} + .deck{grid-template-columns:minmax(0,1fr);grid-auto-rows:auto;} + .agent{border-bottom:1px solid var(--line-soft);} + .agent::after{display:none;} + .agent .log{max-height:220px;} + .corridor{min-height:420px;border-bottom:1px solid var(--line-soft);} + .side{grid-template-columns:1fr 1fr;grid-template-rows:minmax(210px,auto);} + .side::before{display:none;} + .crystal{border-right:1px solid var(--line-soft);} + .cilia{border-top:0;} + .faders{padding-left:12px;padding-right:12px;} + .shell{padding-bottom:66px;} + .rail--transport{ + position:fixed;left:0;right:0;bottom:0;z-index:6; + height:66px; + border-top:1px solid rgba(232,64,42,.28); + background:rgba(5,3,3,.94); + } + .tp--master{max-width:none;flex:1;} +} + +@media (max-width:760px){ + .tp--scope{display:none;} + .tp--master{min-width:0;flex:1;} +} + +@media (max-width:600px){ + :root{--pad:14px;} + .rail--top{gap:12px;} + .doc{display:none;} + .brand{font-size:16px;} + .mode{margin-left:auto;padding-left:0;border-left:0;} + .net{display:none;} + .sky{padding-top:16px;} + .void-wrap{width:104px;} + #halo{inset:-64px;width:calc(100% + 128px);height:calc(100% + 128px);} + #prompt{font-size:19px;} + .hint{display:none;} + .corridor{min-height:340px;} + .corridor-sub .sub{font-size:7px;} + .side{grid-template-columns:1fr;grid-template-rows:minmax(190px,auto) minmax(210px,auto);} + .crystal{border-right:0;border-bottom:1px solid var(--line-soft);} + .cilia{border-top:0;} + .shell{padding-bottom:58px;} + .rail--transport{gap:10px;height:58px;} + .play{width:32px;height:32px;} + .play svg{width:16px;height:16px;} + .icon{width:24px;height:24px;} + .tp--clock{gap:8px;} + .tp-bars{display:none;} + .tp-time{font-size:17px;} + .tp--tempo{display:none;} + .tp--master{min-width:0;} + .snd{display:none;} + .master-top{justify-content:space-between;} + .tp-db{font-size:7px;} + .now-label{display:none;} +} + +@media (prefers-reduced-motion:reduce){ + *,*::before,*::after{ + animation-duration:.001ms !important; + animation-iteration-count:1 !important; + transition-duration:.001ms !important; + } + #terrain{transform:none;} +} + +/* ———————————————————————————————————————————————— + QUARRY · below the nave + ———————————————————————————————————————————————— */ + +body[data-view="quarry"] .sky, +body[data-view="quarry"] .deck{display:none;} +.quarry-tray,.quarry-deck{display:none;} +body[data-view="quarry"] .quarry-tray{display:grid;} +body[data-view="quarry"] .quarry-deck{display:grid;} + +.quarry-tray{ + grid-template-rows:auto minmax(0,1fr); + min-height:0;min-width:0; + position:relative; + animation:room-in .7s cubic-bezier(.2,.7,.2,1) both; +} +.quarry-deck{ + grid-template-columns:var(--rail-l) minmax(0,1fr) var(--rail-r); + grid-template-rows:minmax(0,1fr) 58px; + min-height:0;min-width:0; + animation:room-in .8s cubic-bezier(.2,.7,.2,1) both; +} +@keyframes room-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}} + +.tray-head{ + display:flex;align-items:baseline;justify-content:space-between;gap:20px; + padding:16px 20px 8px; + border-bottom:1px solid var(--line-soft); +} +.tray-id{display:flex;align-items:baseline;gap:14px;min-width:0;} +.tray-meta{ + display:flex;align-items:center;gap:12px; + font-size:8px;letter-spacing:.05em;color:var(--ash); + white-space:nowrap; +} +.tray-num{font-variant-numeric:tabular-nums;color:rgba(239,228,212,.72);} +.ghost--small{font-size:7.5px;letter-spacing:.14em;} + +.seal{display:inline-flex;align-items:center;gap:6px;font-size:7.5px;letter-spacing:.11em;color:var(--ash);} +.seal i{ + width:8px;height:8px;flex:0 0 auto;border-radius:50%; + border:1px solid rgba(232,64,42,.75);position:relative; +} +.seal i::after{ + content:"";position:absolute;inset:2px;border-radius:50%; + background:rgba(232,64,42,.85); +} +.seal--found i::after{border-radius:0;inset:1px;background:none;border:1px solid rgba(232,64,42,.85);} +.seal--loan i::after{inset:3px 1px;border-radius:0;background:rgba(232,64,42,.85);transform:rotate(45deg);} +.seal--unknown i{border-style:dashed;border-color:rgba(155,133,121,.6);} +.seal--unknown i::after{inset:3px;background:rgba(155,133,121,.5);} +.seal--gift i::after{inset:2px;background:none;border:1px solid rgba(239,228,212,.8);} + +.tray-stage{ + position:relative;min-height:0; + background:radial-gradient(70% 100% at 50% 45%, rgba(4,2,2,.55), rgba(4,2,2,.05) 78%); +} +#tray{position:absolute;inset:0;width:100%;height:100%;} +#tray-overlay{position:absolute;inset:0;width:100%;height:100%;cursor:ew-resize;} +.tray-hint{ + position:absolute;right:20px;bottom:8px; + font-size:7px;letter-spacing:.16em;color:rgba(155,133,121,.4); + pointer-events:none; +} +.tray-core-title{ + font-family:var(--mono);font-size:7px;letter-spacing:.14em; + fill:rgba(155,133,121,.6); +} +.tray-tick{stroke:rgba(155,133,121,.3);stroke-width:.7;} +.tray-tick--major{stroke:rgba(155,133,121,.5);} +.tray-tick-text{font-family:var(--mono);font-size:6.5px;fill:rgba(155,133,121,.55);} +.tray-cut{stroke:rgba(232,64,42,.55);stroke-width:.8;stroke-dasharray:1 3;} +.tray-cut-text{font-family:var(--mono);font-size:6.5px;fill:rgba(232,64,42,.8);letter-spacing:.04em;} +.tray-lens{stroke:var(--ember);stroke-width:1.25;shape-rendering:crispEdges;filter:drop-shadow(0 0 4px rgba(232,64,42,.6));} +.tray-lens-handle{fill:var(--ember);} +.tray-lens-text{font-family:var(--mono);font-size:7px;fill:rgba(239,228,212,.85);} + +/* store */ +.store{display:flex;flex-direction:column;min-height:0;position:relative;} +.store::after{ + content:"";position:absolute;top:0;bottom:0;right:0;width:1px; + background:linear-gradient(to bottom,rgba(232,64,42,0),rgba(232,64,42,.2) 16%,rgba(232,64,42,.2) 72%,rgba(232,64,42,0)); + pointer-events:none; +} +.store-list{flex:1;min-height:0;overflow-y:auto;padding:8px 0 10px;scrollbar-width:thin;scrollbar-color:rgba(232,64,42,.25) transparent;} +.store-list::-webkit-scrollbar{width:3px;} +.store-list::-webkit-scrollbar-thumb{background:rgba(232,64,42,.25);} +.core-item{ + display:grid;grid-template-columns:48px minmax(0,1fr);gap:10px;align-items:center; + width:100%;padding:7px 6px 7px 8px;text-align:left; + border-left:1px solid transparent;position:relative; + transition:background .3s ease,border-color .3s ease; +} +.core-item + .core-item{border-top:1px solid rgba(239,228,212,.055);} +.core-item:hover{background:rgba(232,64,42,.035);} +.core-item.is-on{border-left-color:var(--ember);background:rgba(232,64,42,.05);} +.core-thumb{display:block;width:48px;height:58px;} +.core-name{display:block;font-size:9px;line-height:1.45;color:rgba(239,228,212,.78);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} +.core-item.is-on .core-name{color:var(--bone);} +.core-meta{display:flex;align-items:center;gap:6px;margin-top:3px;font-size:6.8px;letter-spacing:.04em;color:rgba(155,133,121,.72);white-space:nowrap;overflow:hidden;} +.core-meta .seal{font-size:6.8px;gap:4px;} +.core-meta .seal i{width:6px;height:6px;} + +.choir-call{ + display:flex;align-items:flex-end;gap:8px; + border-top:1px solid var(--line-soft); + padding-top:9px; +} +#choir-input{ + flex:1;min-width:0;background:none;border:0;outline:none; + border-bottom:1px solid rgba(232,64,42,.3); + padding:2px 0 4px; + font-family:var(--mono);font-size:9px;font-weight:300;letter-spacing:.01em; + color:var(--bone); + transition:border-color .3s ease; +} +#choir-input::placeholder{color:rgba(155,133,121,.62);} +#choir-input:focus{border-color:rgba(232,64,42,.8);} +#choir-input:disabled{color:rgba(239,228,212,.35);} +.choir-call button{ + font-size:7.5px;letter-spacing:.14em;color:var(--ash); + border-bottom:1px solid rgba(232,64,42,.4);padding-bottom:3px; + transition:color .3s ease; +} +.choir-call button:hover{color:var(--bone);} +.choir-call.is-out button{color:rgba(232,64,42,.9);border-color:transparent;} +.extract-btn{align-self:flex-start;margin-top:9px;} + +/* the face */ +.faces{display:flex;flex-direction:column;min-height:0;min-width:0;padding-top:16px;} +.faces-head{padding:0 20px 8px;border-bottom:1px solid var(--line-soft);} +.face-wall{flex:1;min-height:0;position:relative;padding:12px 20px 0;} +.face-wall svg{width:100%;height:100%;display:block;overflow:visible;} +.face-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 20px 10px;} +.face-status{font-size:7.5px;letter-spacing:.05em;color:rgba(155,133,121,.85);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} +.place-group{display:flex;align-items:center;gap:14px;flex:0 0 auto;} +#place:disabled,#place-ring:disabled{color:rgba(109,91,82,.5);border-color:transparent;cursor:default;} + +.stone{cursor:pointer;} +.stone-body{ + fill:rgba(5,3,2,.9); + stroke:rgba(232,64,42,.36); + stroke-width:.9; + transition:fill .25s ease,stroke .25s ease; +} +.stone-body--a{fill:url(#stoneGradA);} +.stone-body--b{fill:url(#stoneGradB);} +.stone-body--c{fill:url(#stoneGradC);} +.stone-body--d{fill:url(#stoneGradD);} +.stone-lip{fill:none;stroke:rgba(239,228,212,.38);stroke-width:.9;stroke-linecap:round;} +.stone-foot-lip{fill:none;stroke:rgba(0,0,0,.55);stroke-width:1;} +.stone-face--inner{stroke:rgba(239,228,212,.07);} +.stone:hover .stone-body{fill:rgba(34,11,7,.94);stroke:rgba(239,228,212,.55);} +.stone.is-sel .stone-body{stroke:rgba(239,228,212,.8);fill:rgba(30,10,7,.92);} +.stone.is-struck .stone-body{fill:rgba(120,30,18,.5);stroke:var(--bone);} +.stone-face{fill:none;stroke:rgba(239,228,212,.17);stroke-width:.7;transition:stroke .25s ease;} +.stone-grain{fill:none;stroke:rgba(232,64,42,.42);stroke-width:.8;} +.stone-grain--faint{stroke:rgba(239,228,212,.14);stroke-width:.6;} +.stone-crack{fill:none;stroke:rgba(239,228,212,.18);stroke-width:.65;} +.stone-pit{fill:rgba(0,0,0,.55);stroke:rgba(232,64,42,.24);stroke-width:.5;} +.stone-strata{fill:none;stroke:rgba(239,228,212,.13);stroke-width:.6;} +.stone-num{font-family:var(--mono);font-size:7px;fill:rgba(155,133,121,.7);letter-spacing:.06em;} +.stone-seal{fill:none;stroke:rgba(155,133,121,.65);stroke-width:.7;} +.stone-seal-dot{fill:rgba(232,64,42,.8);} +.stone:hover .stone-num{fill:rgba(239,228,212,.8);} +.stone-flash{ + fill:none;stroke:rgba(232,64,42,.8);opacity:0; + transform-box:fill-box;transform-origin:center; +} +.stone.is-struck .stone-flash{animation:chip .5s cubic-bezier(.2,.7,.2,1);} +@keyframes chip{ + 0%{opacity:.8;transform:scale(.2);} + 100%{opacity:0;transform:scale(1.4);} +} + +/* weather */ +.weather{display:flex;flex-direction:column;min-height:0;position:relative;} +.weather::before{ + content:"";position:absolute;top:0;bottom:0;left:0;width:1px; + background:linear-gradient(to bottom,rgba(232,64,42,0),rgba(232,64,42,.2) 16%,rgba(232,64,42,.2) 72%,rgba(232,64,42,0)); + pointer-events:none; +} +.weather-list{padding:9px 0 2px;} +.climate{font-size:7px;letter-spacing:.07em;color:rgba(109,91,82,.95);padding:3px 0 12px;} +.ring-block{ + border-top:1px solid var(--line-soft); + padding-top:10px; + display:flex;flex-direction:column;gap:7px; +} +.ring-head{display:flex;align-items:baseline;justify-content:space-between;} +.ring-head strong{ + font-family:var(--serif);font-weight:300; + font-size:24px;line-height:1;color:rgba(239,228,212,.88); + font-variant-numeric:lining-nums; +} +.ring-visual{height:74px;position:relative;} +.ring-visual svg{width:100%;height:100%;display:block;} +.ring-arc{fill:none;stroke:rgba(232,64,42,.55);stroke-width:.8;} +.ghost-toggle{ + display:flex;align-items:center;justify-content:space-between; + border-top:1px solid var(--line-soft); + margin-top:auto;padding-top:10px; +} +.toggle{ + font-size:8px;letter-spacing:.12em;color:var(--bone); + border-bottom:1px solid rgba(232,64,42,.45);padding-bottom:1px; +} +.toggle[aria-pressed="false"]{color:rgba(109,91,82,.9);border-color:transparent;} + +/* ledger */ +.ledger{ + grid-column:1 / -1; + display:grid;grid-template-columns:112px minmax(0,1fr);gap:18px;align-items:center; + padding:0 20px; + border-top:1px solid var(--line); + background:linear-gradient(to top,rgba(5,3,3,.9),rgba(5,3,3,.25)); + min-width:0; +} +.ledger-head{display:flex;flex-direction:column;gap:1px;font-size:8px;letter-spacing:.2em;color:rgba(239,228,212,.72);} +.ledger-sub{font-size:6.5px;letter-spacing:.06em;color:rgba(109,91,82,.95);white-space:nowrap;} +.ledger-list{display:flex;gap:20px;min-width:0;overflow:hidden;height:100%;align-items:center;} +.ledger-item{ + flex:1;min-width:0; + border-left:1px solid rgba(232,64,42,.22); + padding-left:10px; + display:flex;flex-direction:column;gap:2px; + animation:rise .45s cubic-bezier(.2,.7,.2,1) both; +} +.ledger-top{display:flex;align-items:baseline;gap:7px;font-size:7.5px;min-width:0;} +.ledger-t{color:rgba(109,91,82,.95);font-variant-numeric:tabular-nums;} +.ledger-tool{color:rgba(239,228,212,.88);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} +.ledger-by{margin-left:auto;font-size:6.5px;letter-spacing:.1em;color:rgba(155,133,121,.6);flex:0 0 auto;} +.ledger-by.is-mcp{color:rgba(232,64,42,.85);} +.ledger-detail{font-size:7px;color:rgba(155,133,121,.8);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} +.ledger-detail .seal{font-size:6.5px;gap:4px;} +.ledger-detail .seal i{width:6px;height:6px;} + +/* ————— quarry responsive ————— */ + +@media (max-width:1340px){ + .tray-meta{gap:9px;} + .ledger{grid-template-columns:96px minmax(0,1fr);gap:12px;padding:0 16px;} +} + +@media (max-width:1160px){ + .views{padding-right:12px;gap:7px;} + .mode{padding-left:12px;} + .tray-meta .sub-rule:nth-of-type(3){display:none;} +} + +@media (max-width:860px){ + .quarry-tray{grid-template-rows:auto minmax(0,1fr);min-height:320px;} + .tray-stage{min-height:210px;} + .quarry-deck{ + grid-template-columns:minmax(0,1fr); + grid-template-rows:auto auto auto auto; + } + .store{min-height:300px;} + .store::after,.weather::before{display:none;} + .store-list{max-height:220px;} + .faces{min-height:400px;border-top:1px solid var(--line-soft);border-bottom:1px solid var(--line-soft);} + .weather{min-height:320px;} + .ledger{grid-column:auto;grid-row:auto;grid-template-columns:1fr;gap:4px;padding:8px 14px;min-height:64px;} + .ledger-list{gap:12px;} + .ledger-item:nth-child(3){display:none;} + .tray-head{padding:12px 14px 8px;flex-wrap:wrap;gap:4px 12px;} + .tray-meta{flex-wrap:wrap;gap:8px;} + .faces-head,.face-foot{padding-left:14px;padding-right:14px;} + .face-wall{padding:10px 14px 0;} +} + +@media (max-width:600px){ + .views{padding-right:8px;} + .view-btn{font-size:8.5px;} + .tray-meta{font-size:7px;gap:7px;} + .tray-meta .sub-rule{display:none;} + .quarry-tray{min-height:290px;} + .tray-stage{min-height:190px;} + .store{min-height:280px;} + .faces{min-height:340px;} + .weather{min-height:300px;} + .ledger-head{display:none;} + .ledger{grid-template-columns:1fr;min-height:56px;} + .ledger-item:nth-child(2){display:none;} + .ring-visual{height:48px;} +} diff --git a/reference/demo/terrain.js b/reference/demo/terrain.js new file mode 100644 index 0000000..87aa7c7 --- /dev/null +++ b/reference/demo/terrain.js @@ -0,0 +1,399 @@ +/* жила · рельеф + A single contour field over the whole surface: domain-warped fbm for the strata, + a radial well under the eye, perspective compression below the horizon, + and black pools cut where the interface needs to speak. */ + +(function () { + 'use strict'; + + const P = { + scale: 1 / 1.35, // field resolution vs. screen + density: 17, // contour levels per unit of the field + freq: 4.7, // base frequency of the strata + oct: 5, + warp: 0.66, + well: 2.35, + line: 0.68, // half-width of a contour, in pixels + bloom: 0.5 + }; + const SCALE = P.scale; + const canvas = document.getElementById('terrain'); + const ctx = canvas.getContext('2d', { alpha: false }); + const spiresCanvas = document.getElementById('spires'); + if (!canvas) return; + + const anchors = { + eye: { x: 0.5, y: 0.3, sig: 0.13 }, + hor: 0.6 + }; + + /* ————— noise ————— */ + + function hash(x, y) { + let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263); + h = Math.imul(h ^ (h >>> 13), 1274126177); + h ^= h >>> 16; + return (h >>> 0) / 4294967296; + } + + function noise(x, y) { + const xi = Math.floor(x), yi = Math.floor(y); + const xf = x - xi, yf = y - yi; + const u = xf * xf * (3 - 2 * xf); + const v = yf * yf * (3 - 2 * yf); + const a = hash(xi, yi), b = hash(xi + 1, yi); + const c = hash(xi, yi + 1), d = hash(xi + 1, yi + 1); + const ab = a + (b - a) * u; + const cd = c + (d - c) * u; + return ab + (cd - ab) * v; + } + + function fbm(x, y, oct) { + let s = 0, amp = 0.5, f = 1, norm = 0; + for (let i = 0; i < oct; i++) { + s += amp * noise(x * f, y * f); + norm += amp; + f *= 2.04; + amp *= 0.5; + } + return s / norm; + } + + const smooth = (a, b, x) => { + let t = (x - a) / (b - a); + t = t < 0 ? 0 : t > 1 ? 1 : t; + return t * t * (3 - 2 * t); + }; + const clamp01 = v => v < 0 ? 0 : v > 1 ? 1 : v; + + /* ————— anchors: where the eye and the horizon actually are ————— */ + + function measure() { + const voidEl = document.querySelector('.void'); + const stage = document.getElementById('stage'); + const h = window.innerHeight; + if (voidEl) { + const r = voidEl.getBoundingClientRect(); + anchors.eye.x = (r.left + r.width / 2) / h; + anchors.eye.y = (r.top + r.height / 2) / h; + anchors.eye.sig = (r.width * 0.95) / h; + } + if (stage) { + const sr = stage.getBoundingClientRect(); + anchors.hor = (sr.top + sr.height * 0.28) / h; + } + } + + /* ————— black pools around the interface ————— */ + + function blob(g, x, y, w, h, seed) { + const pad = 15; + x -= pad; y -= pad; w += pad * 2; h += pad * 2; + let s = seed * 9301 + 49297; + const rnd = () => { s = (s * 9301 + 49297) % 233280; return s / 233280; }; + const jit = 9; + const step = 34; + const nx = Math.max(2, Math.round(w / step)); + const ny = Math.max(2, Math.round(h / step)); + const pts = []; + for (let i = 0; i <= nx; i++) pts.push([x + (w * i) / nx, y + (rnd() - 0.5) * jit]); + for (let i = 1; i <= ny; i++) pts.push([x + w + (rnd() - 0.5) * jit, y + (h * i) / ny]); + for (let i = nx - 1; i >= 0; i--) pts.push([x + (w * i) / nx, y + h + (rnd() - 0.5) * jit]); + for (let i = ny - 1; i > 0; i--) pts.push([x + (rnd() - 0.5) * jit, y + (h * i) / ny]); + g.beginPath(); + g.moveTo(pts[0][0], pts[0][1]); + for (let i = 1; i < pts.length; i++) g.lineTo(pts[i][0], pts[i][1]); + g.closePath(); + } + + let maskCache = null; + + function buildMask(mw, mh) { + const c = document.createElement('canvas'); + c.width = mw; c.height = mh; + const g = c.getContext('2d'); + g.clearRect(0, 0, mw, mh); + g.fillStyle = '#fff'; + g.filter = 'blur(11px)'; + const els = Array.prototype.slice.call(document.querySelectorAll('[data-pool]')); + els.forEach((el, i) => { + const r = el.getBoundingClientRect(); + if (r.width < 4 || r.height < 4) return; + if (r.bottom < -40 || r.top > window.innerHeight + 40) return; + g.globalAlpha = parseFloat(el.dataset.pool) || 0.9; + blob(g, r.left * SCALE, r.top * SCALE, r.width * SCALE, r.height * SCALE, i + 3); + g.fill(); + }); + g.filter = 'none'; + const data = g.getImageData(0, 0, mw, mh).data; + maskCache = data; + return data; + } + + /* ————— the field ————— */ + + function render() { + measure(); + const W = canvas.clientWidth || window.innerWidth; + const H = canvas.clientHeight || window.innerHeight; + const dpr = Math.min(1.6, window.devicePixelRatio || 1); + canvas.width = Math.round(W * dpr); + canvas.height = Math.round(H * dpr); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.fillStyle = '#080504'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const mw = Math.max(8, Math.round(W * SCALE)); + const mh = Math.max(8, Math.round(H * SCALE)); + const mask = buildMask(mw, mh); + const e = anchors.eye; + const hor = anchors.hor; + const sig2 = 2 * e.sig * e.sig; + const field = new Float32Array(mw * mh); + + /* pass 1: the terrain itself */ + for (let j = 0; j < mh; j++) { + const py = (j + 0.5) / mh; + const below = smooth(hor - 0.01, hor + 0.12, py); + const dd = Math.max(py - hor, 0.006); + let persp = 1.55 / dd; + if (persp > 2.15) persp = 2.15; + const sy = 1 + below * (persp - 1) * 0.8; + const sx = 1 + below * 0.3; + + for (let i = 0; i < mw; i++) { + const px = (i + 0.5) / mh; + const wx = fbm(px * 2.05 + 13.7, py * 2.42 + 4.2, 3); + const wy = fbm(px * 2.05 + 7.1, py * 2.42 + 9.6, 3); + const qx = px + (wx - 0.5) * P.warp; + const qy = py + (wy - 0.5) * P.warp; + const dx = px - e.x, dy = py - e.y; + const well = Math.exp(-(dx * dx + dy * dy) / sig2); + let n = fbm(qx * P.freq * sx + 19.1, qy * P.freq * sy + 2.7, P.oct); + n -= P.well * well; + field[j * mw + i] = n; + } + } + + /* pass 2: contours with a constant ink width */ + const img = new ImageData(mw, mh); + const data = img.data; + const px2 = 1 / mh; + + for (let j = 0; j < mh; j++) { + const py = (j + 0.5) / mh; + const below = smooth(hor - 0.01, hor + 0.12, py); + const dim = 1 - 0.4 * below; + const j0 = Math.max(0, j - 1), j1 = Math.min(mh - 1, j + 1); + for (let i = 0; i < mw; i++) { + const idx = j * mw + i; + const n = field[idx]; + const i0 = Math.max(0, i - 1), i1 = Math.min(mw - 1, i + 1); + const gx = (field[j * mw + i1] - field[j * mw + i0]) * 0.5; + const gy = (field[j1 * mw + i] - field[j0 * mw + i]) * 0.5; + let g = Math.sqrt(gx * gx + gy * gy); + if (g < 0.00035) g = 0.00035; + + const v = n * P.density; + const fr = v - Math.floor(v); + const edge = (fr < 0.5 ? fr : 1 - fr) / P.density; // distance to the line, in field units + const ink = P.line * g * (0.82 + 0.5 * Math.min(1, Math.abs(n) * 2)); + let line = 1 - edge / ink; + if (line < 0) line = 0; + line = Math.pow(line, 0.8); + + const px = (i + 0.5) / mh; + const dx = px - e.x, dy = py - e.y; + const well = Math.exp(-(dx * dx + dy * dy) / sig2); + + const m = 1 - (mask[idx * 4 + 3] / 255) * 0.975; + const vy = 1 - Math.abs(py - 0.12) * 0.9; + const a = clamp01(line * m * dim * (1 + 0.4 * well) * (0.82 + 0.3 * vy)); + const wash = clamp01((0.018 + 0.13 * well) * m * dim * (1 - line) * (0.7 + 0.6 * vy)); + const hot = clamp01(well * 1.1 + n * 0.42); + const p = idx * 4; + data[p] = 196 + 52 * hot; + data[p + 1] = 40 + 30 * hot + 14 * n; + data[p + 2] = 28 + 16 * hot; + data[p + 3] = (a + wash * (1 - a)) * 255; + } + } + + const tmp = document.createElement('canvas'); + tmp.width = mw; tmp.height = mh; + tmp.getContext('2d').putImageData(img, 0, 0); + + ctx.save(); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.globalCompositeOperation = 'lighter'; + ctx.globalAlpha = P.bloom; + ctx.filter = 'blur(8px)'; + ctx.drawImage(tmp, 0, 0, canvas.width, canvas.height); + ctx.filter = 'none'; + ctx.globalAlpha = 1; + ctx.drawImage(tmp, 0, 0, canvas.width, canvas.height); + ctx.restore(); + + const g = ctx.createRadialGradient( + canvas.width * 0.5, canvas.height * 0.36, 0, + canvas.width * 0.5, canvas.height * 0.36, Math.max(canvas.width, canvas.height) * 0.78 + ); + g.addColorStop(0, 'rgba(0,0,0,0)'); + g.addColorStop(1, 'rgba(0,0,0,.38)'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + + /* ————— the city at the end of the road ————— */ + + function mulberry32(a) { + return function () { + a |= 0; a = a + 0x6D2B79F5 | 0; + let t = Math.imul(a ^ a >>> 15, 1 | a); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; + } + + function spire(g, x, base, h, w, rnd, alpha) { + const tipX = x + (rnd() - 0.5) * w * 0.32; + const tipY = base - h; + + const halfAt = function (t) { + let k = Math.pow(1 - t, 0.62); + k *= 1 + 0.1 * Math.sin(t * 7 + w * 0.5); + if (t > 0.34) k *= 1 - 0.15 * (t - 0.34); + return Math.max(0.5, w * 0.5 * k); + }; + const cxAt = function (t) { return x + (tipX - x) * Math.pow(t, 1.6); }; + + const N = 20; + const pts = []; + for (let i = 0; i <= N; i++) { + const t = i / N; + pts.push([cxAt(t) - halfAt(t), base - h * t]); + } + pts.push([tipX, tipY]); + for (let i = N; i >= 0; i--) { + const t = i / N; + pts.push([cxAt(t) + halfAt(t), base - h * t]); + } + + const body = new Path2D(); + body.moveTo(pts[0][0], pts[0][1]); + for (let i = 1; i < pts.length; i++) body.lineTo(pts[i][0], pts[i][1]); + body.closePath(); + + g.fillStyle = 'rgba(58,42,35,.34)'; + g.fill(body); + g.strokeStyle = 'rgba(220,206,190,' + (0.5 * alpha).toFixed(2) + ')'; + g.lineWidth = 0.8; + g.stroke(body); + + /* ribs: the vertical grain of the crystal */ + const ribs = 4; + g.lineWidth = 0.6; + g.strokeStyle = 'rgba(208,194,180,' + (0.26 * alpha).toFixed(2) + ')'; + for (let k = -ribs; k <= ribs; k++) { + const f = k / ribs; + g.beginPath(); + g.moveTo(x + f * w * 0.48, base); + g.lineTo(cxAt(1) + f * 1.6, tipY + 1); + g.stroke(); + } + + /* strata, straight: a wireframe, not a vase */ + const bands = 7 + Math.floor(rnd() * 5); + g.strokeStyle = 'rgba(208,194,180,' + (0.16 * alpha).toFixed(2) + ')'; + for (let i = 1; i <= bands; i++) { + const t = Math.pow(i / (bands + 1), 1.1); + const cx = cxAt(t); + const y = base - h * t; + const hw = halfAt(t) * 0.97; + g.beginPath(); + g.moveTo(cx - hw, y); + g.lineTo(cx + hw, y); + g.stroke(); + } + + /* a few braces, to catch the light */ + g.strokeStyle = 'rgba(208,194,180,' + (0.13 * alpha).toFixed(2) + ')'; + for (let i = 1; i <= 2; i++) { + const t0 = 0.16 + i * 0.22; + const t1 = t0 + 0.12; + const hw0 = halfAt(t0), hw1 = halfAt(t1); + g.beginPath(); + g.moveTo(cxAt(t0) - hw0, base - h * t0); + g.lineTo(cxAt(t1) + hw1, base - h * t1); + g.moveTo(cxAt(t0) + hw0, base - h * t0); + g.lineTo(cxAt(t1) - hw1, base - h * t1); + g.stroke(); + } + + if (rnd() > 0.35) { + g.beginPath(); + g.moveTo(tipX, tipY + 2); + g.lineTo(tipX, tipY - h * 0.14); + g.strokeStyle = 'rgba(224,210,194,' + (0.4 * alpha).toFixed(2) + ')'; + g.lineWidth = 0.7; + g.stroke(); + } + } + + function drawSpires() { + if (!spiresCanvas) return; + const W = spiresCanvas.clientWidth; + const H = spiresCanvas.clientHeight; + if (!W || !H) return; + const dpr = Math.min(2, window.devicePixelRatio || 1); + spiresCanvas.width = Math.round(W * dpr); + spiresCanvas.height = Math.round(H * dpr); + const g = spiresCanvas.getContext('2d'); + g.setTransform(dpr, 0, 0, dpr, 0, 0); + g.clearRect(0, 0, W, H); + + const horizon = H * 0.7; + const dark = g.createLinearGradient(0, 0, 0, horizon); + dark.addColorStop(0, 'rgba(5,3,2,.62)'); + dark.addColorStop(0.72, 'rgba(5,3,2,.2)'); + dark.addColorStop(1, 'rgba(5,3,2,0)'); + g.fillStyle = dark; + g.fillRect(0, 0, W, horizon + 2); + + const haze = g.createLinearGradient(0, horizon - H * 0.55, 0, horizon + H * 0.14); + haze.addColorStop(0, 'rgba(232,64,42,0)'); + haze.addColorStop(1, 'rgba(232,64,42,.1)'); + g.fillStyle = haze; + g.fillRect(0, horizon - H * 0.55, W, H); + + const layers = [ + { hMin: 0.14, hMax: 0.42, alpha: 0.42, wid: 0.8, seed: 41, spread: 0.045 }, + { hMin: 0.3, hMax: 1.0, alpha: 1, wid: 1, seed: 94, spread: 0.05 } + ]; + const clusters = [0.045, 0.15, 0.27, 0.335, 0.665, 0.73, 0.85, 0.955]; + layers.forEach(function (L) { + const rnd = mulberry32(L.seed); + const picked = []; + clusters.forEach(function (cx) { + const n = 1 + Math.floor(rnd() * 2.6); + for (let i = 0; i < n; i++) { + const t = cx + (rnd() - 0.5) * L.spread * 2; + if (t < 0.02 || t > 0.98) continue; + if (t > 0.39 && t < 0.61) continue; + const edge = Math.min(1, Math.abs(t - 0.5) * 2.1); + picked.push({ + x: t * W, + h: H * (L.hMin + rnd() * (L.hMax - L.hMin)) * (0.55 + 0.6 * edge), + w: (7 + rnd() * 16) * L.wid * (0.75 + 0.5 * edge), + a: L.alpha * (0.75 + rnd() * 0.25) + }); + } + }); + picked.sort(function (a, b) { return a.h - b.h; }); + picked.forEach(function (p) { spire(g, p.x, horizon + 2, p.h, p.w, rnd, p.a); }); + }); + } + + window.ZHILA_TERRAIN = { render, drawSpires, measure, P }; +})(); diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..4551461 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +echo "· rust fmt" +cargo fmt --all -- --check +echo "· clippy" +cargo clippy --workspace --all-targets -- -D warnings +echo "· rust tests" +cargo test --workspace +echo "· svelte check" +npm --prefix ui run check +echo "· surface build" +npm --prefix ui run build +echo +echo "all checks passed" diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..dee1eec --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +cleanup() { + trap - EXIT INT TERM + kill 0 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +echo "ZHILA" +echo " daemon ws://127.0.0.1:4317 (token: ${ZHILA_TOKEN:-dev})" +echo " surface http://127.0.0.1:5173" +echo + +cargo run -p zhila-daemon -- --port 4317 & +npm --prefix ui run dev & + +wait diff --git a/scripts/wire-smoke.mjs b/scripts/wire-smoke.mjs new file mode 100644 index 0000000..81e2f9b --- /dev/null +++ b/scripts/wire-smoke.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +// End-to-end wire check against a running zhila-daemon. +// Usage: node scripts/wire-smoke.mjs +const URL = process.env.ZHILA_WS ?? 'ws://127.0.0.1:4317'; +const TOKEN = process.env.ZHILA_TOKEN ?? 'dev'; + +const ws = new WebSocket(URL); +ws.binaryType = 'arraybuffer'; + +let hello = null; +let snapshot = null; +let frames = 0; +let maxMaster = 0; +const pending = new Map(); +const events = []; +let failure = null; + +const timeout = setTimeout(() => fail(new Error('wire smoke timed out after 12s')), 12_000); + +function fail(error) { + if (failure) return; + failure = error; + clearTimeout(timeout); + try { ws.close(); } catch {} + console.error('FAIL', error.message); + process.exit(1); +} + +function done() { + clearTimeout(timeout); + try { ws.close(); } catch {} + console.log(JSON.stringify({ + protocol: hello?.protocol, + session: hello?.session, + audio: snapshot?.audio, + patternRows: snapshot?.pattern?.rows?.length, + steps: snapshot?.pattern?.steps, + frames, + maxMaster: +maxMaster.toFixed(3), + events: events.map(e => e.kind ?? e.type), + tempo: snapshot?.transport?.bpm, + revision: snapshot?.revision + }, null, 2)); + process.exit(0); +} + +function send(id, command) { + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ type: 'command', id, command })); + }); +} + +ws.onopen = () => { + ws.send(JSON.stringify({ type: 'hello', protocol: 1, token: TOKEN, client: 'wire-smoke' })); +}; + +ws.onerror = () => fail(new Error('websocket error (is the daemon running?)')); + +ws.onmessage = async (message) => { + if (failure) return; + if (message.data instanceof ArrayBuffer) { + const view = new DataView(message.data); + if (view.byteLength !== 31 || view.getUint8(0) !== 1) return; + frames++; + const master = view.getFloat32(7, true); + if (master > maxMaster) maxMaster = master; + return; + } + let parsed; + try { parsed = JSON.parse(message.data); } catch { return; } + if (parsed.type === 'hello') { + hello = parsed; + return; + } + if (parsed.type === 'snapshot') { + snapshot = parsed.state; + try { + await send(1, { type: 'play' }); + await new Promise(r => setTimeout(r, 1600)); + await send(2, { type: 'strike', vein: 'bass', velocity: 0.9 }); + await new Promise(r => setTimeout(r, 700)); + await send(3, { type: 'set_tempo', bpm: 132 }); + await new Promise(r => setTimeout(r, 300)); + await send(4, { type: 'stop' }); + if (!frames) throw new Error('no meter frames received'); + if (!(maxMaster > 0)) throw new Error('meter never left zero — engine may be silent'); + if (!snapshot.audio?.running) throw new Error('audio reported offline'); + if (snapshot.pattern?.rows?.length !== 5) throw new Error('pattern is incomplete'); + done(); + } catch (error) { + fail(error); + } + return; + } + if (parsed.type === 'event') { + events.push(parsed.event); + return; + } + if (parsed.type === 'result') { + const entry = pending.get(parsed.id); + if (!entry) return; + pending.delete(parsed.id); + if (parsed.result?.state) snapshot = parsed.result.state; + entry.resolve(parsed.result); + return; + } + if (parsed.type === 'error') { + const entry = parsed.id != null ? pending.get(parsed.id) : null; + if (entry) { pending.delete(parsed.id); entry.reject(new Error(parsed.message)); } + else fail(new Error(parsed.message)); + } +}; diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..8cb225e --- /dev/null +++ b/ui/index.html @@ -0,0 +1,16 @@ + + + + + + + + + ZHILA · first descent + + + +
    + + + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..bb4c3d8 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,1576 @@ +{ + "name": "zhila-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zhila-ui", + "version": "0.1.0", + "dependencies": { + "@fontsource/cormorant-garamond": "^5.0.0", + "@fontsource/jetbrains-mono": "^5.0.0" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0", + "@types/node": "^22.0.0", + "playwright-core": "1.56.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.4.0", + "vite": "^7.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fontsource/cormorant-garamond": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/cormorant-garamond/-/cormorant-garamond-5.3.0.tgz", + "integrity": "sha512-weuGsCirGVWBWqpt6YUp0yLThTe4G8YO45noq8wxeJCRvEpq5lFrxNMFSTxcyOyV5k5otzJ8guDzYegdYlcLMQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.67.3.tgz", + "integrity": "sha512-o4kGHKu2YRn/jegh1XMs6+1/3SuX6AfWaACGEWavFvN5D6YZd9OrMEBqcIMj0uTeKZr6nTXPhlpkc0IXC6DMXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.67.3.tgz", + "integrity": "sha512-vfEEFajQ0K5crrtAAyownI2ZFthoY+BTwGnjoMgd4+l3YPuAcweykaMqcMYXT1wJKCnMiSIpNnAroAWix2fJ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-x64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-x64/-/rollup-android-x64-4.67.3.tgz", + "integrity": "sha512-/oNPEUU5nVDZmp1CYGDMIGdUBzON5z5RQufJbtjnOD4+CZYt//2ECHRvvmJL3EXLHaqQ7Zjr1dSTlhLCxJJbGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.67.3.tgz", + "integrity": "sha512-Otbw33hEDwrM/jUn3nib5JLJL0vyTE0ZlmU8L44sCS+AJm0JiZs3ZlNUYj141L4k7XxYmOWtf+9cN2Kqog7OzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.67.3.tgz", + "integrity": "sha512-3CNtrc+6X9HL+fzSkKF0o3WkBdO6YBm5PhbLnR9PeQNKLLL7ir4MDtlx6gvP9ZM3Uxjue3xVysAX7+VjWX66Ng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.67.3.tgz", + "integrity": "sha512-KfHwLGjSfgOKHoyzhqqKNhohxNErfYRR2dNWXN6kn5iWMrDNldZopob+Ft/q6aurweYHRz+7M9HuFZ7nvYj5Xg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.67.3.tgz", + "integrity": "sha512-fXWMWNshsQrNi9pq8FJIpTZfLjhLUqDCabW1hq2Qv1kxzz7I63fRjFRNAC0C49xK9CyUngL/tWbSYjpKo2+GRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.67.3.tgz", + "integrity": "sha512-22gepfGL1b0Aay36BYddC4mqvQoUgQwGU/5zrUK2BdJucWgWytbhxY4o3iSIbtZsSsFiiRlNT2xfAjrCsDQGQw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.67.3.tgz", + "integrity": "sha512-l04417gOo3y7xaQu52PeXGgy17dSPAa8qhs4iqxMDHKyAUpldNFsasy0kR+Z7CFifnTgwFGmyukDWLIZYEnyQQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.67.3.tgz", + "integrity": "sha512-0SEhbnWkUv29OhSANlnnOKQSWs5KFoJ0FTQ3ZhxZunUjgBagQzzUmLgxpulYSpldXAGOCK5XLvvspoPngjEWpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.67.3.tgz", + "integrity": "sha512-2wLIbgSqKUcEc4DHrQDHOg9LSfgfSr+rAoBj9wromzLMLSclupvEKHTEugGSuGuklrF7wF0dzXo9R5VymKXgcg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.67.3.tgz", + "integrity": "sha512-cV3cQixj3ILfdOmvJeXu6GedaH/Xlm4/qtNAUxvVK0tQnzICits97mwuvWY/DZs0lEZTkbCqZmpO/+ne6hs5iQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.67.3.tgz", + "integrity": "sha512-DTnriHdEmvrGgPWHVoV8LfwkLsmG2oK9GYvlfV6+beMcdVWVYnNC3R6+jL9DhTVOIpv+Ys9A+1i9rM8aeayOZw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.67.3.tgz", + "integrity": "sha512-2+v1frx+8t084SIinkBBURODj1lpTqLZO53INfozQa0+bnCNEGtRFag8Gi6OMV9kEK67zIRIqncfyKe1eIYGHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.67.3.tgz", + "integrity": "sha512-vEi902pAjMHXPXOpU4Jr58jMMiZxTf8kkhis1xiLBtLtlm6oRaX+mgFjOsqMdbGt80lS+bfnTCEsAofbK9isLA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.67.3.tgz", + "integrity": "sha512-1Nm/kYmkKDcc9gJ9cRVsRPBrNUqLEaY5b2zDk2OsQMcWd2nJiFUpQf3q4T1IEVKgyUQSMuiVNDemLXcuKh/f9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.67.3.tgz", + "integrity": "sha512-bx93yN/wit1aY9lja1lRh2jfRPFDBfeI9kHoytXDi1Jw6dWz2P2zd6B3oKnQOHa5fTtpHmX9/swWOA2+jWLjKw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.67.3.tgz", + "integrity": "sha512-Nt1/uFKauyn2bwGcYaIcF9DA3TORW45XrtdhThm5QPu0TNDpDC5onMNSh8fzvB+klrnLKTCISCw8lR9xpxrEFA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.67.3.tgz", + "integrity": "sha512-K8HqIGH9CD57XmZFo/0T3pafDAzAoB9Iof02FEFvOXg7lBn7vlkLEjHgtZkYp+srWANrlxTSf8BolQM/27kiCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.67.3.tgz", + "integrity": "sha512-6vTihCOamPjOpgcjkXOFB+Lh+LM4Eb95EPYNHjcovwxsnQrFRIJ7M664fLnGklKFOZutrhR9/o+NfgQLE4Zmdg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.67.3.tgz", + "integrity": "sha512-k5XxCKgPGASSGyXOhXVQQJM6VIRHteqwHBsbwhW55EoimmI2jfkPebwgg/lwcjezpvgyLu8YTTCRsdSrvHKA7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.67.3.tgz", + "integrity": "sha512-WYqJUhonki85lB0iQ/Ro9BNJiGd7rG7GBZBiB/h4EUBWiUKyI3vq6EZ1aTyy5Y8qVuSIx4ZdqDyAXYiR59Vtvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.67.3.tgz", + "integrity": "sha512-WJgIOxBAnyZy2nXZdSk1xtDP0nmU41qfB47jCZ5VCxCipt2/aoImPtXMyPYB1fzXcpmNsAtYxBCY2d2WFYo00g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.67.3.tgz", + "integrity": "sha512-C0vJ/s69Fll/puogTouGiFkn7kFI7nIAQc8/PTVawth9VNpNYIpv9xrZr6syRaFzSxVhTUt5zoXUFLELGs6vVA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.67.3.tgz", + "integrity": "sha512-UFCIgrIBg2jU6Ajkrx+AwGz0EWpYCRTjr6wh+jBCY+A6r8pCDeeejTtMbbBszRdnmtUCzlXwwA4h/f4f3zPRSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.67.3.tgz", + "integrity": "sha512-tlynfm3LIQtOCjFdT7kjyMnGrl0PbhyldgCfj7b1VxafD6uYiJFjd2ATzmYb+YnzNIzNezpK7XzFzDtPu37IeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright-core": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz", + "integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.67.3.tgz", + "integrity": "sha512-HJXOntSbeXzBfjUQ+YfgcOR+NV+8queObyCqWWOy2YL6bXDFE7vNRgVXlOUyrQqfJnxdXcL27PXlkC0O3ze6jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.10" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.67.3", + "@rollup/rollup-android-arm64": "4.67.3", + "@rollup/rollup-android-x64": "4.67.3", + "@rollup/rollup-darwin-arm64": "4.67.3", + "@rollup/rollup-darwin-x64": "4.67.3", + "@rollup/rollup-freebsd-arm64": "4.67.3", + "@rollup/rollup-freebsd-x64": "4.67.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.67.3", + "@rollup/rollup-linux-arm-musleabihf": "4.67.3", + "@rollup/rollup-linux-arm64-gnu": "4.67.3", + "@rollup/rollup-linux-arm64-musl": "4.67.3", + "@rollup/rollup-linux-loong64-gnu": "4.67.3", + "@rollup/rollup-linux-loong64-musl": "4.67.3", + "@rollup/rollup-linux-ppc64-gnu": "4.67.3", + "@rollup/rollup-linux-ppc64-musl": "4.67.3", + "@rollup/rollup-linux-riscv64-gnu": "4.67.3", + "@rollup/rollup-linux-riscv64-musl": "4.67.3", + "@rollup/rollup-linux-s390x-gnu": "4.67.3", + "@rollup/rollup-linux-x64-gnu": "4.67.3", + "@rollup/rollup-linux-x64-musl": "4.67.3", + "@rollup/rollup-openbsd-x64": "4.67.3", + "@rollup/rollup-openharmony-arm64": "4.67.3", + "@rollup/rollup-win32-arm64-msvc": "4.67.3", + "@rollup/rollup-win32-ia32-msvc": "4.67.3", + "@rollup/rollup-win32-x64-gnu": "4.67.3", + "@rollup/rollup-win32-x64-msvc": "4.67.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.57.3", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.3.tgz", + "integrity": "sha512-5mAftM3SoDV9X0zdF386F9p2pv8iKOwi/KfBS1XnBAkVCKFzVOLHUoa8iGWAt4buoicLmyWVRpuuD5wplXOQsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.9.2", + "esm-env": "^1.2.1", + "esrap": "^2.3.6", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.7.tgz", + "integrity": "sha512-Ge2IMdeTpbJuzNFGRiiMg0RWgbScysKQu7xwcyFJ22+DU9F5nPX51+Q3gHmGqRxYQMx2knreVWt5oCtfe0vf2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "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 + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..44d143e --- /dev/null +++ b/ui/package.json @@ -0,0 +1,26 @@ +{ + "name": "zhila-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --port 5173 --strictPort", + "build": "vite build", + "preview": "vite preview --port 5173 --strictPort", + "check": "svelte-check --tsconfig ./tsconfig.json", + "smoke": "node scripts/smoke.mjs" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0", + "@types/node": "^22.0.0", + "playwright-core": "1.56.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.4.0", + "vite": "^7.0.0" + }, + "dependencies": { + "@fontsource/cormorant-garamond": "^5.0.0", + "@fontsource/jetbrains-mono": "^5.0.0" + } +} diff --git a/ui/public/favicon.svg b/ui/public/favicon.svg new file mode 100644 index 0000000..c2fd633 --- /dev/null +++ b/ui/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/scripts/smoke.mjs b/ui/scripts/smoke.mjs new file mode 100644 index 0000000..3475cb0 --- /dev/null +++ b/ui/scripts/smoke.mjs @@ -0,0 +1,52 @@ +import { chromium } from 'playwright-core'; + +const chrome = process.env.CHROME_PATH ?? '/home/emil/.cache/ms-playwright/chromium-1248/chrome-linux64/chrome'; +const browser = await chromium.launch({ executablePath: chrome, args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); +const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, deviceScaleFactor: 1 }); +const errors = []; +page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`)); +page.on('console', (message) => { if (message.type() === 'error') errors.push(`console: ${message.text()}`); }); +await page.goto('http://127.0.0.1:5173/', { waitUntil: 'load' }); +await page.waitForTimeout(2800); +await page.screenshot({ path: '../preview-p0.png' }); +if (await page.locator('.play.is-playing').count()) { + await page.click('.play'); + await page.waitForTimeout(400); +} +await page.click('.play'); +await page.waitForTimeout(3000); +await page.click('.strike'); +await page.waitForTimeout(1300); +await page.screenshot({ path: '../preview-p0-playing.png' }); +const report = await page.evaluate(() => ({ + net: document.querySelector('.net b')?.textContent, + status: document.querySelector('.wire-status')?.textContent?.trim(), + wire: [...document.querySelectorAll('.wire-label')].slice(-8).map((el) => el.textContent), + facts: [...document.querySelectorAll('.fact b')].map((el) => el.textContent), + lanes: [...document.querySelectorAll('.lane')].map((el) => el.getAttribute('aria-pressed')), + gains: [...document.querySelectorAll('.vein-gain')].map((el) => el.textContent), + cilia: [...document.querySelectorAll('.cilium')].map((el) => Number(getComputedStyle(el).getPropertyValue('--level'))), + trace: Boolean(document.querySelector('canvas.trace')), + time: document.querySelector('.clock-time')?.textContent, + bar: document.querySelector('.clock-bar')?.textContent, + master: document.querySelector('.master-db')?.textContent, + canvas: { w: document.querySelector('canvas')?.width, h: document.querySelector('canvas')?.height } +})); +console.log(JSON.stringify(report, null, 2)); +console.log(errors.length ? errors.join('\n') : 'no console errors'); + +const failures = []; +if (report.net !== 'live') failures.push('the engine did not report live'); +if (!report.wire.includes('play')) failures.push('the wire did not record play'); +if (!report.wire.includes('strike bass 0.85')) failures.push('the wire did not record the strike'); +if (!report.trace) failures.push('the signal trace is missing'); +if (!report.gains.length) failures.push('the vein gains are missing'); +if (!report.cilia.some((level) => level > 0.001)) failures.push('no cilia level moved'); +if (!(report.canvas.w > 1000)) failures.push('the descent canvas did not size to the hero'); +if (errors.length) failures.push('console errors'); +await browser.close(); +if (failures.length) { + console.error('SMOKE FAILED: ' + failures.join('; ')); + process.exit(1); +} +console.log('smoke passed'); diff --git a/ui/src/App.svelte b/ui/src/App.svelte new file mode 100644 index 0000000..5ad9047 --- /dev/null +++ b/ui/src/App.svelte @@ -0,0 +1,212 @@ + + + + +
    +
    +
    + ZHILA + 0.1 +
    +
    + nave + + hand + {project ? `${project.veins.length} veins` : 'five veins'} +
    +
    + + engine + {status === 'live' ? 'live' : status === 'connecting' ? 'opening' : 'offline'} +
    +
    + +
    + + + +
    + + +
    diff --git a/ui/src/app.css b/ui/src/app.css new file mode 100644 index 0000000..e3b7771 --- /dev/null +++ b/ui/src/app.css @@ -0,0 +1,1237 @@ +:root { + --resin: #0c0908; + --resin-2: #120c0a; + --soot: #1a100d; + --ember: #e8432a; + --blood: #a8271a; + --bone: #efe4d4; + --ash: #9b8579; + --ash-2: #6d5b52; + --line: rgba(232, 64, 42, 0.18); + --line-soft: rgba(155, 133, 121, 0.14); + --font-display: 'Cormorant Garamond', 'Iowan Old Style', 'Palatino Linotype', serif; + --font-mono: 'JetBrains Mono', 'SF Mono', 'Cascadia Mono', monospace; + --rail-h: 48px; + --transport-h: 66px; + --panel-l: 318px; + --panel-r: 274px; + --m: 0; + --bass: 0; + --crack: 0; + --voice: 0; + --noise: 0; + --silt: 0; + color-scheme: dark; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#app { + height: 100%; +} + +body { + margin: 0; + overflow: hidden; + background: var(--resin); + color: var(--bone); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +button { + appearance: none; + border: 0; + padding: 0; + margin: 0; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} + +button:focus-visible, +a:focus-visible, +[tabindex]:focus-visible { + outline: 1px solid var(--ember); + outline-offset: 3px; +} + +::selection { + background: var(--ember); + color: var(--resin); +} + +.app { + display: grid; + grid-template-rows: var(--rail-h) minmax(0, 1fr) var(--transport-h); + height: 100vh; + height: 100dvh; + min-height: 560px; + background: var(--resin); +} + +/* — rail — */ + +.rail { + position: relative; + z-index: 6; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0 18px; + border-bottom: 1px solid rgba(155, 133, 121, 0.12); + background: rgba(8, 5, 4, 0.9); + animation: rise 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) both; +} + +.rail::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 1px; + background: var(--ember); + opacity: calc(0.04 + var(--m) * 0.22); + pointer-events: none; +} + +.brand { + display: flex; + align-items: baseline; + gap: 9px; + min-width: 150px; +} + +.mark { + font-family: var(--font-display); + font-size: 21px; + font-weight: 400; + letter-spacing: 0.24em; + line-height: 1; + color: var(--bone); + padding-right: 1px; +} + +.version { + font-size: 9px; + letter-spacing: 0.08em; + color: var(--ash-2); + font-variant-numeric: tabular-nums; +} + +.rail-center { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.room { + position: relative; + padding-bottom: 3px; + font-family: var(--font-display); + font-size: 16px; + font-weight: 400; + color: var(--ash); + letter-spacing: 0.01em; +} + +.room.is-on { + color: var(--bone); +} + +.room.is-on::after { + content: ''; + position: absolute; + left: 0; + right: 1px; + bottom: 0; + height: 1px; + background: var(--ember); + opacity: 0.65; +} + +.mode-dot { + display: inline-block; + width: 3px; + height: 3px; + margin-right: 5px; + vertical-align: 2px; + border-radius: 50%; + background: var(--ember); + opacity: calc(0.4 + var(--bass) * 0.6); +} + +.rail-rule { + width: 1px; + height: 10px; + background: var(--line-soft); +} + +.rail-note { + font-size: 9px; + letter-spacing: 0.12em; + color: var(--ash-2); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.net { + display: flex; + align-items: center; + gap: 7px; + min-width: 128px; + justify-content: flex-end; + font-size: 9px; + letter-spacing: 0.08em; + color: var(--ash-2); +} + +.net b { + font-weight: 400; + color: var(--ash); +} + +.net.is-live b { + color: var(--ember); +} + +.net .dot { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--ash-2); + transition: background 0.2s ease; +} + +.net.is-live .dot { + background: var(--ember); + box-shadow: 0 0 calc(3px + var(--m) * 14px) rgba(232, 64, 42, 0.55); + opacity: calc(0.55 + var(--m) * 0.45); +} + +/* — hero — */ + +.hero { + position: relative; + min-height: 0; + overflow: hidden; + background: var(--resin); +} + +.descent { + position: absolute; + inset: 0; + z-index: 0; +} + +.descent canvas { + display: block; + width: 100%; + height: 100%; + cursor: crosshair; +} + +.scene { + position: absolute; + top: 24.5%; + left: 50%; + z-index: 1; + width: min(78%, 760px); + transform: translateX(-50%); + text-align: center; + pointer-events: none; +} + +.scene h1 { + margin: 0; + font-family: var(--font-display); + font-size: clamp(38px, 4vw, 54px); + font-weight: 300; + font-style: italic; + line-height: 1; + letter-spacing: 0.005em; + color: var(--bone); + text-shadow: 0 0 calc(12px + var(--voice) * 46px) rgba(232, 64, 42, 0.16); +} + +.scene p { + margin: 16px 0 0; + font-size: 9px; + line-height: 1.6; + letter-spacing: 0.16em; + color: var(--ash); + opacity: calc(0.55 + var(--voice) * 0.35 + var(--m) * 0.1); + font-variant-numeric: tabular-nums; +} + +.scene p::before { + content: ''; + display: block; + width: 56px; + height: 1px; + margin: 0 auto 13px; + background: linear-gradient(90deg, transparent, var(--ember), transparent); + opacity: 0.45; +} + +.lanes { + position: absolute; + left: 50%; + bottom: calc(10% - 34px); + z-index: 3; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + width: min(56%, 940px); + transform: translateX(-50%); + animation: fade 0.9s 0.1s ease both; +} + +.lane { + position: relative; + overflow: hidden; + padding: 9px 10px 7px; + border-top: 1px solid rgba(155, 133, 121, 0.2); + text-align: left; + color: rgba(239, 228, 212, calc(0.44 + var(--level) * 0.5)); + transition: color 0.2s ease, border-color 0.2s ease; +} + +.lane:hover, +.lane.is-on { + color: var(--bone); +} + +.lane.is-on { + border-top-color: var(--ember); +} + +.lane-fill { + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: calc(var(--level) * 100%); + background: linear-gradient(to top, rgba(232, 64, 42, 0.2), rgba(232, 64, 42, 0.02)); + border-bottom: 1px solid rgba(232, 64, 42, calc(0.2 + var(--level) * 0.5)); + transition: height 90ms linear; + pointer-events: none; +} + +.lane-name { + position: relative; + z-index: 1; + display: block; + font-size: 10px; + letter-spacing: 0.08em; + line-height: 1.4; +} + +/* — pools — */ + +.pool { + position: absolute; + top: 0; + bottom: 0; + z-index: 4; + display: flex; + flex-direction: column; + width: var(--panel-l); + padding: 20px 20px 0 22px; + background: linear-gradient(180deg, rgba(8, 5, 4, 0.95), rgba(8, 5, 4, 0.88)); + overflow: hidden; + animation: rise 0.75s 0.05s cubic-bezier(0.2, 0.7, 0.2, 1) both; +} + +.pool--wire { + left: 0; + border-right: 1px solid rgba(155, 133, 121, 0.13); +} + +.pool--engine { + right: 0; + width: var(--panel-r); + padding-right: 22px; + padding-bottom: 16px; + border-left: 1px solid rgba(155, 133, 121, 0.13); + overflow-y: auto; +} + +.pool::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: var(--ember); + opacity: calc(0.04 + var(--m) * 0.2); + pointer-events: none; +} + +.pool--wire::after { + right: 0; +} + +.pool--engine::after { + left: 0; +} + +.pool-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding-bottom: 13px; + border-bottom: 1px solid rgba(155, 133, 121, 0.12); +} + +.pool-head h2 { + margin: 0; + font-family: var(--font-display); + font-size: 25px; + font-weight: 300; + line-height: 1; + letter-spacing: 0.01em; + color: var(--bone); +} + +.pool-sub { + margin: 5px 0 0; + font-size: 8px; + line-height: 1.5; + letter-spacing: 0.14em; + color: var(--ash-2); +} + +.pool-state { + display: flex; + align-items: center; + gap: 6px; + padding-top: 3px; + font-size: 9px; + letter-spacing: 0.07em; + color: var(--ash-2); + white-space: nowrap; +} + +.pool-state .dot { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--ash-2); +} + +.pool-state.is-live { + color: var(--ash); +} + +.pool-state.is-live .dot { + background: var(--ember); + box-shadow: 0 0 calc(3px + var(--m) * 10px) rgba(232, 64, 42, 0.45); +} + +/* — wire — */ + +.wire-list { + flex: 1; + min-height: 0; + margin: 10px 0 0; + padding: 0 0 18px; + list-style: none; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(155, 133, 121, 0.3) transparent; +} + +.wire-list::-webkit-scrollbar { + width: 4px; +} + +.wire-list::-webkit-scrollbar-thumb { + background: rgba(155, 133, 121, 0.28); +} + +.wire-empty { + padding: 16px 0; + font-size: 10px; + color: var(--ash-2); +} + +.wire-entry { + display: grid; + grid-template-columns: 48px 13px minmax(0, 1fr); + gap: 6px; + align-items: baseline; + padding: 7px 0; + border-bottom: 1px solid rgba(155, 133, 121, 0.07); + animation: entry 0.35s ease both; +} + +.wire-time { + font-size: 8.5px; + letter-spacing: 0.02em; + color: var(--ash-2); + font-variant-numeric: tabular-nums; +} + +.wire-dir { + font-size: 10px; + line-height: 1.3; + text-align: center; + color: var(--ash-2); +} + +.wire-copy { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.wire-label { + font-size: 10px; + line-height: 1.55; + color: var(--bone); + overflow-wrap: anywhere; +} + +.wire-detail { + font-size: 9px; + line-height: 1.55; + color: var(--ash); + overflow-wrap: anywhere; +} + +.wire-entry--send .wire-dir { + color: var(--ember); +} + +.wire-entry--recv .wire-dir { + color: var(--bone); +} + +.wire-entry--error .wire-label, +.wire-entry--error .wire-dir { + color: var(--ember); +} + +.wire-foot { + display: flex; + flex-wrap: wrap; + gap: 13px; + padding: 11px 0 10px; + border-top: 1px solid rgba(155, 133, 121, 0.1); + font-size: 8.5px; + letter-spacing: 0.04em; + color: var(--ash-2); +} + +.wire-foot b { + font-weight: 400; + color: var(--ash); +} + +.wire-offline { + margin-top: auto; + padding: 14px 0 18px; + border-top: 1px solid rgba(155, 133, 121, 0.12); +} + +.wire-offline p { + margin: 0; + font-size: 10px; + line-height: 1.6; + color: var(--ash); +} + +.wire-offline code { + display: block; + margin-top: 9px; + padding: 8px 0 8px 9px; + border-left: 1px solid var(--ember); + font-family: var(--font-mono); + font-size: 8.5px; + line-height: 1.7; + color: var(--bone); + overflow-wrap: anywhere; +} + +.wire-offline button, +.reconnect { + margin-top: 12px; + padding-bottom: 2px; + border-bottom: 1px solid var(--ember); + font-size: 9px; + letter-spacing: 0.06em; + color: var(--ember); +} + +/* — engine — */ + +.facts { + display: flex; + flex-direction: column; + margin-top: 12px; +} + +.fact { + display: grid; + grid-template-columns: 64px minmax(0, 1fr); + gap: 8px; + padding: 4.5px 0; + border-bottom: 1px solid rgba(155, 133, 121, 0.07); + font-size: 9.5px; + line-height: 1.55; +} + +.fact span { + color: var(--ash-2); + letter-spacing: 0.04em; +} + +.fact b { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; + font-weight: 400; + color: var(--bone); +} + +.cilia-head { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-top: 19px; + padding-bottom: 8px; + font-size: 9px; + letter-spacing: 0.12em; + color: var(--ash-2); +} + +.cilia-master { + color: var(--ash); + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; +} + +.cilia { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 7px; + align-items: end; + height: 118px; +} + +.cilium { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + gap: 7px; + height: 100%; + min-width: 0; +} + +.cilium-track { + position: relative; + display: flex; + align-items: flex-end; + width: 1px; + height: 86px; + background: rgba(155, 133, 121, 0.16); +} + +.cilium-fill { + display: block; + width: 1px; + height: calc(var(--level) * 100%); + background: linear-gradient(to top, rgba(232, 64, 42, 0.25), var(--ember)); + box-shadow: 0 0 7px rgba(232, 64, 42, 0.28); + transition: height 80ms linear; +} + +.cilium-tip { + position: absolute; + left: 50%; + bottom: calc(var(--level) * 100%); + width: 3px; + height: 3px; + transform: translateX(-50%); + border-radius: 50%; + background: var(--ember); + opacity: calc(0.4 + var(--level) * 0.6); + box-shadow: 0 0 calc(3px + var(--level) * 12px) rgba(232, 64, 42, 0.5); + transition: bottom 80ms linear; +} + +.cilium-name { + font-size: 9px; + letter-spacing: 0.02em; + color: var(--ash); + white-space: nowrap; +} + +.master-track { + height: 1px; + margin-top: 10px; + background: rgba(155, 133, 121, 0.14); +} + +.master-track span { + display: block; + height: 1px; + background: var(--ember); + box-shadow: 0 0 6px rgba(232, 64, 42, 0.4); + transition: width 70ms linear; +} + +.veins-head { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-top: 16px; + padding-bottom: 5px; + font-size: 8px; + letter-spacing: 0.12em; + color: var(--ash-2); +} + +.veins { + display: flex; + flex-direction: column; + gap: 1px; +} + +.vein-row { + display: grid; + grid-template-columns: 42px minmax(0, 1fr) 27px; + gap: 8px; + align-items: center; + font-size: 9px; + line-height: 1.5; +} + +.vein-name { + color: var(--ash); + letter-spacing: 0.02em; +} + +.vein-gain { + color: var(--bone); + text-align: right; + font-variant-numeric: tabular-nums; +} + +.vein-range { + width: 100%; + height: 16px; + margin: 0; + appearance: none; + -webkit-appearance: none; + background: transparent; + cursor: pointer; +} + +.vein-range::-webkit-slider-runnable-track { + height: 1px; + background: rgba(155, 133, 121, 0.24); +} + +.vein-range::-webkit-slider-thumb { + width: 7px; + height: 7px; + margin-top: -3px; + border-radius: 50%; + background: var(--ember); + box-shadow: 0 0 6px rgba(232, 64, 42, 0.45); + -webkit-appearance: none; +} + +.vein-range::-moz-range-track { + height: 1px; + background: rgba(155, 133, 121, 0.24); +} + +.vein-range::-moz-range-thumb { + width: 7px; + height: 7px; + border: 0; + border-radius: 50%; + background: var(--ember); + box-shadow: 0 0 6px rgba(232, 64, 42, 0.45); +} + +.trace-head { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-top: auto; + padding-top: 17px; + padding-bottom: 6px; + font-size: 8px; + letter-spacing: 0.12em; + color: var(--ash-2); +} + +.trace { + display: block; + width: 100%; + height: 46px; +} + +/* — transport — */ + +.transport { + position: relative; + z-index: 6; + display: flex; + align-items: center; + gap: 18px; + min-width: 0; + padding: 0 20px; + border-top: 1px solid rgba(155, 133, 121, 0.14); + background: rgba(8, 5, 4, 0.94); + animation: rise 0.75s 0.12s cubic-bezier(0.2, 0.7, 0.2, 1) both; +} + +.transport-master { + position: absolute; + top: 0; + left: 0; + height: 1px; + width: calc(var(--master) * 100%); + background: var(--ember); + box-shadow: 0 0 8px rgba(232, 64, 42, 0.5); + transition: width 60ms linear; +} + +.play { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 40px; + height: 40px; + border: 1px solid rgba(232, 64, 42, 0.45); + border-radius: 50%; + background: rgba(232, 64, 42, 0.04); + transition: background 0.2s ease, border-color 0.2s ease; +} + +.play:hover, +.play.is-playing { + background: rgba(232, 64, 42, 0.12); + border-color: var(--ember); +} + +.icon-play { + width: 0; + height: 0; + margin-left: 3px; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-left: 9px solid var(--bone); +} + +.icon-stop { + width: 8px; + height: 8px; + background: var(--bone); +} + +.clock { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 104px; +} + +.clock-time { + font-size: 19px; + font-weight: 300; + line-height: 1.2; + letter-spacing: 0.03em; + color: var(--bone); + font-variant-numeric: tabular-nums; +} + +.clock-bar { + font-size: 8.5px; + letter-spacing: 0.08em; + color: var(--ash-2); + font-variant-numeric: tabular-nums; +} + +.tempo { + display: flex; + align-items: center; + gap: 6px; +} + +.tempo button { + display: grid; + place-items: center; + width: 19px; + height: 19px; + border: 1px solid rgba(155, 133, 121, 0.25); + color: var(--ash); + font-size: 12px; + line-height: 1; + transition: color 0.2s ease, border-color 0.2s ease; +} + +.tempo button:hover { + color: var(--ember); + border-color: var(--ember); +} + +.tempo-value { + min-width: 27px; + text-align: right; + font-size: 11px; + color: var(--bone); + font-variant-numeric: tabular-nums; +} + +.tempo-unit { + font-size: 8px; + letter-spacing: 0.08em; + color: var(--ash-2); +} + +.master-read { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 62px; +} + +.master-label { + font-size: 8px; + letter-spacing: 0.1em; + color: var(--ash-2); +} + +.master-db { + font-size: 9.5px; + color: var(--ash); + font-variant-numeric: tabular-nums; +} + +.strike { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; + padding: 8px 11px; + border: 1px solid rgba(232, 64, 42, 0.32); + background: rgba(232, 64, 42, 0.035); + color: var(--bone); + font-size: 9.5px; + letter-spacing: 0.06em; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.strike:hover { + border-color: var(--ember); + background: rgba(232, 64, 42, 0.1); +} + +.strike-glyph { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--ember); + box-shadow: 0 0 calc(3px + var(--m) * 12px) rgba(232, 64, 42, 0.55); + opacity: calc(0.55 + var(--m) * 0.45); +} + +.strike-vein { + color: var(--ember); + font-size: 8.5px; + letter-spacing: 0.05em; +} + +.wire-status { + display: flex; + align-items: center; + gap: 6px; + min-width: 122px; + font-size: 8.5px; + letter-spacing: 0.06em; + color: var(--ash-2); + white-space: nowrap; +} + +.status-dot { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--ash-2); +} + +.wire-status.is-live { + color: var(--ash); +} + +.wire-status.is-live .status-dot { + background: var(--ember); + box-shadow: 0 0 calc(3px + var(--m) * 10px) rgba(232, 64, 42, 0.45); +} + +.wire-status.is-offline { + color: var(--ember); +} + +.wire-status.is-offline .status-dot { + background: var(--ember); + opacity: 0.45; +} + +.wire-status .reconnect { + margin-top: 0; + margin-left: 2px; +} + +/* — motion — */ + +@keyframes rise { + from { + opacity: 0; + transform: translateY(7px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes entry { + from { + opacity: 0; + transform: translateY(2px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* — responsive — */ + +@media (max-width: 1240px) { + :root { + --panel-l: 286px; + --panel-r: 248px; + } + .scene h1 { + font-size: clamp(34px, 4.4vw, 50px); + } + .lanes { + width: min(58%, 780px); + } + .transport { + gap: 13px; + padding: 0 16px; + } + .clock { + min-width: 94px; + } +} + +@media (max-width: 980px) { + body { + overflow: auto; + } + .app { + height: auto; + min-height: 100dvh; + grid-template-rows: var(--rail-h) auto auto; + } + .hero { + display: flex; + flex-direction: column; + overflow: visible; + } + .descent { + position: relative; + inset: auto; + height: 58vh; + min-height: 420px; + width: 100%; + } + .scene { + top: 27%; + width: 86%; + } + .scene h1 { + font-size: clamp(34px, 8vw, 48px); + } + .lanes { + bottom: 12px; + width: 68%; + } + .pool { + position: relative; + inset: auto; + width: 100%; + max-height: none; + border: 0; + border-top: 1px solid rgba(155, 133, 121, 0.14); + padding: 20px 22px 16px; + } + .pool--wire { + border-right: 0; + } + .pool--engine { + width: 100%; + border-left: 0; + } + .pool::after { + display: none; + } + .wire-list { + max-height: 280px; + flex: none; + } + .wire-offline { + margin-top: 0; + } + .cilia { + height: 96px; + } + .cilium-track { + height: 68px; + } + .transport { + position: sticky; + bottom: 0; + flex-wrap: wrap; + height: auto; + min-height: 66px; + padding: 10px 16px; + gap: 12px 16px; + } + .strike { + margin-left: 0; + } + .wire-status { + margin-left: auto; + } +} + +@media (max-width: 640px) { + .rail { + padding: 0 13px; + gap: 8px; + } + .brand { + min-width: 0; + } + .mark { + font-size: 18px; + letter-spacing: 0.2em; + } + .rail-center { + gap: 8px; + } + .room { + font-size: 14px; + } + .rail-note { + display: none; + } + .net { + min-width: 0; + gap: 5px; + } + .net-label { + display: none; + } + .descent { + height: 62vh; + min-height: 380px; + } + .scene { + top: 25%; + } + .scene p { + font-size: 8px; + letter-spacing: 0.1em; + } + .lanes { + width: 84%; + } + .lane { + padding: 7px 5px 5px; + } + .lane-name { + font-size: 9px; + } + .transport { + padding: 10px 13px; + gap: 9px 12px; + } + .play { + width: 36px; + height: 36px; + } + .clock-time { + font-size: 17px; + } + .tempo { + gap: 4px; + } + .master-read { + min-width: 52px; + } + .strike { + padding: 7px 9px; + } + .wire-status { + width: 100%; + margin-left: 0; + justify-content: flex-end; + } + .pool { + padding: 18px 16px 14px; + } + .pool-head h2 { + font-size: 22px; + } + .facts .fact { + grid-template-columns: 58px minmax(0, 1fr); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} diff --git a/ui/src/components/Descent.svelte b/ui/src/components/Descent.svelte new file mode 100644 index 0000000..7d2fd64 --- /dev/null +++ b/ui/src/components/Descent.svelte @@ -0,0 +1,75 @@ + + + + +
    + + + + +
    + {#each veins as vein, index} + + {/each} +
    +
    diff --git a/ui/src/components/EnginePanel.svelte b/ui/src/components/EnginePanel.svelte new file mode 100644 index 0000000..6caa291 --- /dev/null +++ b/ui/src/components/EnginePanel.svelte @@ -0,0 +1,156 @@ + + + + + diff --git a/ui/src/components/Transport.svelte b/ui/src/components/Transport.svelte new file mode 100644 index 0000000..d3efa4a --- /dev/null +++ b/ui/src/components/Transport.svelte @@ -0,0 +1,87 @@ + + + + +
    + + + + +
    + {time} + bar {bar} / {loop} +
    + +
    + + {fmtTempo(bpm)} + + bpm +
    + +
    + master + {fmtDb(meter.master)} +
    + + + +
    + + {status === 'live' ? 'engine live' : status === 'connecting' ? 'connecting' : 'engine offline'} + {#if status === 'offline'}{/if} +
    +
    diff --git a/ui/src/components/Wire.svelte b/ui/src/components/Wire.svelte new file mode 100644 index 0000000..7ee8e5c --- /dev/null +++ b/ui/src/components/Wire.svelte @@ -0,0 +1,69 @@ + + + + + diff --git a/ui/src/lib/bridge.ts b/ui/src/lib/bridge.ts new file mode 100644 index 0000000..5007ce5 --- /dev/null +++ b/ui/src/lib/bridge.ts @@ -0,0 +1,204 @@ +import type { + Command, + CommandResult, + Event, + HelloMessage, + MeterFrame, + ServerMessage, + State, + Status +} from './types'; +import { METER_LEN, PROTOCOL_VERSION } from './types'; + +export interface BridgeHandlers { + onStatus: (status: Status) => void; + onHello: (message: HelloMessage) => void; + onState: (state: State) => void; + onMeter: (meter: MeterFrame) => void; + onEvent: (event: Event) => void; + onError: (message: string) => void; +} + +interface Pending { + resolve: (result: CommandResult) => void; + reject: (error: Error) => void; + timer: number; +} + +const COMMAND_TIMEOUT_MS = 5000; + +export class Bridge { + private ws: WebSocket | null = null; + private pending = new Map(); + private nextId = 0; + private closed = false; + private reconnectTimer: number | null = null; + private attempts = 0; + private status: Status = 'offline'; + + constructor( + private readonly url: string, + private readonly token: string, + private readonly handlers: BridgeHandlers + ) {} + + connect(): void { + if (this.closed || this.ws) return; + if (this.reconnectTimer !== null) { + window.clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.setStatus('connecting'); + let ws: WebSocket; + try { + ws = new WebSocket(this.url); + } catch (error) { + this.handlers.onError(error instanceof Error ? error.message : 'the wire address is invalid'); + this.scheduleReconnect(); + return; + } + ws.binaryType = 'arraybuffer'; + this.ws = ws; + + ws.onopen = () => { + this.attempts = 0; + ws.send(JSON.stringify({ + type: 'hello', + protocol: PROTOCOL_VERSION, + token: this.token, + client: 'zhila-ui' + })); + }; + + ws.onmessage = (message: MessageEvent) => { + this.receive(message.data); + }; + + ws.onerror = () => { + this.handlers.onError('the wire refused the connection'); + }; + + ws.onclose = () => { + if (this.ws === ws) this.ws = null; + this.rejectAll(new Error('the wire closed')); + this.setStatus('offline'); + if (!this.closed) this.scheduleReconnect(); + }; + } + + command(command: Command): Promise { + return new Promise((resolve, reject) => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + reject(new Error('the engine is not on the wire')); + return; + } + const id = ++this.nextId; + const timer = window.setTimeout(() => { + this.pending.delete(id); + reject(new Error('the engine did not answer in time')); + }, COMMAND_TIMEOUT_MS); + this.pending.set(id, { resolve, reject, timer }); + this.ws.send(JSON.stringify({ type: 'command', id, command })); + }); + } + + destroy(): void { + this.closed = true; + if (this.reconnectTimer !== null) { + window.clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.ws?.close(); + this.ws = null; + this.rejectAll(new Error('the wire was closed by the surface')); + this.setStatus('offline'); + } + + private scheduleReconnect(): void { + if (this.closed || this.reconnectTimer !== null) return; + const delay = Math.min(5000, 900 + this.attempts * 700); + this.attempts += 1; + this.reconnectTimer = window.setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + } + + private receive(data: unknown): void { + if (data instanceof ArrayBuffer) { + this.receiveMeter(data); + return; + } + if (typeof data !== 'string') return; + let message: ServerMessage; + try { + message = JSON.parse(data) as ServerMessage; + } catch { + return; + } + switch (message.type) { + case 'hello': + this.attempts = 0; + this.setStatus('live'); + this.handlers.onHello(message); + break; + case 'snapshot': + if (this.status !== 'live') this.setStatus('live'); + this.handlers.onState(message.state); + break; + case 'result': { + const pending = this.pending.get(message.id); + if (!pending) break; + this.pending.delete(message.id); + window.clearTimeout(pending.timer); + if (message.result.kind === 'snapshot' || message.result.kind === 'state') { + this.handlers.onState(message.result.state); + } + pending.resolve(message.result); + break; + } + case 'event': + this.handlers.onEvent(message.event); + break; + case 'error': { + if (message.id !== null) { + const pending = this.pending.get(message.id); + if (pending) { + this.pending.delete(message.id); + window.clearTimeout(pending.timer); + pending.reject(new Error(message.message)); + break; + } + } + this.handlers.onError(message.message); + break; + } + } + } + + private receiveMeter(data: ArrayBuffer): void { + if (data.byteLength !== METER_LEN) return; + const view = new DataView(data); + if (view.getUint8(0) !== 1) return; + if (view.getUint16(1, true) !== 5) return; + const bar = view.getFloat32(3, true); + const master = view.getFloat32(7, true); + const veins: number[] = []; + for (let i = 0; i < 5; i += 1) veins.push(view.getFloat32(11 + i * 4, true)); + this.handlers.onMeter({ bar, master, veins }); + } + + private rejectAll(error: Error): void { + for (const [, pending] of this.pending) { + window.clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + } + + private setStatus(status: Status): void { + if (this.status === status) return; + this.status = status; + this.handlers.onStatus(status); + } +} diff --git a/ui/src/lib/descent.ts b/ui/src/lib/descent.ts new file mode 100644 index 0000000..9747bbc --- /dev/null +++ b/ui/src/lib/descent.ts @@ -0,0 +1,703 @@ +import type { MeterFrame, Pattern, State, VeinId } from './types'; + +const VEINS: VeinId[] = ['bass', 'crack', 'voice', 'noise', 'silt']; +const STEPS_PER_BAR = 8; +const LOOP_STEPS = 16; +const TAU = Math.PI * 2; +const STONE_FILL = [ + 'rgba(232,64,42,', + 'rgba(168,39,26,', + 'rgba(239,228,212,', + 'rgba(155,133,121,', + 'rgba(109,91,82,' +]; +const STONE_GLOW = [ + 'rgba(232,64,42,', + 'rgba(168,39,26,', + 'rgba(239,228,212,', + 'rgba(155,133,121,', + 'rgba(109,91,82,' +]; +const CONTOUR_LEVELS = [-0.34, -0.255, -0.17, -0.085, 0, 0.085, 0.17, 0.255, 0.34]; + +export class Descent { + private ctx: CanvasRenderingContext2D; + private terrain: HTMLCanvasElement; + private glow: HTMLCanvasElement; + private w = 0; + private h = 0; + private dpr = 1; + private state: State | null = null; + private meter: MeterFrame = { bar: 1, master: 0, veins: [0, 0, 0, 0, 0] }; + private selected: VeinId = 'bass'; + private smooth = { master: 0, veins: [0, 0, 0, 0, 0] }; + private phaseStart = 0; + private wasPlaying = false; + private displayStep = 0; + private bpm = 128; + private raf = 0; + private last = 0; + private resizeTimer: number | null = null; + private observer: ResizeObserver | null = null; + private reduced = false; + private particles: { x: number; y: number; speed: number; phase: number; size: number; alpha: number }[] = []; + private lights: { x: number; y: number; size: number; phase: number; intensity: number }[] = []; + private road = { cx: 0, bottom: 0, horizon: 0, bhw: 0, thw: 0 }; + + constructor(private canvas: HTMLCanvasElement, private host: HTMLElement) { + const context = canvas.getContext('2d'); + if (!context) throw new Error('2d canvas is unavailable'); + this.ctx = context; + this.terrain = document.createElement('canvas'); + this.glow = document.createElement('canvas'); + this.buildGlow(); + this.reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + } + + start(): void { + this.observer = new ResizeObserver(() => this.onResize()); + this.observer.observe(this.host); + this.resize(); + this.last = performance.now(); + this.raf = requestAnimationFrame(this.frame); + } + + destroy(): void { + cancelAnimationFrame(this.raf); + if (this.resizeTimer !== null) window.clearTimeout(this.resizeTimer); + this.observer?.disconnect(); + } + + update(state: State | null, meter: MeterFrame, selected: VeinId): void { + if (state) { + this.state = state; + this.bpm = state.transport.bpm; + if (!state.transport.playing) this.wasPlaying = false; + } + this.meter = meter; + this.selected = selected; + } + + pick(clientX: number, clientY: number): VeinId | null { + const rect = this.canvas.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + const { cx, bottom, horizon, bhw, thw } = this.road; + if (y > bottom || y < horizon) return null; + const t = (bottom - y) / Math.max(1, bottom - horizon); + const half = bhw + (thw - bhw) * t; + const relative = (x - cx) / Math.max(1, half); + if (relative < -1 || relative > 1) return null; + const lane = Math.min(4, Math.max(0, Math.floor(((relative + 1) / 2) * 5))); + return VEINS[lane]; + } + + private onResize(): void { + if (this.resizeTimer !== null) window.clearTimeout(this.resizeTimer); + this.resizeTimer = window.setTimeout(() => { + this.resizeTimer = null; + this.resize(); + }, 140); + } + + private resize(): void { + const rect = this.host.getBoundingClientRect(); + const width = Math.max(320, Math.floor(rect.width)); + const height = Math.max(320, Math.floor(rect.height)); + this.w = width; + this.h = height; + this.dpr = Math.min(2, Math.max(1, window.devicePixelRatio || 1)); + this.canvas.width = Math.floor(width * this.dpr); + this.canvas.height = Math.floor(height * this.dpr); + this.canvas.style.width = width + 'px'; + this.canvas.style.height = height + 'px'; + this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + this.road = { + cx: width * 0.5, + bottom: height * 0.9, + horizon: height * 0.585, + bhw: Math.min(width * 0.3, 470), + thw: Math.max(56, width * 0.085) + }; + this.seedAtmosphere(); + this.buildTerrain(); + } + + private seedAtmosphere(): void { + const random = mulberry(0x5eed1234); + this.particles = Array.from({ length: 64 }, () => ({ + x: random(), + y: random(), + speed: 0.25 + random() * 1.4, + phase: random() * TAU, + size: random() > 0.72 ? 1.5 : 1, + alpha: 0.18 + random() * 0.5 + })); + this.lights = Array.from({ length: 26 }, () => { + const x = this.road.cx + (random() - 0.5) * this.w * 0.62; + return { + x, + y: this.road.horizon - random() * this.h * 0.085, + size: random() > 0.8 ? 2 : 1, + phase: random() * TAU, + intensity: 0.3 + random() * 0.7 + }; + }); + } + + private buildGlow(): void { + const size = 256; + this.glow.width = size; + this.glow.height = size; + const ctx = this.glow.getContext('2d'); + if (!ctx) return; + const gradient = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2); + gradient.addColorStop(0, 'rgba(232,64,42,0.55)'); + gradient.addColorStop(0.32, 'rgba(200,45,30,0.22)'); + gradient.addColorStop(0.7, 'rgba(120,25,18,0.06)'); + gradient.addColorStop(1, 'rgba(40,8,6,0)'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, size, size); + } + + private buildTerrain(): void { + if (!this.w || !this.h) return; + const { w, h, dpr } = this; + const canvas = this.terrain; + canvas.width = Math.floor(w * dpr); + canvas.height = Math.floor(h * dpr); + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const base = ctx.createLinearGradient(0, 0, 0, h); + base.addColorStop(0, '#0a0605'); + base.addColorStop(0.42, '#0d0807'); + base.addColorStop(0.7, '#110a08'); + base.addColorStop(1, '#080404'); + ctx.fillStyle = base; + ctx.fillRect(0, 0, w, h); + + const cell = 9; + const cols = Math.ceil(w / cell); + const rows = Math.ceil(h / cell); + const values = new Float32Array((cols + 1) * (rows + 1)); + for (let y = 0; y <= rows; y += 1) { + for (let x = 0; x <= cols; x += 1) { + values[y * (cols + 1) + x] = this.field((x * cell) / w, (y * cell) / h); + } + } + + for (let levelIndex = 0; levelIndex < CONTOUR_LEVELS.length; levelIndex += 1) { + const level = CONTOUR_LEVELS[levelIndex]; + const t = levelIndex / Math.max(1, CONTOUR_LEVELS.length - 1); + ctx.beginPath(); + for (let y = 0; y < rows; y += 1) { + for (let x = 0; x < cols; x += 1) { + const v00 = values[y * (cols + 1) + x]; + const v10 = values[y * (cols + 1) + x + 1]; + const v11 = values[(y + 1) * (cols + 1) + x + 1]; + const v01 = values[(y + 1) * (cols + 1) + x]; + const points: { x: number; y: number }[] = []; + if ((v00 < level) !== (v10 < level)) { + const k = (level - v00) / (v10 - v00 || 1); + points.push({ x: x * cell + k * cell, y: y * cell }); + } + if ((v10 < level) !== (v11 < level)) { + const k = (level - v10) / (v11 - v10 || 1); + points.push({ x: (x + 1) * cell, y: y * cell + k * cell }); + } + if ((v11 < level) !== (v01 < level)) { + const k = (level - v11) / (v01 - v11 || 1); + points.push({ x: (x + 1) * cell - k * cell, y: (y + 1) * cell }); + } + if ((v01 < level) !== (v00 < level)) { + const k = (level - v01) / (v00 - v01 || 1); + points.push({ x: x * cell, y: (y + 1) * cell - k * cell }); + } + if (points.length === 2) { + ctx.moveTo(points[0].x, points[0].y); + ctx.lineTo(points[1].x, points[1].y); + } else if (points.length === 4) { + ctx.moveTo(points[0].x, points[0].y); + ctx.lineTo(points[1].x, points[1].y); + ctx.moveTo(points[2].x, points[2].y); + ctx.lineTo(points[3].x, points[3].y); + } + } + } + ctx.strokeStyle = `rgba(${Math.round(148 + t * 92)}, ${Math.round(28 + t * 40)}, ${Math.round(20 + t * 26)}, ${0.15 + t * 0.4})`; + ctx.lineWidth = 0.7 + t * 0.7; + ctx.stroke(); + } + + const horizonWash = ctx.createLinearGradient(0, 0, 0, h); + horizonWash.addColorStop(0, 'rgba(4,2,2,0.34)'); + horizonWash.addColorStop(0.35, 'rgba(4,2,2,0)'); + horizonWash.addColorStop(0.78, 'rgba(8,4,3,0.12)'); + horizonWash.addColorStop(1, 'rgba(4,2,2,0.55)'); + ctx.fillStyle = horizonWash; + ctx.fillRect(0, 0, w, h); + + const vignette = ctx.createRadialGradient(w * 0.5, h * 0.42, Math.min(w, h) * 0.12, w * 0.5, h * 0.46, Math.max(w, h) * 0.74); + vignette.addColorStop(0, 'rgba(0,0,0,0)'); + vignette.addColorStop(0.62, 'rgba(0,0,0,0.12)'); + vignette.addColorStop(1, 'rgba(0,0,0,0.72)'); + ctx.fillStyle = vignette; + ctx.fillRect(0, 0, w, h); + + this.drawCity(ctx); + + const eyeLight = ctx.createRadialGradient(w * 0.5, h * 0.16, 0, w * 0.5, h * 0.16, Math.min(w, h) * 0.3); + eyeLight.addColorStop(0, 'rgba(232,64,42,0.12)'); + eyeLight.addColorStop(0.55, 'rgba(150,32,22,0.04)'); + eyeLight.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = eyeLight; + ctx.fillRect(0, 0, w, h); + } + + private drawCity(ctx: CanvasRenderingContext2D): void { + const { w, h } = this; + const horizon = this.road.horizon || h * 0.585; + const random = mulberry(0xc17a); + ctx.save(); + ctx.lineJoin = 'round'; + const back = ctx.createLinearGradient(0, horizon - h * 0.17, 0, horizon); + back.addColorStop(0, 'rgba(232,64,42,0)'); + back.addColorStop(1, 'rgba(232,64,42,0.06)'); + ctx.fillStyle = back; + ctx.fillRect(w * 0.08, horizon - h * 0.17, w * 0.84, h * 0.17); + for (let i = 0; i < 76; i += 1) { + const x = w * 0.14 + random() * w * 0.72; + const tall = random(); + const tower = h * (0.012 + tall * tall * 0.135); + const half = 1.2 + random() * (5 + tall * 4); + ctx.beginPath(); + ctx.moveTo(x - half, horizon); + ctx.lineTo(x + (random() - 0.5) * half, horizon - tower); + ctx.lineTo(x + half, horizon); + ctx.closePath(); + ctx.fillStyle = `rgba(8,4,3,${0.62 + random() * 0.3})`; + ctx.fill(); + ctx.strokeStyle = `rgba(190,160,140,${0.09 + tall * 0.2})`; + ctx.lineWidth = 0.7; + ctx.stroke(); + if (tall > 0.8) { + ctx.beginPath(); + ctx.moveTo(x, horizon - tower); + ctx.lineTo(x, horizon - tower - h * 0.015); + ctx.strokeStyle = 'rgba(239,228,212,0.24)'; + ctx.stroke(); + } + } + const line = ctx.createLinearGradient(w * 0.08, 0, w * 0.92, 0); + line.addColorStop(0, 'rgba(190,160,140,0)'); + line.addColorStop(0.5, 'rgba(190,160,140,0.32)'); + line.addColorStop(1, 'rgba(190,160,140,0)'); + ctx.strokeStyle = line; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(w * 0.08, horizon); + ctx.lineTo(w * 0.92, horizon); + ctx.stroke(); + ctx.restore(); + } + + private field(u: number, v: number): number { + const x = (u - 0.5) * 3.1; + const y = (v - 0.18) * 3.8; + const warp = fbm(x * 1.05 + 4.2, y * 1.05 + 1.7); + let value = fbm(x * 1.65 + warp * 1.55 + 7.3, y * 1.45 + warp * 1.1 + 2.1); + value -= 0.5; + const dx = (u - 0.5) * 1.7; + const dy = (v - 0.16) * 2.35; + value -= Math.exp(-(dx * dx + dy * dy) * 10.5) * 0.5; + value += Math.exp(-(((u - 0.5) * 2.8) ** 2 + ((v - 0.585) * 9.5) ** 2)) * 0.06; + return value; + } + + private frame = (now: number): void => { + const dt = Math.min(0.06, Math.max(0, (now - this.last) / 1000)); + this.last = now; + const k = 1 - Math.exp(-dt * 11); + this.smooth.master += (this.meter.master - this.smooth.master) * k; + for (let i = 0; i < 5; i += 1) { + const target = this.meter.veins[i] ?? 0; + this.smooth.veins[i] += (target - this.smooth.veins[i]) * k; + } + this.computeStep(now); + this.draw(now, dt); + this.raf = requestAnimationFrame(this.frame); + }; + + private computeStep(now: number): void { + const playing = this.state?.transport.playing ?? false; + const stepsPerSecond = (this.bpm / 60) * 2; + if (playing) { + if (!this.wasPlaying) { + this.phaseStart = now; + this.wasPlaying = true; + } + let step = (((now - this.phaseStart) / 1000) * stepsPerSecond) % LOOP_STEPS; + const meterStep = (((this.meter.bar - 1) * STEPS_PER_BAR) % LOOP_STEPS + LOOP_STEPS) % LOOP_STEPS; + let diff = (step - meterStep + LOOP_STEPS * 1.5) % LOOP_STEPS - LOOP_STEPS / 2; + if (Math.abs(diff) > 2.4) { + this.phaseStart += (diff / stepsPerSecond) * 1000; + step = (((now - this.phaseStart) / 1000) * stepsPerSecond) % LOOP_STEPS; + } + this.displayStep = step; + } else { + this.displayStep = 0; + this.phaseStart = now; + this.wasPlaying = false; + } + } + + private draw(now: number, dt: number): void { + const { ctx, w, h } = this; + const level = this.smooth; + const bass = level.veins[0]; + const crack = level.veins[1]; + const voice = level.veins[2]; + const noise = level.veins[3]; + const silt = level.veins[4]; + const master = level.master; + + ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + ctx.fillStyle = '#0c0908'; + ctx.fillRect(0, 0, w, h); + ctx.globalAlpha = 0.78 + master * 0.22; + ctx.drawImage(this.terrain, 0, -bass * 2.5, w, h + bass * 2.5); + ctx.globalAlpha = 1; + + const eyeX = w * 0.5; + const eyeY = h * 0.16; + const eyeSize = Math.min(w, h) * (0.64 + bass * 0.34 + master * 0.08); + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.globalAlpha = 0.3 + bass * 0.5 + master * 0.16; + ctx.drawImage(this.glow, eyeX - eyeSize / 2, eyeY - eyeSize / 2, eyeSize, eyeSize); + ctx.restore(); + + const coreRadius = Math.max(46, w * 0.072); + const core = ctx.createRadialGradient(eyeX, eyeY, 0, eyeX, eyeY, coreRadius); + core.addColorStop(0, 'rgba(4,2,2,0.99)'); + core.addColorStop(0.62, 'rgba(6,3,3,0.92)'); + core.addColorStop(1, 'rgba(6,3,3,0)'); + ctx.fillStyle = core; + ctx.beginPath(); + ctx.arc(eyeX, eyeY, coreRadius, 0, TAU); + ctx.fill(); + + const ring = Math.min(w, h) * 0.06 + bass * Math.min(w, h) * 0.03; + ctx.strokeStyle = `rgba(232,64,42,${0.22 + bass * 0.5 + master * 0.12})`; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.arc(eyeX, eyeY, ring, 0, TAU); + ctx.stroke(); + ctx.strokeStyle = `rgba(232,64,42,${0.07 + master * 0.18})`; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(eyeX, eyeY, ring * 1.42, 0, TAU); + ctx.stroke(); + if (voice > 0.01) { + ctx.strokeStyle = `rgba(239,228,212,${voice * 0.2})`; + ctx.beginPath(); + ctx.arc(eyeX, eyeY, ring * 1.85, 0, TAU); + ctx.stroke(); + } + + for (const light of this.lights) { + const flicker = 0.55 + 0.45 * Math.sin(now * 0.0012 + light.phase); + const alpha = light.intensity * flicker * (0.13 + crack * 0.5 + noise * 0.24); + if (alpha <= 0.01) continue; + ctx.fillStyle = `rgba(232,64,42,${alpha})`; + ctx.fillRect(light.x, light.y, light.size, light.size); + } + + this.drawDust(now, dt, noise, silt); + + this.drawRoad(now, master, bass, crack, voice, noise, silt); + } + + private drawDust(now: number, dt: number, noise: number, silt: number): void { + const { ctx, w, h } = this; + ctx.save(); + for (const particle of this.particles) { + particle.y -= dt * (0.004 + noise * 0.035) * particle.speed; + particle.x += Math.sin(now * 0.00018 + particle.phase) * dt * 0.004; + if (particle.y < -0.02) particle.y = 1.02; + if (particle.x < -0.02) particle.x = 1.02; + if (particle.x > 1.02) particle.x = -0.02; + const alpha = particle.alpha * (0.03 + noise * 0.12 + silt * 0.05); + if (alpha <= 0.005) continue; + ctx.fillStyle = `rgba(239,228,212,${alpha})`; + ctx.fillRect(particle.x * w, particle.y * h, particle.size, particle.size); + } + ctx.restore(); + } + + private roadPoint(z: number): { y: number; half: number } { + const { bottom, horizon, bhw, thw } = this.road; + const depth = Math.pow(Math.max(0, Math.min(1, z)), 1.16); + return { + y: bottom - (bottom - horizon) * depth, + half: bhw + (thw - bhw) * depth + }; + } + + private drawRoad(now: number, master: number, bass: number, crack: number, voice: number, noise: number, silt: number): void { + const { ctx, w } = this; + const { cx, bottom, horizon, bhw, thw } = this.road; + const levels = this.smooth.veins; + const active = Math.floor(this.displayStep) % LOOP_STEPS; + + const roadFill = ctx.createLinearGradient(0, bottom, 0, horizon); + roadFill.addColorStop(0, 'rgba(24,10,7,0.66)'); + roadFill.addColorStop(0.55, 'rgba(12,6,4,0.46)'); + roadFill.addColorStop(1, 'rgba(4,2,2,0.2)'); + ctx.fillStyle = roadFill; + ctx.beginPath(); + ctx.moveTo(cx - bhw, bottom); + ctx.lineTo(cx + bhw, bottom); + ctx.lineTo(cx + thw, horizon); + ctx.lineTo(cx - thw, horizon); + ctx.closePath(); + ctx.fill(); + + ctx.save(); + for (let lane = 0; lane < 5; lane += 1) { + const leftBottom = cx + ((lane / 5) - 0.5) * 2 * bhw; + const rightBottom = cx + (((lane + 1) / 5) - 0.5) * 2 * bhw; + const leftTop = cx + ((lane / 5) - 0.5) * 2 * thw; + const rightTop = cx + (((lane + 1) / 5) - 0.5) * 2 * thw; + const selected = VEINS[lane] === this.selected; + const level = levels[lane] ?? 0; + ctx.fillStyle = STONE_FILL[lane] + (0.03 + level * 0.18 + (selected ? 0.03 : 0)) + ')'; + ctx.beginPath(); + ctx.moveTo(leftBottom, bottom); + ctx.lineTo(rightBottom, bottom); + ctx.lineTo(rightTop, horizon); + ctx.lineTo(leftTop, horizon); + ctx.closePath(); + ctx.fill(); + } + ctx.restore(); + + for (let step = 0; step <= LOOP_STEPS; step += 1) { + const point = this.roadPoint(step / LOOP_STEPS); + const barLine = step % STEPS_PER_BAR === 0; + ctx.strokeStyle = barLine + ? `rgba(190,160,140,${0.18 + master * 0.12})` + : `rgba(155,133,121,${0.07 + master * 0.05})`; + ctx.lineWidth = barLine ? 1 : 0.65; + ctx.beginPath(); + ctx.moveTo(cx - point.half, point.y); + ctx.lineTo(cx + point.half, point.y); + ctx.stroke(); + } + + if (this.displayStep > 0) { + const z = this.displayStep / LOOP_STEPS; + const point = this.roadPoint(z); + ctx.fillStyle = `rgba(232,64,42,${0.04 + master * 0.08})`; + ctx.beginPath(); + ctx.moveTo(cx - point.half, point.y); + ctx.lineTo(cx + point.half, point.y); + ctx.lineTo(cx + thw, horizon); + ctx.lineTo(cx - thw, horizon); + ctx.closePath(); + ctx.fill(); + } + + for (let laneEdge = 0; laneEdge <= 5; laneEdge += 1) { + const fraction = laneEdge / 5; + const left = laneEdge === 0 || laneEdge === 5; + const laneA = Math.max(0, laneEdge - 1); + const laneB = Math.min(4, laneEdge); + const selected = VEINS[laneA] === this.selected || VEINS[laneB] === this.selected; + ctx.strokeStyle = left + ? `rgba(232,64,42,${0.4 + master * 0.25})` + : selected + ? `rgba(232,64,42,${0.24 + levels[laneB] * 0.22})` + : `rgba(190,160,140,${0.12 + Math.max(levels[laneA], levels[laneB]) * 0.14})`; + if (left) { + ctx.lineWidth = 5; + ctx.strokeStyle = `rgba(232,64,42,${0.05 + master * 0.12})`; + } else { + ctx.lineWidth = left ? 1.4 : 0.8; + } + ctx.beginPath(); + ctx.moveTo(cx + (fraction - 0.5) * 2 * bhw, bottom); + ctx.lineTo(cx + (fraction - 0.5) * 2 * thw, horizon); + ctx.stroke(); + if (left) { + ctx.strokeStyle = `rgba(232,64,42,${0.32 + master * 0.3})`; + ctx.lineWidth = 1.1; + ctx.beginPath(); + ctx.moveTo(cx + (fraction - 0.5) * 2 * bhw, bottom); + ctx.lineTo(cx + (fraction - 0.5) * 2 * thw, horizon); + ctx.stroke(); + } + } + + const pattern = this.state?.pattern; + if (pattern) { + this.drawPattern(pattern, active, now); + } + + const play = this.roadPoint(this.displayStep / LOOP_STEPS); + const playing = this.state?.transport.playing ?? false; + const playAlpha = playing ? 0.5 + master * 0.45 : 0.18; + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.globalAlpha = 0.26 + master * 0.45; + const playGlow = Math.min(w, 560) * 0.24; + ctx.drawImage(this.glow, cx - playGlow / 2, play.y - playGlow * 0.4, playGlow, playGlow * 0.8); + ctx.restore(); + ctx.strokeStyle = `rgba(239,228,212,${playAlpha})`; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.moveTo(cx - play.half, play.y); + ctx.lineTo(cx + play.half, play.y); + ctx.stroke(); + ctx.strokeStyle = `rgba(232,64,42,${0.18 + master * 0.24})`; + ctx.lineWidth = 0.65; + ctx.beginPath(); + ctx.moveTo(cx - play.half, play.y + 2); + ctx.lineTo(cx + play.half, play.y + 2); + ctx.stroke(); + + this.drawBarLabels(ctx, cx, bhw); + } + + private drawPattern(pattern: Pattern, active: number, now: number): void { + const { ctx } = this; + const cx = this.road.cx; + const playing = this.state?.transport.playing ?? false; + for (const row of pattern.rows) { + const lane = VEINS.indexOf(row.vein); + if (lane < 0) continue; + for (let step = 0; step < row.velocities.length; step += 1) { + const velocity = row.velocities[step]; + if (!velocity) continue; + const z = (step + 0.5) / LOOP_STEPS; + const point = this.roadPoint(z); + const laneCenter = (lane + 0.5) / 5; + const x = cx + (laneCenter - 0.5) * 2 * point.half; + const isActive = playing && step === active; + const size = (3.3 + (1 - z) * 10.6) * (0.62 + (velocity / 127) * 0.6); + this.drawStone(x, point.y, size, lane, velocity, isActive, z, now, step); + } + } + } + + private drawStone(x: number, y: number, size: number, lane: number, velocity: number, active: boolean, z: number, now: number, step: number): void { + const { ctx } = this; + const glowSize = size * (active ? 10 : 6); + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.globalAlpha = (0.06 + (velocity / 127) * 0.19) * (1 - z * 0.3) * (active ? 2.4 : 1); + ctx.drawImage(this.glow, x - glowSize / 2, y - glowSize / 2, glowSize, glowSize); + ctx.restore(); + + const selected = VEINS[lane] === this.selected; + const jitter = (a: number, b: number) => { + const n = Math.sin(a * 12.9898 + b * 78.233 + lane * 37.719) * 43758.5453; + return n - Math.floor(n); + }; + ctx.save(); + ctx.translate(x, y); + ctx.beginPath(); + const points = 7; + for (let i = 0; i < points; i += 1) { + const angle = (i / points) * TAU + now * 0.000015 * (active ? 1 : 0.2); + const radius = size * (0.55 + jitter(step, i) * 0.45); + const px = Math.cos(angle) * radius; + const py = Math.sin(angle) * radius * 0.62; + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.closePath(); + ctx.fillStyle = active + ? 'rgba(239,228,212,0.92)' + : STONE_FILL[lane] + (0.36 + (velocity / 127) * 0.5) + ')'; + ctx.fill(); + ctx.strokeStyle = active + ? 'rgba(232,64,42,0.95)' + : selected + ? 'rgba(232,64,42,0.5)' + : 'rgba(239,228,212,0.12)'; + ctx.lineWidth = active ? 1.1 : 0.6; + ctx.stroke(); + if (active) { + ctx.strokeStyle = 'rgba(232,64,42,0.7)'; + ctx.beginPath(); + ctx.arc(0, 0, size * 1.7, 0, TAU); + ctx.stroke(); + } + ctx.restore(); + } + + private drawBarLabels(ctx: CanvasRenderingContext2D, cx: number, bhw: number): void { + ctx.save(); + ctx.font = '10px "JetBrains Mono", monospace'; + ctx.fillStyle = 'rgba(190,160,140,0.78)'; + ctx.textAlign = 'right'; + const first = this.roadPoint(0.0); + ctx.fillText('01', cx - bhw - 10, first.y - 2); + const second = this.roadPoint(0.5); + ctx.fillText('02', cx - second.half - 10, second.y - 2); + ctx.restore(); + } +} + +function mulberry(seed: number): () => number { + let a = seed >>> 0; + return () => { + a += 0x6d2b79f5; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function hash2(x: number, y: number, seed: number): number { + let h = x * 374761393 + y * 668265263 + seed * 1442695041; + h = (h ^ (h >>> 13)) * 1274126177; + h = h ^ (h >>> 16); + return (h >>> 0) / 4294967296; +} + +function smoothstep(t: number): number { + return t * t * (3 - 2 * t); +} + +function noise(x: number, y: number): number { + const xi = Math.floor(x); + const yi = Math.floor(y); + const xf = x - xi; + const yf = y - yi; + const u = smoothstep(xf); + const v = smoothstep(yf); + const a = hash2(xi, yi, 17); + const b = hash2(xi + 1, yi, 17); + const c = hash2(xi, yi + 1, 17); + const d = hash2(xi + 1, yi + 1, 17); + return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v; +} + +function fbm(x: number, y: number): number { + let value = 0; + let amplitude = 0.5; + let frequency = 1; + let total = 0; + for (let octave = 0; octave < 4; octave += 1) { + value += noise(x * frequency, y * frequency) * amplitude; + total += amplitude; + amplitude *= 0.5; + frequency *= 2.03; + } + return value / total; +} diff --git a/ui/src/lib/format.ts b/ui/src/lib/format.ts new file mode 100644 index 0000000..417cf4d --- /dev/null +++ b/ui/src/lib/format.ts @@ -0,0 +1,39 @@ +import type { Command } from './types'; + +const MINUS = '\u2212'; + +export function fmtDb(level: number): string { + if (!(level > 0.0001)) return MINUS + '\u221e'; + const db = 20 * Math.log10(level); + return (db >= 0 ? '+' : MINUS) + Math.abs(db).toFixed(1) + ' dB'; +} + +export function fmtTime(bar: number, bpm: number): string { + const seconds = Math.max(0, bar - 1) * 4 * (60 / Math.max(40, bpm)); + const minutes = Math.floor(seconds / 60); + const rest = seconds - minutes * 60; + return String(minutes).padStart(2, '0') + ':' + rest.toFixed(1).padStart(4, '0'); +} + +export function fmtBar(bar: number): string { + return (Math.round(bar * 10) / 10).toFixed(1); +} + +export function fmtTempo(bpm: number): string { + return Number.isInteger(bpm) ? String(bpm) : bpm.toFixed(1); +} + +export function commandLabel(command: Command): string { + switch (command.type) { + case 'play': return 'play'; + case 'stop': return 'stop'; + case 'strike': return `strike ${command.vein} ${command.velocity.toFixed(2)}`; + case 'set_tempo': return `tempo ${fmtTempo(command.bpm)}`; + case 'set_vein_gain': return `${command.vein} gain ${command.gain.toFixed(2)}`; + case 'get_snapshot': return 'snapshot'; + } +} + +export function eventLabel(event: { kind: string }): string { + return event.kind.replace('_', ' '); +} diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts new file mode 100644 index 0000000..a46eabc --- /dev/null +++ b/ui/src/lib/types.ts @@ -0,0 +1,119 @@ +// Wire types for protocol v1. Kept in step with crates/zhila-protocol. +export type VeinId = 'bass' | 'crack' | 'voice' | 'noise' | 'silt'; +export type Status = 'connecting' | 'live' | 'offline'; + +export interface AudioInfo { + running: boolean; + device: string; + sample_rate: number; + buffer_frames: number; + channels: number; +} + +export interface TransportState { + playing: boolean; + bpm: number; + bar: number; + loop_bars: number; +} + +export interface VeinState { + id: VeinId; + name: string; + gain: number; +} + +export interface PatternRow { + vein: VeinId; + velocities: number[]; +} + +export interface Pattern { + steps: number; + steps_per_bar: number; + rows: PatternRow[]; +} + +export interface State { + revision: number; + transport: TransportState; + veins: VeinState[]; + pattern: Pattern; + audio: AudioInfo; + journal_len: number; +} + +export type Event = + | { kind: 'transport'; playing: boolean } + | { kind: 'tempo'; bpm: number } + | { kind: 'vein_gain'; vein: VeinId; gain: number } + | { kind: 'struck'; vein: VeinId; velocity: number }; + +export type Command = + | { type: 'get_snapshot' } + | { type: 'play' } + | { type: 'stop' } + | { type: 'strike'; vein: VeinId; velocity: number } + | { type: 'set_tempo'; bpm: number } + | { type: 'set_vein_gain'; vein: VeinId; gain: number }; + +export type CommandResult = + | { kind: 'snapshot'; state: State } + | { kind: 'state'; state: State }; + +export interface HelloMessage { + type: 'hello'; + protocol: number; + session: string; + engine: AudioInfo; +} + +export interface SnapshotMessage { + type: 'snapshot'; + revision: number; + state: State; +} + +export interface EventMessage { + type: 'event'; + revision: number; + event: Event; +} + +export interface ResultMessage { + type: 'result'; + id: number; + result: CommandResult; +} + +export interface ErrorMessage { + type: 'error'; + id: number | null; + code: string; + message: string; +} + +export type ServerMessage = HelloMessage | SnapshotMessage | EventMessage | ResultMessage | ErrorMessage; + +export interface MeterFrame { + bar: number; + master: number; + veins: number[]; +} + +export const VEINS: VeinId[] = ['bass', 'crack', 'voice', 'noise', 'silt']; +export const METER_LEN = 31; +export const PROTOCOL_VERSION = 1; + +export function isVein(value: unknown): value is VeinId { + return typeof value === 'string' && (VEINS as string[]).includes(value); +} + +export type WireDir = 'send' | 'recv' | 'info' | 'error'; +export interface WireEntry { + id: number; + at: string; + dir: WireDir; + label: string; + detail?: string; +} diff --git a/ui/src/main.ts b/ui/src/main.ts new file mode 100644 index 0000000..9e9b9b1 --- /dev/null +++ b/ui/src/main.ts @@ -0,0 +1,17 @@ +import '@fontsource/cormorant-garamond/300.css'; +import '@fontsource/cormorant-garamond/300-italic.css'; +import '@fontsource/cormorant-garamond/400.css'; +import '@fontsource/cormorant-garamond/400-italic.css'; +import '@fontsource/jetbrains-mono/300.css'; +import '@fontsource/jetbrains-mono/400.css'; +import '@fontsource/jetbrains-mono/500.css'; +import './app.css'; +import { mount } from 'svelte'; +import App from './App.svelte'; + +const target = document.getElementById('app'); +if (!target) throw new Error('the surface has no mount point'); + +const app = mount(App, { target }); + +export default app; diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts new file mode 100644 index 0000000..0a8b41f --- /dev/null +++ b/ui/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_ZHILA_WS?: string; + readonly VITE_ZHILA_TOKEN?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/ui/svelte.config.js b/ui/svelte.config.js new file mode 100644 index 0000000..af5cd09 --- /dev/null +++ b/ui/svelte.config.js @@ -0,0 +1,8 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess(), + compilerOptions: { + runes: true + } +}; diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..6d71e70 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "allowJs": false, + "checkJs": false, + "types": ["vite/client"], + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..cdf7b1d --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + plugins: [svelte()], + server: { port: 5173, strictPort: true, host: '127.0.0.1' }, + preview: { port: 5173, strictPort: true, host: '127.0.0.1' }, + build: { target: 'es2022' } +});