diff --git a/Cargo.lock b/Cargo.lock index c311d79c0..95acb31d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,7 +35,7 @@ checksum = "d8a75151518dae7509b03ee01bec486d3634fda572c9d4b21ce66d5995f8c2dc" dependencies = [ "accesskit", "accesskit_consumer 0.35.0", - "jni", + "jni 0.21.1", "log", ] @@ -202,8 +202,8 @@ dependencies = [ "bitflags 2.11.0", "cc", "cesu8", - "jni", - "jni-sys", + "jni 0.21.1", + "jni-sys 0.3.0", "libc", "log", "ndk", @@ -245,9 +245,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -852,8 +852,8 @@ dependencies = [ "slab", "smallvec", "stylo", - "stylo_config", "stylo_dom", + "stylo_static_prefs", "stylo_taffy", "stylo_traits", "taffy", @@ -1018,10 +1018,11 @@ dependencies = [ [[package]] name = "borsh" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ + "bytes", "cfg_aliases 0.2.1", ] @@ -1189,9 +1190,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -1360,9 +1361,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "colored" @@ -1937,7 +1938,7 @@ dependencies = [ "dioxus-cli-config", "http", "infer", - "jni", + "jni 0.21.1", "ndk", "ndk-context", "ndk-sys 0.6.0+11769913", @@ -2564,9 +2565,9 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" [[package]] name = "euclid" -version = "0.22.13" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" dependencies = [ "num-traits", "serde", @@ -2724,7 +2725,6 @@ dependencies = [ [[package]] name = "fontique" version = "0.7.0" -source = "git+https://github.com/linebender/parley?rev=c3d3d41eaaca0d2dce9558f460c2c129559fc425#c3d3d41eaaca0d2dce9558f460c2c129559fc425" dependencies = [ "hashbrown 0.16.1", "linebender_resource_handle", @@ -3748,9 +3748,9 @@ dependencies = [ [[package]] name = "inotify" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" dependencies = [ "bitflags 2.11.0", "inotify-sys", @@ -3871,19 +3871,68 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.0", "log", "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] +[[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.18", + "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.117", +] + [[package]] name = "jni-sys" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[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.117", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -3993,9 +4042,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libfuzzer-sys" @@ -4175,7 +4224,7 @@ dependencies = [ "dioxus-cli-config", "dioxus-core-types", "serde", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -4436,7 +4485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ "bitflags 2.11.0", - "jni-sys", + "jni-sys 0.3.0", "log", "ndk-sys 0.6.0+11769913", "num_enum", @@ -4456,7 +4505,7 @@ version = "0.5.0+25.2.9519653" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" dependencies = [ - "jni-sys", + "jni-sys 0.3.0", ] [[package]] @@ -4465,7 +4514,7 @@ version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys", + "jni-sys 0.3.0", ] [[package]] @@ -4616,9 +4665,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -4626,9 +4675,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -4958,9 +5007,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -4976,9 +5025,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orbclient" -version = "0.3.50" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ad2c6bae700b7aa5d1cc30c59bdd3a1c180b09dbaea51e2ae2b8e1cf211fdd" +checksum = "59aed3b33578edcfa1bc96a321d590d31832b6ad55a26f0313362ce687e9abd6" dependencies = [ "libc", "libredox", @@ -5075,7 +5124,6 @@ dependencies = [ [[package]] name = "parley" version = "0.7.0" -source = "git+https://github.com/linebender/parley?rev=c3d3d41eaaca0d2dce9558f460c2c129559fc425#c3d3d41eaaca0d2dce9558f460c2c129559fc425" dependencies = [ "fontique", "harfrust", @@ -5092,11 +5140,8 @@ dependencies = [ [[package]] name = "parley_data" version = "0.0.0" -source = "git+https://github.com/linebender/parley?rev=c3d3d41eaaca0d2dce9558f460c2c129559fc425#c3d3d41eaaca0d2dce9558f460c2c129559fc425" dependencies = [ - "icu_collections", "icu_properties", - "zerovec", ] [[package]] @@ -5310,9 +5355,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" dependencies = [ "portable-atomic", ] @@ -5371,7 +5416,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.4+spec-1.1.0", + "toml_edit 0.25.5+spec-1.1.0", ] [[package]] @@ -5495,9 +5540,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -6078,9 +6123,7 @@ dependencies = [ [[package]] name = "selectors" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fdfed56cd634f04fe8b9ddf947ae3dc493483e819593d2ba17df9ad05db8b2" +version = "0.36.1" dependencies = [ "bitflags 2.11.0", "cssparser", @@ -6220,8 +6263,6 @@ dependencies = [ [[package]] name = "servo_arc" version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" dependencies = [ "serde", "stable_deref_trait", @@ -6291,6 +6332,16 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simd_helpers" version = "0.1.0" @@ -6300,6 +6351,12 @@ dependencies = [ "quote", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simplecss" version = "0.2.2" @@ -6448,12 +6505,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6573,15 +6630,15 @@ dependencies = [ [[package]] name = "strum" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" [[package]] name = "strum_macros" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck", "proc-macro2", @@ -6591,9 +6648,7 @@ dependencies = [ [[package]] name = "stylo" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ad158e6840fc267f068c7956ade41bbdd09a184d490a17ca38fcff94f910c15" +version = "0.14.0" dependencies = [ "app_units", "arrayvec", @@ -6631,7 +6686,6 @@ dependencies = [ "strum", "strum_macros", "stylo_atoms", - "stylo_config", "stylo_derive", "stylo_dom", "stylo_malloc_size_of", @@ -6649,25 +6703,15 @@ dependencies = [ [[package]] name = "stylo_atoms" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4de3f265b0364402f95a6c5e8f05250fd659e8b011be0da9fa9f68b1f641f308" +version = "0.14.0" dependencies = [ "string_cache", "string_cache_codegen", ] -[[package]] -name = "stylo_config" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cf1244a50a1fba2266bc587f2efa85419105e7eb210eebfe828db0c6c996be0" - [[package]] name = "stylo_derive" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d440f646f5f66464ed6b77bed976867979efc4d2dfe5e910d17f06ebe6c7d19" +version = "0.14.0" dependencies = [ "darling 0.20.11", "proc-macro2", @@ -6678,9 +6722,7 @@ dependencies = [ [[package]] name = "stylo_dom" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "410e21fafc44c6cd5ae0640422b19c651d0d356a7bfc34c15a2cd42811befee2" +version = "0.14.0" dependencies = [ "bitflags 2.11.0", "stylo_malloc_size_of", @@ -6688,9 +6730,7 @@ dependencies = [ [[package]] name = "stylo_malloc_size_of" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd4fa073f68ccb54cb5b747c0de7ec5807eefc57426a8463d5b5529873dfc7e" +version = "0.14.0" dependencies = [ "app_units", "cssparser", @@ -6706,12 +6746,7 @@ dependencies = [ [[package]] name = "stylo_static_prefs" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9efd065dbc5acf78da14eda70578b15cd6c31e057289bf2f188186ca9c5ae6e1" -dependencies = [ - "stylo_config", -] +version = "0.14.0" [[package]] name = "stylo_taffy" @@ -6724,9 +6759,7 @@ dependencies = [ [[package]] name = "stylo_traits" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baad668b39a5e993a73111df8f55def808bd561c651acf794ab58a9c208c3f4" +version = "0.14.0" dependencies = [ "app_units", "bitflags 2.11.0", @@ -6904,9 +6937,9 @@ checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.4.2", @@ -6943,7 +6976,6 @@ checksum = "253bcead4f3aa96243b0f8fa46f9010e87ca23bd5d0c723d474ff1d2417bbdf8" [[package]] name = "text_primitives" version = "0.1.0" -source = "git+https://github.com/linebender/parley?rev=c3d3d41eaaca0d2dce9558f460c2c129559fc425#c3d3d41eaaca0d2dce9558f460c2c129559fc425" [[package]] name = "thin-vec" @@ -7082,9 +7114,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -7098,8 +7130,6 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "to_shmem" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eab187810ca1e6aaa4c97a06492aac9ade2ffae6a301fd2aac103656f5a69edb" dependencies = [ "cssparser", "servo_arc", @@ -7112,8 +7142,6 @@ dependencies = [ [[package]] name = "to_shmem_derive" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ba1f5563024b63bb6acb4558452d9ba737518c1d11fcc1861febe98d1e31cf4" dependencies = [ "darling 0.20.11", "proc-macro2", @@ -7216,7 +7244,7 @@ dependencies = [ "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -7239,9 +7267,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.0.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" dependencies = [ "serde_core", ] @@ -7257,28 +7285,28 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" dependencies = [ "indexmap", - "toml_datetime 1.0.0+spec-1.1.0", + "toml_datetime 1.0.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.0", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.0.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" dependencies = [ - "winnow", + "winnow 1.0.0", ] [[package]] @@ -7289,9 +7317,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.0.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" [[package]] name = "tower" @@ -7384,9 +7412,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -7457,9 +7485,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "uds_windows" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", @@ -8125,12 +8153,12 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f00bb839c1cf1e3036066614cbdcd035ecf215206691ea646aa3c60a24f68f2" +checksum = "fe985f41e291eecef5e5c0770a18d28390addb03331c043964d9e916453d6f16" dependencies = [ "core-foundation 0.10.1", - "jni", + "jni 0.22.4", "log", "ndk-context", "objc2 0.6.4", @@ -9145,6 +9173,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -9468,7 +9505,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 0.7.15", "zbus_macros", "zbus_names", "zvariant", @@ -9520,7 +9557,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "winnow", + "winnow 0.7.15", "zvariant", ] @@ -9538,18 +9575,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.40" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.40" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", @@ -9647,7 +9684,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 0.7.15", "zvariant_derive", "zvariant_utils", ] @@ -9675,5 +9712,5 @@ dependencies = [ "quote", "serde", "syn 2.0.117", - "winnow", + "winnow 0.7.15", ] diff --git a/Cargo.toml b/Cargo.toml index b3efc32d9..4d4c517c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,12 +48,12 @@ debug_timer = { version = "0.1.2", path = "./packages/debug_timer" } accesskit_xplat = { version = "0.1", path = "./packages/accesskit_xplat", default-features = false } # Servo dependencies -style = { version = "0.12.0", package = "stylo" } -style_traits = { version = "0.12.0", package = "stylo_traits" } -style_atoms = { version = "0.12.0", package = "stylo_atoms" } -style_config = { version = "0.12.0", package = "stylo_config" } -style_dom = { version = "0.12.0", package = "stylo_dom" } -selectors = { version = "0.35.0", package = "selectors" } +style = { version = "0.14.0", package = "stylo" } +style_traits = { version = "0.14.0", package = "stylo_traits" } +style_atoms = { version = "0.14.0", package = "stylo_atoms" } +style_dom = { version = "0.14.0", package = "stylo_dom" } +static_prefs = { version = "0.14.0", package = "stylo_static_prefs" } +selectors = { version = "0.36.0", package = "selectors" } cssparser = { version = "0.36" } # HTML5ever dependencies @@ -255,18 +255,20 @@ tracing-subscriber = "0.3" # vello_hybrid = { path = "../vello/sparse_strips/vello_hybrid" } # vello_common = { path = "../vello/sparse_strips/vello_common" } -# [patch.crates-io] -# style = { path = "../stylo/style", package = "stylo" } -# style_traits = { path = "../stylo/style_traits", package = "stylo_traits" } -# style_atoms = { path = "../stylo/stylo_atoms", package = "stylo_atoms" } -# style_config = { path = "../stylo/stylo_config", package = "stylo_config" } -# style_dom = { path = "../stylo/stylo_dom", package = "stylo_dom" } -# selectors = { path = "../stylo/selectors", package = "selectors" } - # [patch.crates-io] # [patch."https://github.com/dioxuslabs/taffy"] # taffy = { path = "../taffy" } -# [patch."https://github.com/linebender/parley"] -# parley = { path = "../parley/parley" } -# fontique = { path = "../parley/fontique" } \ No newline at end of file +[patch."https://github.com/linebender/parley"] +parley = { path = "/Users/jonathankelley/Development/Tinkering/parley/parley" } +fontique = { path = "/Users/jonathankelley/Development/Tinkering/parley/fontique" } + +[patch."crates-io"] +parley = { path = "/Users/jonathankelley/Development/Tinkering/parley/parley" } +fontique = { path = "/Users/jonathankelley/Development/Tinkering/parley/fontique" } +style = { path = "/Users/jonathankelley/Development/Tinkering/stylo/style", package = "stylo" } +style_traits = { path = "/Users/jonathankelley/Development/Tinkering/stylo/style_traits", package = "stylo_traits" } +style_atoms = { path = "/Users/jonathankelley/Development/Tinkering/stylo/stylo_atoms", package = "stylo_atoms" } +style_dom = { path = "/Users/jonathankelley/Development/Tinkering/stylo/stylo_dom", package = "stylo_dom" } +static_prefs = { path = "/Users/jonathankelley/Development/Tinkering/stylo/stylo_static_prefs", package = "stylo_static_prefs" } +selectors = { path = "/Users/jonathankelley/Development/Tinkering/stylo/selectors", package = "selectors" } diff --git a/packages/blitz-dom/Cargo.toml b/packages/blitz-dom/Cargo.toml index 55fcac695..063661f8e 100644 --- a/packages/blitz-dom/Cargo.toml +++ b/packages/blitz-dom/Cargo.toml @@ -43,7 +43,7 @@ debug_timer = { workspace = true } style = { workspace = true } selectors = { workspace = true } cssparser = { workspace = true } -style_config = { workspace = true } +static_prefs = { workspace = true } style_traits = { workspace = true } style_dom = { workspace = true } app_units = { workspace = true } diff --git a/packages/blitz-dom/src/debug.rs b/packages/blitz-dom/src/debug.rs index fdcff756e..46e6a817f 100644 --- a/packages/blitz-dom/src/debug.rs +++ b/packages/blitz-dom/src/debug.rs @@ -47,10 +47,10 @@ impl BaseDocument { println!("Lines:"); for (i, line) in inline_layout.layout.lines().enumerate() { let metrics = line.metrics(); - let x = metrics.inline_min_coord; - let y = metrics.block_min_coord; - let w = metrics.inline_max_coord - metrics.inline_min_coord; - let h = metrics.block_max_coord - metrics.block_min_coord; + let x = metrics.offset; + let y = metrics.min_coord; + let w = metrics.advance; + let h = metrics.max_coord - metrics.min_coord; println!("Line {i}: x:{x} y:{y} width:{w} height:{h}"); for item in line.items() { print!(" "); @@ -63,8 +63,7 @@ impl BaseDocument { ) } PositionedLayoutItem::InlineBox(ibox) => print!( - "BOX {:?} (id: {} x: {} y: {} w: {}, h: {})", - ibox.kind, + "BOX (id: {} x: {} y: {} w: {}, h: {})", ibox.id, ibox.x.round(), ibox.y.round(), diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index 9e2758f39..2b167b8a8 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -41,7 +41,7 @@ use std::time::Instant; use style::Atom; use style::animation::DocumentAnimationSet; use style::attr::{AttrIdentifier, AttrValue}; -use style::data::{ElementData as StyloElementData, ElementStyles}; +use style::data::ElementStyles; use style::media_queries::MediaType; use style::properties::ComputedValues; use style::properties::style_structs::Font; @@ -52,7 +52,8 @@ use style::values::GenericAtomIdent; use style::values::computed::Overflow; use style::{ dom::{TDocument, TNode}, - media_queries::{Device, MediaList}, + device::Device, + media_queries::MediaList, selector_parser::SnapshotMap, shared_lock::{SharedRwLock, StylesheetGuards}, stylesheets::{AllowImportRules, DocumentStyleSheet, Origin, Stylesheet}, @@ -264,6 +265,8 @@ pub struct BaseDocument { pub(crate) changed_nodes: HashSet, /// Set of changed nodes for updating the accessibility tree pub(crate) deferred_construction_nodes: Vec, + /// Node IDs of all position:sticky elements, for efficient recomputation on scroll + pub(crate) sticky_nodes: Vec, /// Cache of loaded images, keyed by URL. Allows reusing images across multiple /// elements without re-fetching from the network. @@ -340,12 +343,10 @@ impl BaseDocument { let font_ctx = Arc::new(Mutex::new(font_ctx)); // Make sure we turn on stylo features *before* creating the Stylist - style_config::set_bool("layout.flexbox.enabled", true); - style_config::set_bool("layout.grid.enabled", true); - style_config::set_bool("layout.legacy_layout", true); - style_config::set_bool("layout.unimplemented", true); - style_config::set_bool("layout.columns.enabled", true); - style_config::set_i32("layout.threads", -1); + static_prefs::set_pref!("layout.grid.enabled", true); + static_prefs::set_pref!("layout.unimplemented", true); + static_prefs::set_pref!("layout.columns.enabled", true); + static_prefs::set_pref!("layout.threads", -1); let viewport = config.viewport.unwrap_or_default(); let device = make_device(&viewport, font_ctx.clone()); @@ -408,6 +409,7 @@ impl BaseDocument { sub_document_nodes: HashSet::new(), changed_nodes: HashSet::new(), deferred_construction_nodes: Vec::new(), + sticky_nodes: Vec::new(), image_cache: HashMap::new(), pending_images: HashMap::new(), pending_critical_resources: HashSet::new(), @@ -438,17 +440,18 @@ impl BaseDocument { } // Stylo data on the root node container is needed to render the node - let stylo_element_data = StyloElementData { - styles: ElementStyles { + let wrapper = style::data::ElementDataWrapper::default(); + { + let mut stylo_element_data = wrapper.borrow_mut(); + stylo_element_data.styles = ElementStyles { primary: Some( ComputedValues::initial_values_with_font_override(Font::initial_values()) .to_arc(), ), ..Default::default() - }, - ..Default::default() - }; - *doc.root_node().stylo_element_data.borrow_mut() = Some(stylo_element_data); + }; + } + doc.root_node().stylo_element_data.set(wrapper); doc } @@ -1093,7 +1096,7 @@ impl BaseDocument { cb(&mut self.nodes[node_id]); } - // Takes (x, y) co-ordinates (relative to the ) + // Takes (x, y) co-ordinates (page coordinates, i.e. viewport + scroll offset) pub fn hit(&self, x: f32, y: f32) -> Option { if TDocument::as_node(&&self.nodes[0]) .first_element_child() @@ -1104,9 +1107,67 @@ impl BaseDocument { return None; } + // Fixed-position elements are painted at viewport-relative positions (scroll-compensated), + // but hit() receives page coordinates (viewport + scroll). Test fixed children first + // with viewport coordinates so they can be hit correctly when the page is scrolled. + if let Some(hit) = self.hit_fixed_children(x, y) { + return Some(hit); + } + self.root_element().hit(x, y) } + /// Test fixed-position children of the root element using viewport coordinates. + /// Fixed elements are painted at viewport-relative positions, so hit testing them + /// requires subtracting the viewport scroll from page coordinates. + fn hit_fixed_children(&self, page_x: f32, page_y: f32) -> Option { + use style::properties::generated::longhands::position::computed_value::T as Position; + + let root = self.root_element(); + let root_id = root.id; + let vx = page_x - self.viewport_scroll.x as f32; + let vy = page_y - self.viewport_scroll.y as f32; + + // Helper closure to test a single child node + let test_child = |node_id: usize, offset_x: f32, offset_y: f32| -> Option { + let child = &self.nodes[node_id]; + if child.css_position == Position::Fixed && child.layout_parent.get() == Some(root_id) { + child.hit(vx - offset_x, vy - offset_y) + } else { + None + } + }; + + // Positive z_index hoisted children (highest priority, painted last) + if let Some(hoisted) = &root.stacking_context { + for hc in hoisted.pos_z_hoisted_children().rev() { + if let Some(hit) = test_child(hc.node_id, hc.position.x, hc.position.y) { + return Some(hit); + } + } + } + + // Regular paint children + if let Some(children) = &*root.paint_children.borrow() { + for &child_id in children.iter().rev() { + if let Some(hit) = test_child(child_id, 0.0, 0.0) { + return Some(hit); + } + } + } + + // Negative z_index hoisted children + if let Some(hoisted) = &root.stacking_context { + for hc in hoisted.neg_z_hoisted_children().rev() { + if let Some(hit) = test_child(hc.node_id, hc.position.x, hc.position.y) { + return Some(hit); + } + } + } + + None + } + pub fn focus_next_node(&mut self) -> Option { let focussed_node_id = self.get_focussed_node_id()?; let id = self.next_node(&self.nodes[focussed_node_id], |node| node.is_focussable())?; @@ -1474,7 +1535,9 @@ impl BaseDocument { } pub fn scroll_viewport_by(&mut self, x: f64, y: f64) { - self.scroll_viewport_by_has_changed(x, y); + if self.scroll_viewport_by_has_changed(x, y) { + self.recompute_sticky_offsets(); + } } /// Scroll the viewport by the given values @@ -1504,11 +1567,15 @@ impl BaseDocument { scroll_y: f64, dispatch_event: &mut dyn FnMut(DomEvent), ) -> bool { - if let Some(anchor_node_id) = anchor_node_id { + let changed = if let Some(anchor_node_id) = anchor_node_id { self.scroll_node_by_has_changed(anchor_node_id, scroll_x, scroll_y, dispatch_event) } else { self.scroll_viewport_by_has_changed(scroll_x, scroll_y) + }; + if changed { + self.recompute_sticky_offsets(); } + changed } pub fn viewport_scroll(&self) -> crate::Point { diff --git a/packages/blitz-dom/src/font_metrics.rs b/packages/blitz-dom/src/font_metrics.rs index d7f85358a..96587bb81 100644 --- a/packages/blitz-dom/src/font_metrics.rs +++ b/packages/blitz-dom/src/font_metrics.rs @@ -7,8 +7,8 @@ use skrifa::MetadataProvider as _; use skrifa::charmap::Charmap; use style::properties::style_structs::Font as FontStyles; use style::{ + device::servo::FontMetricsProvider, font_metrics::FontMetrics, - servo::media_queries::FontMetricsProvider, values::computed::{CSSPixelLength, font::QueryFontMetricsFlags}, }; diff --git a/packages/blitz-dom/src/layout/construct.rs b/packages/blitz-dom/src/layout/construct.rs index 89f5162c3..475a3bda0 100644 --- a/packages/blitz-dom/src/layout/construct.rs +++ b/packages/blitz-dom/src/layout/construct.rs @@ -3,13 +3,12 @@ use std::sync::Arc; use markup5ever::{QualName, local_name, ns}; use parley::{ - FontContext, InlineBox, InlineBoxKind, LayoutContext, StyleProperty, TreeBuilder, + FontContext, InlineBox, LayoutContext, StyleProperty, TreeBuilder, WhiteSpaceCollapse, }; use slab::Slab; use style::{ computed_values::position::T as PositionProperty, - data::ElementData as StyloElementData, shared_lock::StylesheetGuards, values::{ computed::{Content, ContentItem, Display, Float}, @@ -77,7 +76,7 @@ fn push_non_whitespace_children_and_pseudos(layout_children: &mut Vec, no } /// Convert a relative line height to an absolute one -fn resolve_line_height(line_height: parley::LineHeight, font_size: f32) -> f32 { +pub(super) fn resolve_line_height(line_height: parley::LineHeight, font_size: f32) -> f32 { match line_height { parley::LineHeight::FontSizeRelative(relative) => relative * font_size, parley::LineHeight::Absolute(absolute) => absolute, @@ -396,12 +395,11 @@ fn flush_pseudo_elements(doc: &mut BaseDocument, node_id: usize) { let after_node_id = node.after; // Note: yes these are kinda backwards - let style_data = node.stylo_element_data.borrow(); - let before_style = style_data - .as_ref() + let before_style = node.stylo_element_data + .borrow() .and_then(|d| d.styles.pseudos.as_array()[1].clone()); - let after_style = style_data - .as_ref() + let after_style = node.stylo_element_data + .borrow() .and_then(|d| d.styles.pseudos.as_array()[0].clone()); (before_style, after_style, before_node_id, after_node_id) @@ -449,11 +447,14 @@ fn flush_pseudo_elements(doc: &mut BaseDocument, node_id: usize) { } } - let mut element_data = StyloElementData::default(); - element_data.styles.primary = Some(pe_style.clone()); - element_data.set_restyled(); - element_data.damage = ALL_DAMAGE; - *doc.nodes[new_node_id].stylo_element_data.borrow_mut() = Some(element_data); + let wrapper = style::data::ElementDataWrapper::default(); + { + let mut element_data = wrapper.borrow_mut(); + element_data.styles.primary = Some(pe_style.clone()); + element_data.set_restyled(); + element_data.damage = ALL_DAMAGE; + } + doc.nodes[new_node_id].stylo_element_data.set(wrapper); let node = &mut doc.nodes[node_id]; node.set_pe_by_index(idx, Some(new_node_id)); @@ -464,8 +465,7 @@ fn flush_pseudo_elements(doc: &mut BaseDocument, node_id: usize) { if let (Some(pe_node_id), Some(pe_style)) = (pe_node_id, pe_style) { // TODO: Update content - let mut node_styles = doc.nodes[pe_node_id].stylo_element_data.borrow_mut(); - let node_styles = &mut node_styles.as_mut().unwrap(); + let mut node_styles = doc.nodes[pe_node_id].stylo_element_data.borrow_mut().unwrap(); node_styles.damage.insert(ALL_DAMAGE); let primary_styles = &mut node_styles.styles.primary; @@ -549,15 +549,14 @@ fn collect_complex_layout_children( &PseudoElement::ServoAnonymousBox, &parent_style, ); - let mut stylo_element_data = StyloElementData { - damage: ALL_DAMAGE, - ..Default::default() - }; - drop(parent_style); - - stylo_element_data.styles.primary = Some(style); - stylo_element_data.set_restyled(); - *doc.nodes[node_id].stylo_element_data.borrow_mut() = Some(stylo_element_data); + let wrapper = style::data::ElementDataWrapper::default(); + { + let mut stylo_element_data = wrapper.borrow_mut(); + stylo_element_data.damage = ALL_DAMAGE; + stylo_element_data.styles.primary = Some(style); + stylo_element_data.set_restyled(); + } + doc.nodes[node_id].stylo_element_data.set(wrapper); if doc.nodes[container_node_id] .flags .contains(NodeFlags::IS_IN_DOCUMENT) @@ -908,14 +907,14 @@ pub(crate) fn build_inline_layout_into( .map(|s| s.clone_position()) .unwrap_or(PositionProperty::Static); let float = style.map(|s| s.clone_float()).unwrap_or(Float::None); - let box_kind = if position.is_absolutely_positioned() { - InlineBoxKind::OutOfFlow - } else if float.is_floating() { - InlineBoxKind::CustomOutOfFlow - } else { - InlineBoxKind::InFlow - }; - + let _is_out_of_flow = position.is_absolutely_positioned() || float.is_floating(); + let (alignment_baseline, baseline_shift, baseline_source) = style + .map(|s| ( + stylo_to_parley::alignment_baseline(&s), + stylo_to_parley::baseline_shift(&s), + stylo_to_parley::baseline_source(&s), + )) + .unwrap_or_default(); match (display.outside(), display.inside()) { (DisplayOutside::None, DisplayInside::None) => { // node.remove_damage(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX); @@ -944,12 +943,15 @@ pub(crate) fn build_inline_layout_into( { builder.push_inline_box(InlineBox { id: node_id as u64, - kind: box_kind, // Overridden by push_inline_box method index: 0, // Width and height are set during layout width: 0.0, height: 0.0, + alignment_baseline, + baseline_shift, + baseline_source, + first_baseline: None, }); } else if *tag_name == local_name!("br") { // node.remove_damage(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX); @@ -1021,12 +1023,15 @@ pub(crate) fn build_inline_layout_into( (_, _) => { builder.push_inline_box(InlineBox { id: node_id as u64, - kind: box_kind, // Overridden by push_inline_box method index: 0, // Width and height are set during layout width: 0.0, height: 0.0, + alignment_baseline, + baseline_shift, + baseline_source, + first_baseline: None, }); } }; diff --git a/packages/blitz-dom/src/layout/damage.rs b/packages/blitz-dom/src/layout/damage.rs index 28f91ddd1..6c7918af5 100644 --- a/packages/blitz-dom/src/layout/damage.rs +++ b/packages/blitz-dom/src/layout/damage.rs @@ -383,8 +383,8 @@ impl BaseDocument { let display = { let node = self.nodes.get_mut(node_id).unwrap(); let _damage = node.damage().unwrap_or(ALL_DAMAGE); - let stylo_element_data = node.stylo_element_data.borrow(); - let primary_styles = stylo_element_data + let stylo_data_ref = node.stylo_element_data.borrow(); + let primary_styles = stylo_data_ref .as_ref() .and_then(|data| data.styles.get_primary()); @@ -394,6 +394,7 @@ impl BaseDocument { // if damage.intersects(RestyleDamage::RELAYOUT | CONSTRUCT_BOX) { node.style = stylo_taffy::to_taffy_style(style); + node.css_position = style.clone_position(); node.display_constructed_as = style.clone_display(); // } @@ -529,8 +530,13 @@ impl BaseDocument { let position = style.clone_position(); let z_index = style.clone_z_index().integer_or(0); - // TODO: more complete hoisting detection - if position != Position::Static && z_index != 0 { + // Hoist children that participate in z-ordering: + // - Positioned elements with explicit z-index (CSS spec §9.9) + // - Any element that creates a stacking context (opacity, transform, filter, etc.) + let is_positioned_with_z = position != Position::Static && z_index != 0; + let creates_stacking_context = + child.is_stacking_context_root(is_flex_or_grid); + if is_positioned_with_z || creates_stacking_context { stacking_context.children.push(HoistedPaintChild { node_id: child_id, z_index, @@ -556,9 +562,12 @@ impl BaseDocument { if let Some(parent_stacking_context) = parent_stacking_context { let position = self.nodes[node_id].final_layout.location; let scroll_offset = self.nodes[node_id].scroll_offset; + let sticky_offset = self.nodes[node_id].sticky_offset; for hoisted in stacking_context.children.iter_mut() { - hoisted.position.x += position.x - scroll_offset.x as f32; - hoisted.position.y += position.y - scroll_offset.y as f32; + hoisted.position.x += + position.x + sticky_offset.x as f32 - scroll_offset.x as f32; + hoisted.position.y += + position.y + sticky_offset.y as f32 - scroll_offset.y as f32; } parent_stacking_context .children diff --git a/packages/blitz-dom/src/layout/inline.rs b/packages/blitz-dom/src/layout/inline.rs index 169ed7cfd..c21f19120 100644 --- a/packages/blitz-dom/src/layout/inline.rs +++ b/packages/blitz-dom/src/layout/inline.rs @@ -1,4 +1,6 @@ +use parley::fontique::{Attributes, QueryFont, QueryStatus}; use parley::{AlignmentOptions, IndentOptions}; +use skrifa::MetadataProvider as _; use style::values::{computed::CSSPixelLength, generics::text::GenericTextIndent}; use taffy::{ AvailableSpace, BlockContext, BlockFormattingContext, BoxSizing, CollapsibleMarginSet, @@ -8,12 +10,11 @@ use taffy::{ }; #[cfg(feature = "floats")] -use parley::YieldData; -#[cfg(feature = "floats")] -use taffy::{Clear, Float, prelude::TaffyMaxContent}; +use taffy::{Float, prelude::TaffyMaxContent}; use super::resolve_calc_value; use crate::BaseDocument; +use crate::stylo_to_parley; impl BaseDocument { pub(crate) fn compute_inline_layout( @@ -295,6 +296,10 @@ impl BaseDocument { #[cfg(not(feature = "floats"))] let is_floated = false; + // CSS2.1 §10.8.1: overflow != visible → baseline = bottom margin edge + let overflow_not_visible = + style.overflow.x.is_scroll_container() || style.overflow.y.is_scroll_container(); + if style.position == Position::Absolute || is_floated { ibox.width = 0.0; ibox.height = 0.0; @@ -302,6 +307,42 @@ impl BaseDocument { let output = self.compute_child_layout(NodeId::from(ibox.id), child_inputs); ibox.width = (margin.left + margin.right + output.size.width) * scale; ibox.height = (margin.top + margin.bottom + output.size.height) * scale; + + // CSS2.1 §10.8.1: the baseline of an inline-block is the baseline + // of its last in-flow line box, unless it has no in-flow line boxes + // or its overflow is not visible, in which case it's the bottom + // margin edge (represented as first_baseline = None). + // + // Note: the internal baseline may exceed the box height when the + // box has an explicit small height but inherits a large line-height. + // This is correct — parley's metric clamping (shift_offset==0 → clamp) + // prevents line expansion while the full baseline is used for positioning. + ibox.first_baseline = if overflow_not_visible { + None + } else { + output.first_baselines.y.map(|b| (b + margin.top) * scale) + }; + + eprintln!( + "[ibox] id={} w={:.1} h={:.1} first_baseline={:?} overflow_hidden={} margin={:.1}/{:.1}/{:.1}/{:.1} output_size={:.1}x{:.1} scale={:.2}", + ibox.id, ibox.width, ibox.height, ibox.first_baseline, + overflow_not_visible, + margin.top, margin.right, margin.bottom, margin.left, + output.size.width, output.size.height, scale + ); + } + + // Re-read baseline_shift from node style each time to avoid accumulating + // scales across repeated layout passes (e.g. flex sizing calls this multiple times) + let node_for_shift = &self.nodes[ibox.id as usize]; + if let Some(styles) = node_for_shift.primary_styles() { + let bs = crate::stylo_to_parley::baseline_shift(&styles); + ibox.baseline_shift = match bs { + parley::style::BaselineShift::Length(v) => { + parley::style::BaselineShift::Length(v * scale) + } + other => other, + }; } } @@ -437,121 +478,31 @@ impl BaseDocument { // }; // } + // CSS strut (CSS2.1 §10.8.1): set minimum line metrics from root font + if let Some((strut_ascent, strut_descent, strut_line_height, strut_x_height)) = + self.compute_strut_metrics(node_id, scale) + { + inline_layout.layout.set_strut( + strut_ascent, + strut_descent, + strut_line_height, + strut_x_height, + ); + } + #[cfg(not(feature = "floats"))] { inline_layout.layout.break_all_lines(Some(width)); } // Perform inline layout + // TODO: Re-implement float-aware line breaking with new parley BreakLines API. + // The new API no longer exposes state_mut()/YieldData, so float interaction + // during line breaking is not yet supported. Float positioning is handled + // in the post-layout loop below. #[cfg(feature = "floats")] { - let mut breaker = inline_layout.layout.break_lines(); - let initial_slot = block_ctx.find_content_slot(0.0, Clear::None, None); - let mut has_active_floats = initial_slot.segment_id.is_some(); - let state = breaker.state_mut(); - state.set_layout_max_advance(width); - state.set_line_max_advance(initial_slot.width * scale); - state.set_line_x(initial_slot.x * scale); - state.set_line_y((initial_slot.y * scale) as f64); - - // TODO: revert state and retry layout if a line doesn't fit - // - // Save initial state. Saved state is used to revert the layout to a previous state if needed - // (e.g. to revert a line that doesn't fit in the space it was laid out into) - // - // let mut saved_state = breaker.state().clone(); - - while let Some(yield_data) = breaker.break_next() { - match yield_data { - YieldData::LineBreak(line_break_data) => { - let state = breaker.state_mut(); - - if has_active_floats { - // TODO: revert state and retry layout if a line doesn't fit - // saved_state = state.clone(); - - let min_y = (state.line_y() + line_break_data.line_height as f64) - / scale as f64; - let next_slot = - block_ctx.find_content_slot(min_y as f32, Clear::None, None); - has_active_floats = next_slot.segment_id.is_some(); - - state.set_line_max_advance(next_slot.width * scale); - state.set_line_x(next_slot.x * scale); - state.set_line_y((next_slot.y * scale) as f64); - } else { - state.set_line_x(0.0); - state.set_line_max_advance(width); - state.set_line_y(state.line_y() + line_break_data.line_height as f64); - } - - continue; - } - YieldData::MaxHeightExceeded(_data) => { - // TODO - continue; - } - YieldData::InlineBoxBreak(box_break_data) => { - let state = breaker.state_mut(); - let node_id = box_break_data.inline_box_id as usize; - let node = &mut self.nodes[node_id]; - - // We can assume that the box is a float because we only set `break_on_box: true` for floats - let direction = match node.style.float { - Float::Left => taffy::FloatDirection::Left, - Float::Right => taffy::FloatDirection::Right, - Float::None => unreachable!(), - }; - let clear = node.style.clear; - let margin = node - .style - .margin - .resolve_or_zero(inputs.parent_size, resolve_calc_value); - - let margin_sum = margin.sum_axes(); - - let output = - self.compute_child_layout(NodeId::from(node_id), float_child_inputs); - let min_y = state.line_y() as f32 / scale; - let mut pos = block_ctx.place_floated_box( - output.size + margin_sum, - min_y, - direction, - clear, - ); - pos.x += container_pb.left; - pos.y += container_pb.top; - - let min_y = state.line_y() / scale as f64; //.max(pos.y as f64); - let next_slot = - block_ctx.find_content_slot(min_y as f32, Clear::None, None); - has_active_floats = next_slot.segment_id.is_some(); - - state.set_line_max_advance(next_slot.width * scale); - state.set_line_x(next_slot.x * scale); - state.set_line_y((next_slot.y * scale) as f64); - - let layout = &mut self.nodes[node_id].unrounded_layout; - layout.size = output.size; - layout.location.x = pos.x + margin.left + container_pb.left; - layout.location.y = pos.y + margin.top + container_pb.top; - - // dbg!(&layout.size); - // dbg!(&layout.location); - - state.append_inline_box_to_line(box_break_data.advance, 0.0); - - // if float.is_floated() { - // println!("INLINE FLOATED BOX ({}) {:?}", ibox.id, float); - // println!( - // "w:{} h:{} x:{}, y:{}", - // layout.size.width, layout.size.height, 0, 0 - // ); - // } - } - } - } - breaker.finish(); + inline_layout.layout.break_all_lines(Some(width)); } let alignment = self.nodes[node_id] @@ -575,6 +526,7 @@ impl BaseDocument { .unwrap_or(parley::layout::Alignment::Start); inline_layout.layout.align( + Some(width), alignment, AlignmentOptions { align_when_overflowing: false, @@ -645,6 +597,12 @@ impl BaseDocument { let is_floated = false; if node.style.position == Position::Absolute { + // Skip absolute boxes that were reparented to a different containing block. + // They will be sized and positioned by Taffy through the containing block. + if self.nodes[ibox.id as usize].layout_parent.get() != Some(node_id) { + continue; + } + let output = self.compute_child_layout(NodeId::from(ibox.id), child_inputs); let layout = &mut self.nodes[ibox.id as usize].unrounded_layout; @@ -671,9 +629,30 @@ impl BaseDocument { layout.padding = padding; //.map(|p| p / scale); layout.border = border; //.map(|p| p / scale); } else if is_floated { - let layout = &mut self.nodes[ibox.id as usize].unrounded_layout; - layout.padding = padding; //.map(|p| p / scale); - layout.border = border; //.map(|p| p / scale); + #[cfg(feature = "floats")] + { + let float_dir = match self.nodes[ibox.id as usize].style.float { + Float::Left => taffy::FloatDirection::Left, + Float::Right => taffy::FloatDirection::Right, + Float::None => unreachable!(), + }; + let clear = self.nodes[ibox.id as usize].style.clear; + let output = self + .compute_child_layout(NodeId::from(ibox.id), float_child_inputs); + let min_y = ibox.y / scale; + let pos = block_ctx.place_floated_box( + output.size + margin.sum_axes(), + min_y, + float_dir, + clear, + ); + let layout = &mut self.nodes[ibox.id as usize].unrounded_layout; + layout.size = output.size; + layout.location.x = pos.x + margin.left + container_pb.left; + layout.location.y = pos.y + margin.top + container_pb.top; + layout.padding = padding; + layout.border = border; + } } else { let layout = &mut node.unrounded_layout; layout.size.width = (ibox.width / scale) - margin.left - margin.right; @@ -693,6 +672,23 @@ impl BaseDocument { // println!("known_dimensions: w: {:?} h: {:?}", inputs.known_dimensions.width, inputs.known_dimensions.height); // println!("\n"); + // CSS2.1 §10.8.1: the baseline of an inline-block is the baseline + // of its last in-flow line box. + // + // When the box has an explicit height that clips later lines (e.g. height: 4px + // but the content spans multiple lines), the last line's baseline may be outside + // the box. In that case, walk backwards to find the last line whose baseline + // fits within the box, falling back to the first line if none fit (handles the + // case where even line 1's baseline exceeds a tiny explicit height). + let content_height = final_size.height * scale; + let last_baseline_y = inline_layout + .layout + .lines() + .rev() + .find(|line| line.metrics().baseline <= content_height) + .or_else(|| inline_layout.layout.lines().next()) + .map(|line| (line.metrics().baseline / scale) + container_pb.top); + // Put layout back self.nodes[node_id] .data @@ -721,7 +717,10 @@ impl BaseDocument { LayoutOutput { size, content_size: measured_size + padding.sum_axes(), - first_baselines: Point::NONE, + first_baselines: Point { + x: None, + y: last_baseline_y, + }, top_margin: CollapsibleMarginSet::ZERO, bottom_margin: CollapsibleMarginSet::ZERO, margins_can_collapse_through: !has_styles_preventing_being_collapsed_through @@ -729,6 +728,96 @@ impl BaseDocument { && measured_size.height == 0.0, } } + + /// Compute CSS strut metrics (CSS2.1 §10.8.1) for an inline formatting context root. + /// + /// Returns `(ascent, descent, line_height, x_height)` in physical pixels, or `None` + /// if the root node's font cannot be resolved. + fn compute_strut_metrics(&self, node_id: usize, scale: f32) -> Option<(f32, f32, f32, f32)> { + let styles = self.nodes[node_id].primary_styles()?; + let font_styles = styles.get_font(); + + // Resolve font size in CSS pixels + let font_size_px = font_styles.font_size.used_size.0.px(); + + // Query fontique for matching font + let mut font_ctx = self.font_ctx.lock().unwrap(); + let font_ctx = &mut *font_ctx; + let mut query = font_ctx.collection.query(&mut font_ctx.source_cache); + + let families = font_styles + .font_family + .families + .iter() + .map(stylo_to_parley::query_font_family); + query.set_families(families); + query.set_attributes(Attributes { + width: stylo_to_parley::font_width(font_styles.font_stretch), + weight: stylo_to_parley::font_weight(font_styles.font_weight), + style: stylo_to_parley::font_style(font_styles.font_style), + }); + + // Find a font that supports the space character + let mut font: Option = None; + query.matches_with(|q_font: &QueryFont| { + let Ok(font_ref) = skrifa::FontRef::from_index(q_font.blob.as_ref(), q_font.index) + else { + return QueryStatus::Continue; + }; + if font_ref.charmap().map(' ').is_some() { + font = Some(q_font.clone()); + QueryStatus::Stop + } else { + QueryStatus::Continue + } + }); + let font = font?; + + // Get skrifa metrics at CSS-pixel font size + let font_ref = skrifa::FontRef::from_index(font.blob.as_ref(), font.index).ok()?; + let metrics = skrifa::metrics::Metrics::new( + &font_ref, + skrifa::instance::Size::new(font_size_px), + skrifa::instance::LocationRef::default(), + ); + + // Scale to physical pixels + let strut_ascent = metrics.ascent * scale; + let strut_descent = -metrics.descent * scale; // skrifa descent is negative + let strut_line_height = match font_styles.line_height { + // CSS2.1 §10.8.1: "normal" uses font's recommended line spacing + stylo_to_parley::stylo::LineHeight::Normal => { + // OS/2 Win metrics (usWinAscent + usWinDescent) are closer to + // what browsers use for line-height: normal than skrifa's default + // Metrics (which may use sTypo metrics that sum to exactly UPM + // with zero leading for fonts like Helvetica). + use skrifa::raw::TableProvider; + let upm = font_ref.head().map(|h| h.units_per_em() as f32).unwrap_or(1000.0); + let scale_factor = font_size_px / upm; + let normal_lh = font_ref + .os2() + .ok() + .map(|os2| { + let win_ascent = os2.us_win_ascent() as f32 * scale_factor; + let win_descent = os2.us_win_descent() as f32 * scale_factor; + win_ascent + win_descent + }) + .unwrap_or(metrics.ascent - metrics.descent + metrics.leading); + normal_lh * scale + } + stylo_to_parley::stylo::LineHeight::Number(n) => n.0 * font_size_px * scale, + stylo_to_parley::stylo::LineHeight::Length(v) => v.0.px() * scale, + }; + // x_height for vertical-align: middle; CSS spec fallback is font_size * 0.5 + let strut_x_height = metrics.x_height.unwrap_or(font_size_px * 0.5) * scale; + + Some(( + strut_ascent, + strut_descent, + strut_line_height, + strut_x_height, + )) + } } #[inline(always)] diff --git a/packages/blitz-dom/src/layout/list.rs b/packages/blitz-dom/src/layout/list.rs index e44d67e0f..86febd371 100644 --- a/packages/blitz-dom/src/layout/list.rs +++ b/packages/blitz-dom/src/layout/list.rs @@ -1,7 +1,8 @@ use markup5ever::local_name; use parley::FontFamily; use style::computed_values::list_style_position::T as ListStylePosition; -use style::computed_values::list_style_type::T as ListStyleType; +use style::values::computed::ListStyleType; +use style::Atom; use crate::{ BaseDocument, @@ -75,7 +76,7 @@ fn node_list_item_child( ListStylePosition::Outside => { let mut parley_style = stylo_to_parley::style(child_id, &styles); - if let Some(font_family) = font_for_bullet_style(list_style_type) { + if let Some(font_family) = font_for_bullet_style(&list_style_type) { parley_style.font_family = font_family; } @@ -107,44 +108,47 @@ fn node_list_item_child( Some(ListItemLayout { marker, position }) } +/// Helper to get the counter style name atom from a ListStyleType, if it's a named style. +fn counter_style_name(list_style_type: &ListStyleType) -> Option<&Atom> { + use style::counter_style::CounterStyle; + use style::values::CustomIdent; + match &list_style_type.0 { + CounterStyle::Name(CustomIdent(atom)) => Some(atom), + _ => None, + } +} + // Determine the marker to render for a given list style type fn marker_for_style(list_style_type: ListStyleType, index: usize) -> Option { - if list_style_type == ListStyleType::None { + if list_style_type == ListStyleType::none() { return None; } - Some(match list_style_type { - ListStyleType::LowerAlpha => { + let name = counter_style_name(&list_style_type); + Some(match name.map(|a| &**a) { + Some("lower-alpha") => { let mut marker = String::new(); build_alpha_marker(index, &mut marker); Marker::String(format!("{marker}. ")) } - ListStyleType::UpperAlpha => { + Some("upper-alpha") => { let mut marker = String::new(); build_alpha_marker(index, &mut marker); Marker::String(format!("{}. ", marker.to_ascii_uppercase())) } - ListStyleType::Decimal => Marker::String(format!("{}. ", index + 1)), - ListStyleType::Disc => Marker::Char('•'), - ListStyleType::Circle => Marker::Char('◦'), - ListStyleType::Square => Marker::Char('▪'), - ListStyleType::DisclosureOpen => Marker::Char('▾'), - ListStyleType::DisclosureClosed => Marker::Char('▸'), + Some("decimal") => Marker::String(format!("{}. ", index + 1)), + Some("disc") => Marker::Char('•'), + Some("circle") => Marker::Char('◦'), + Some("square") => Marker::Char('▪'), + Some("disclosure-open") => Marker::Char('▾'), + Some("disclosure-closed") => Marker::Char('▸'), _ => Marker::Char('□'), }) } // Override the font to our specific bullet font when rendering bullets -fn font_for_bullet_style(list_style_type: ListStyleType) -> Option> { - let bullet_font = Some("Bullet, monospace, sans-serif".into()); - match list_style_type { - ListStyleType::Disc - | ListStyleType::Circle - | ListStyleType::Square - | ListStyleType::DisclosureOpen - | ListStyleType::DisclosureClosed => bullet_font, - _ => None, - } +fn font_for_bullet_style(list_style_type: &ListStyleType) -> Option> { + list_style_type.0.is_bullet().then(|| "Bullet, monospace, sans-serif".into()) } const ALPHABET: [char; 26] = [ @@ -163,26 +167,33 @@ fn build_alpha_marker(index: usize, str: &mut String) { } } +#[cfg(test)] +fn named_list_style(name: &str) -> ListStyleType { + use style::counter_style::CounterStyle; + use style::values::CustomIdent; + ListStyleType(CounterStyle::Name(CustomIdent(Atom::from(name)))) +} + #[test] fn test_marker_for_disc() { - let result = marker_for_style(ListStyleType::Disc, 0); + let result = marker_for_style(named_list_style("disc"), 0); assert_eq!(result, Some(Marker::Char('•'))); } #[test] fn test_marker_for_decimal() { - let result_1 = marker_for_style(ListStyleType::Decimal, 0); - let result_2 = marker_for_style(ListStyleType::Decimal, 1); + let result_1 = marker_for_style(named_list_style("decimal"), 0); + let result_2 = marker_for_style(named_list_style("decimal"), 1); assert_eq!(result_1, Some(Marker::String("1. ".to_string()))); assert_eq!(result_2, Some(Marker::String("2. ".to_string()))); } #[test] fn test_marker_for_lower_alpha() { - let result_1 = marker_for_style(ListStyleType::LowerAlpha, 0); - let result_2 = marker_for_style(ListStyleType::LowerAlpha, 1); - let result_extended_1 = marker_for_style(ListStyleType::LowerAlpha, 26); - let result_extended_2 = marker_for_style(ListStyleType::LowerAlpha, 27); + let result_1 = marker_for_style(named_list_style("lower-alpha"), 0); + let result_2 = marker_for_style(named_list_style("lower-alpha"), 1); + let result_extended_1 = marker_for_style(named_list_style("lower-alpha"), 26); + let result_extended_2 = marker_for_style(named_list_style("lower-alpha"), 27); assert_eq!(result_1, Some(Marker::String("a. ".to_string()))); assert_eq!(result_2, Some(Marker::String("b. ".to_string()))); assert_eq!(result_extended_1, Some(Marker::String("aa. ".to_string()))); @@ -191,10 +202,10 @@ fn test_marker_for_lower_alpha() { #[test] fn test_marker_for_upper_alpha() { - let result_1 = marker_for_style(ListStyleType::UpperAlpha, 0); - let result_2 = marker_for_style(ListStyleType::UpperAlpha, 1); - let result_extended_1 = marker_for_style(ListStyleType::UpperAlpha, 26); - let result_extended_2 = marker_for_style(ListStyleType::UpperAlpha, 27); + let result_1 = marker_for_style(named_list_style("upper-alpha"), 0); + let result_2 = marker_for_style(named_list_style("upper-alpha"), 1); + let result_extended_1 = marker_for_style(named_list_style("upper-alpha"), 26); + let result_extended_2 = marker_for_style(named_list_style("upper-alpha"), 27); assert_eq!(result_1, Some(Marker::String("A. ".to_string()))); assert_eq!(result_2, Some(Marker::String("B. ".to_string()))); assert_eq!(result_extended_1, Some(Marker::String("AA. ".to_string()))); diff --git a/packages/blitz-dom/src/layout/table.rs b/packages/blitz-dom/src/layout/table.rs index 7ed99c69d..8a72de3ad 100644 --- a/packages/blitz-dom/src/layout/table.rs +++ b/packages/blitz-dom/src/layout/table.rs @@ -83,8 +83,6 @@ pub(crate) fn build_table_context( let border_collapse = stylo_styles.clone_border_collapse(); let border_spacing = stylo_styles.clone_border_spacing().0; - drop(stylo_styles); - let mut column_sizes: Vec = Vec::new(); let mut first_cell_border: Option> = None; for child_id in children.iter().copied() { diff --git a/packages/blitz-dom/src/mutator.rs b/packages/blitz-dom/src/mutator.rs index 34a34c425..31ef6edd4 100644 --- a/packages/blitz-dom/src/mutator.rs +++ b/packages/blitz-dom/src/mutator.rs @@ -135,10 +135,9 @@ impl DocumentMutator<'_> { let node = self.doc.get_node(id).unwrap(); // Initialise style data - *node.stylo_element_data.borrow_mut() = Some(style::data::ElementData { - damage: ALL_DAMAGE, - ..Default::default() - }); + let wrapper = style::data::ElementDataWrapper::default(); + wrapper.borrow_mut().damage = ALL_DAMAGE; + node.stylo_element_data.set(wrapper); id } @@ -215,7 +214,7 @@ impl DocumentMutator<'_> { self.doc.snapshot_node(node_id); let node = &mut self.doc.nodes[node_id]; - if let Some(data) = &mut *node.stylo_element_data.borrow_mut() { + if let Some(mut data) = node.stylo_element_data.borrow_mut() { data.hint |= RestyleHint::restyle_subtree(); data.damage.insert(ALL_DAMAGE); } @@ -224,7 +223,7 @@ impl DocumentMutator<'_> { let parent = node.parent; if let Some(parent_id) = parent { let parent = &mut self.doc.nodes[parent_id]; - if let Some(data) = &mut *parent.stylo_element_data.borrow_mut() { + if let Some(mut data) = parent.stylo_element_data.borrow_mut() { data.hint |= RestyleHint::restyle_subtree(); } } @@ -294,12 +293,10 @@ impl DocumentMutator<'_> { let node = &mut self.doc.nodes[node_id]; - let mut stylo_element_data = node.stylo_element_data.borrow_mut(); - if let Some(data) = &mut *stylo_element_data { + if let Some(mut data) = node.stylo_element_data.borrow_mut() { data.hint |= RestyleHint::restyle_subtree(); data.damage.insert(ALL_DAMAGE); } - drop(stylo_element_data); // Mark ancestors dirty so the style traversal visits this subtree. // Without this, the traversal may skip nodes with pending RestyleHint/damage. @@ -394,7 +391,7 @@ impl DocumentMutator<'_> { // TODO: make this fine grained / conditional based on ElementSelectorFlags if parent_is_in_doc { - if let Some(data) = &mut *parent.stylo_element_data.borrow_mut() { + if let Some(mut data) = parent.stylo_element_data.borrow_mut() { data.hint |= RestyleHint::restyle_subtree(); } // Mark ancestors dirty so the style traversal visits this subtree. @@ -414,7 +411,7 @@ impl DocumentMutator<'_> { // TODO: make this fine grained / conditional based on ElementSelectorFlags if parent_is_in_doc { - if let Some(data) = &mut *parent.stylo_element_data.borrow_mut() { + if let Some(mut data) = parent.stylo_element_data.borrow_mut() { data.hint |= RestyleHint::restyle_subtree(); } // Mark ancestors dirty so the style traversal visits this subtree. @@ -467,7 +464,7 @@ impl DocumentMutator<'_> { // TODO: make this fine grained / conditional based on ElementSelectorFlags if new_parent_is_in_doc { - if let Some(data) = &mut *new_parent.stylo_element_data.borrow_mut() { + if let Some(mut data) = new_parent.stylo_element_data.borrow_mut() { data.hint |= RestyleHint::restyle_subtree(); } // Mark ancestors dirty so the style traversal visits this subtree. @@ -491,7 +488,7 @@ impl DocumentMutator<'_> { // TODO: make this fine grained / conditional based on ElementSelectorFlags if child_was_in_doc { - if let Some(data) = &mut *old_parent.stylo_element_data.borrow_mut() { + if let Some(mut data) = old_parent.stylo_element_data.borrow_mut() { data.hint |= RestyleHint::restyle_subtree(); } // Mark ancestors dirty so the style traversal visits this subtree. diff --git a/packages/blitz-dom/src/node/mod.rs b/packages/blitz-dom/src/node/mod.rs index 57156a8e6..3183e490d 100644 --- a/packages/blitz-dom/src/node/mod.rs +++ b/packages/blitz-dom/src/node/mod.rs @@ -3,6 +3,7 @@ mod attributes; mod element; mod node; +mod stylo_data; pub use attributes::{Attribute, Attributes}; pub use element::{ @@ -11,3 +12,4 @@ pub use element::{ Status, TextBrush, TextInputData, TextLayout, }; pub use node::*; +pub use stylo_data::StyloData; diff --git a/packages/blitz-dom/src/node/node.rs b/packages/blitz-dom/src/node/node.rs index cbaf3b804..c0c3abfcd 100644 --- a/packages/blitz-dom/src/node/node.rs +++ b/packages/blitz-dom/src/node/node.rs @@ -1,4 +1,3 @@ -use atomic_refcell::{AtomicRef, AtomicRefCell, AtomicRefMut}; use bitflags::bitflags; use blitz_traits::events::{ BlitzPointerEvent, BlitzPointerId, DomEventData, HitResult, PointerCoords, @@ -15,14 +14,15 @@ use std::fmt::Write; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use style::Atom; +use super::StyloData; use style::invalidation::element::restyle_hints::RestyleHint; use style::properties::ComputedValues; use style::properties::generated::longhands::position::computed_value::T as Position; use style::selector_parser::{PseudoElement, RestyleDamage}; +use style::shared_lock::SharedRwLock; use style::stylesheets::UrlExtraData; use style::values::computed::Display as StyloDisplay; use style::values::specified::box_::{DisplayInside, DisplayOutside}; -use style::{data::ElementData as StyloElementData, shared_lock::SharedRwLock}; use style_dom::ElementState; use style_traits::values::ToCss; use taffy::{ @@ -101,9 +101,9 @@ pub struct Node { /// Node type (Element, TextNode, etc) specific data pub data: NodeData, - // This little bundle of joy is our style data from stylo and a lock guard that allows access to it - // TODO: See if guard can be hoisted to a higher level - pub stylo_element_data: AtomicRefCell>, + /// Style data from stylo. Wrapped in `StyloData` to encapsulate the interior + /// mutability needed by stylo's `TElement` trait. + pub stylo_element_data: StyloData, pub selector_flags: Cell, pub guard: SharedRwLock, pub element_state: ElementState, @@ -114,6 +114,8 @@ pub struct Node { // Taffy layout data: pub style: Style, + /// Original CSS position value (not the Taffy mapping which loses Fixed→Absolute and Sticky→Relative) + pub css_position: Position, pub has_snapshot: bool, pub snapshot_handled: AtomicBool, /// Whether any descendant of this node needs restyling. @@ -124,6 +126,9 @@ pub struct Node { pub unrounded_layout: Layout, pub final_layout: Layout, pub scroll_offset: crate::Point, + /// Sticky positioning offset: visual displacement from normal-flow position. + /// Recomputed on every scroll event without relayout. + pub sticky_offset: crate::Point, } unsafe impl Send for Node {} @@ -175,6 +180,7 @@ impl Node { after: None, style: Default::default(), + css_position: Position::Static, has_snapshot: false, snapshot_handled: AtomicBool::new(false), dirty_descendants: AtomicBool::new(true), @@ -183,6 +189,7 @@ impl Node { unrounded_layout: Layout::new(), final_layout: Layout::new(), scroll_offset: crate::Point::ZERO, + sticky_offset: crate::Point::ZERO, } } @@ -255,8 +262,8 @@ impl Node { } pub fn set_restyle_hint(&self, hint: RestyleHint) { - if let Some(element_data) = self.stylo_element_data.borrow_mut().as_mut() { - element_data.hint.insert(hint); + if let Some(mut data) = self.stylo_element_data.borrow_mut() { + data.hint.insert(hint); } // Mark all ancestors as having dirty descendants so the style traversal // will visit this node's subtree @@ -280,7 +287,7 @@ impl Node { /// Set appropriate damage for Stylo when an element's style attribute is updated pub(crate) fn mark_style_attr_updated(&mut self) { - if let Some(data) = &mut self.stylo_element_data.get_mut() { + if let Some(mut data) = self.stylo_element_data.borrow_mut() { data.hint |= RestyleHint::RESTYLE_STYLE_ATTRIBUTE; self.set_dirty_descendants(); } @@ -302,45 +309,30 @@ impl Node { } } - pub fn damage_mut(&self) -> Option> { - let element_data = self.stylo_element_data.borrow_mut(); - #[allow(clippy::manual_map, reason = "false positive")] - match *element_data { - Some(_) => Some(AtomicRefMut::map( - element_data, - |data: &mut Option| &mut data.as_mut().unwrap().damage, - )), - None => None, - } - } - pub fn damage(&mut self) -> Option { - self.stylo_element_data - .get_mut() - .as_ref() - .map(|data| data.damage) + self.stylo_element_data.borrow().map(|data| data.damage) } pub fn set_damage(&self, damage: RestyleDamage) { - if let Some(data) = self.stylo_element_data.borrow_mut().as_mut() { + if let Some(mut data) = self.stylo_element_data.borrow_mut() { data.damage = damage; } } pub fn insert_damage(&mut self, damage: RestyleDamage) { - if let Some(data) = self.stylo_element_data.get_mut().as_mut() { + if let Some(mut data) = self.stylo_element_data.borrow_mut() { data.damage |= damage; } } pub fn remove_damage(&self, damage: RestyleDamage) { - if let Some(data) = self.stylo_element_data.borrow_mut().as_mut() { + if let Some(mut data) = self.stylo_element_data.borrow_mut() { data.damage.remove(damage); } } pub fn clear_damage_mut(&mut self) { - if let Some(data) = self.stylo_element_data.get_mut() { + if let Some(mut data) = self.stylo_element_data.borrow_mut() { data.damage = RestyleDamage::empty(); } } @@ -803,22 +795,8 @@ impl Node { Some(&attr.value) } - pub fn primary_styles(&self) -> Option> { - let stylo_element_data = self.stylo_element_data.borrow(); - if stylo_element_data - .as_ref() - .and_then(|d| d.styles.get_primary()) - .is_some() - { - Some(AtomicRef::map( - stylo_element_data, - |data: &Option| -> &ComputedValues { - data.as_ref().unwrap().styles.get_primary().unwrap() - }, - )) - } else { - None - } + pub fn primary_styles(&self) -> Option> { + self.stylo_element_data.borrow()?.styles.get_primary().cloned() } pub fn text_content(&self) -> String { @@ -885,12 +863,21 @@ impl Node { return true; } + // Transform (any value other than none) + if !style.get_box().transform.0.is_empty() { + return true; + } + + // Filter (any value other than none) + if !style.get_effects().filter.0.is_empty() { + return true; + } + // TODO: mix-blend-mode - // TODO: transforms - // TODO: filter // TODO: clip-path // TODO: mask // TODO: isolation + // TODO: perspective // TODO: contain false @@ -917,8 +904,10 @@ impl Node { } } - let mut x = x - self.final_layout.location.x + self.scroll_offset.x as f32; - let mut y = y - self.final_layout.location.y + self.scroll_offset.y as f32; + let mut x = x - self.final_layout.location.x - self.sticky_offset.x as f32 + + self.scroll_offset.x as f32; + let mut y = y - self.final_layout.location.y - self.sticky_offset.y as f32 + + self.scroll_offset.y as f32; let size = self.final_layout.size; let matches_self = !(x < 0.0 @@ -1080,8 +1069,10 @@ impl Node { /// Computes the Document-relative coordinates of the `Node` pub fn absolute_position(&self, x: f32, y: f32) -> crate::util::Point { - let x = x + self.final_layout.location.x - self.scroll_offset.x as f32; - let y = y + self.final_layout.location.y - self.scroll_offset.y as f32; + let x = x + self.final_layout.location.x + self.sticky_offset.x as f32 + - self.scroll_offset.x as f32; + let y = y + self.final_layout.location.y + self.sticky_offset.y as f32 + - self.scroll_offset.y as f32; // Recurse up the layout hierarchy self.layout_parent diff --git a/packages/blitz-dom/src/node/stylo_data.rs b/packages/blitz-dom/src/node/stylo_data.rs new file mode 100644 index 000000000..fb52fc417 --- /dev/null +++ b/packages/blitz-dom/src/node/stylo_data.rs @@ -0,0 +1,55 @@ +use std::cell::UnsafeCell; +use std::fmt; +use style::data::{ElementDataMut, ElementDataRef, ElementDataWrapper}; + +/// Interior-mutable wrapper around `Option`. +/// +/// Encapsulates the `UnsafeCell` so that access sites don't need raw `unsafe` blocks. +/// Safety relies on stylo's single-threaded traversal model: mutations (`set`/`clear`) +/// only happen during exclusive-access phases, and borrows don't overlap with mutations. +pub struct StyloData { + inner: UnsafeCell>, +} + +impl Default for StyloData { + fn default() -> Self { + Self { + inner: UnsafeCell::new(None), + } + } +} + +impl fmt::Debug for StyloData { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("StyloData").finish_non_exhaustive() + } +} + +impl StyloData { + /// Whether element data has been initialized. + pub fn has_data(&self) -> bool { + unsafe { &*self.inner.get() }.is_some() + } + + /// Borrow the element data immutably, if present. + pub fn borrow(&self) -> Option> { + let opt = unsafe { &*self.inner.get() }; + opt.as_ref().map(|w| w.borrow()) + } + + /// Borrow the element data mutably, if present. + pub fn borrow_mut(&self) -> Option> { + let opt = unsafe { &*self.inner.get() }; + opt.as_ref().map(|w| w.borrow_mut()) + } + + /// Set the element data wrapper. + pub fn set(&self, data: ElementDataWrapper) { + unsafe { *self.inner.get() = Some(data) }; + } + + /// Clear the element data, returning to the uninitialized state. + pub fn clear(&self) { + unsafe { *self.inner.get() = None }; + } +} diff --git a/packages/blitz-dom/src/resolve.rs b/packages/blitz-dom/src/resolve.rs index 6bc6a2a9b..a7068be35 100644 --- a/packages/blitz-dom/src/resolve.rs +++ b/packages/blitz-dom/src/resolve.rs @@ -70,15 +70,22 @@ impl BaseDocument { self.resolve_layout_children(); timer.record_time("construct"); + // Reparent absolutely/fixed-positioned children to their correct containing block + // ancestor so Taffy resolves insets and percentages against the right dimensions. + self.reparent_out_of_flow_children(); + timer.record_time("reparent"); + self.resolve_deferred_tasks(); timer.record_time("pconstruct"); // Merge stylo into taffy self.flush_styles_to_layout(root_node_id); + self.collect_sticky_nodes(); timer.record_time("flush"); // Next we resolve layout with the data resolved by stlist self.resolve_layout(); + self.recompute_sticky_offsets(); timer.record_time("layout"); // Clear all damage and dirty flags @@ -171,9 +178,8 @@ impl BaseDocument { for child_id in layout_children.iter().copied() { resolve_layout_children_recursive(doc, child_id); doc.nodes[child_id].layout_parent.set(Some(node_id)); - if let Some(data) = doc.nodes[child_id].stylo_element_data.get_mut() { - data.damage - .remove(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX); + if let Some(mut data) = doc.nodes[child_id].stylo_element_data.borrow_mut() { + data.damage.remove(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX); } } @@ -203,6 +209,112 @@ impl BaseDocument { } } + /// Move absolutely/fixed-positioned children from their DOM parent's layout_children + /// to their correct CSS containing block ancestor's layout_children. + /// + /// This ensures Taffy resolves insets and percentage sizes against the correct + /// containing block dimensions (CSS2.1 §10.1). + /// + /// Sticky-positioned elements are intentionally NOT reparented — they participate + /// in normal flow and their visual offset is computed at scroll-time. + fn reparent_out_of_flow_children(&mut self) { + use style::computed_values::position::T as CssPosition; + + // Collect reparenting operations: (child_id, old_parent_id, new_parent_id) + let mut reparent_list: Vec<(usize, usize, usize)> = Vec::new(); + + for (node_id, node) in self.nodes.iter() { + let Some(style) = node.primary_styles() else { + continue; + }; + let position = style.clone_position(); + + let is_abs = position == CssPosition::Absolute; + let is_fixed = position == CssPosition::Fixed; + if !is_abs && !is_fixed { + continue; + } + + let Some(current_parent) = node.layout_parent.get() else { + continue; + }; + + let target = if is_fixed { + self.find_fixed_containing_block(node_id) + } else { + self.find_absolute_containing_block(node_id) + }; + + if let Some(target) = target { + if current_parent != target { + reparent_list.push((node_id, current_parent, target)); + } + } + } + + // Apply reparenting + for (child_id, old_parent, new_parent) in reparent_list { + if let Some(ref mut children) = *self.nodes[old_parent].layout_children.borrow_mut() { + children.retain(|&id| id != child_id); + } + if let Some(ref mut children) = *self.nodes[new_parent].layout_children.borrow_mut() { + children.push(child_id); + } + self.nodes[child_id].layout_parent.set(Some(new_parent)); + } + } + + /// Find the containing block for an absolutely-positioned element. + /// This is the nearest ancestor with position != static (CSS2.1 §10.1). + fn find_absolute_containing_block(&self, node_id: usize) -> Option { + let mut current = self.nodes[node_id].parent?; + loop { + if self.node_is_positioned(current) { + return Some(current); + } + match self.nodes[current].parent { + Some(p) => current = p, + None => return Some(current), // root = initial containing block + } + } + } + + /// Find the containing block for a fixed-position element. + /// This is the nearest ancestor with transform/filter/perspective, + /// or the root element (viewport) if none found. + fn find_fixed_containing_block(&self, node_id: usize) -> Option { + let mut current = self.nodes[node_id].parent?; + loop { + if self.node_creates_containing_block_for_fixed(current) { + return Some(current); + } + match self.nodes[current].parent { + Some(p) => current = p, + None => return Some(current), // root = viewport + } + } + } + + /// Returns true if the node has position != static (is "positioned"). + fn node_is_positioned(&self, node_id: usize) -> bool { + use style::computed_values::position::T as CssPosition; + self.nodes[node_id] + .primary_styles() + .map(|s| s.clone_position() != CssPosition::Static) + .unwrap_or(false) + } + + /// Returns true if the node creates a containing block for fixed-position descendants. + /// Per CSS spec, this is triggered by transform, filter, or perspective. + fn node_creates_containing_block_for_fixed(&self, node_id: usize) -> bool { + let Some(style) = self.nodes[node_id].primary_styles() else { + return false; + }; + !style.get_box().transform.0.is_empty() + || !style.get_effects().filter.0.is_empty() + // TODO: perspective, will-change: transform/filter + } + pub fn resolve_deferred_tasks(&mut self) { let mut deferred_construction_nodes = std::mem::take(&mut self.deferred_construction_nodes); @@ -286,6 +398,178 @@ impl BaseDocument { /// /// TODO: update taffy to use an associated type instead of slab key /// TODO: update taffy to support traited styles so we don't even need to rely on taffy for storage + /// Collect all position:sticky nodes for efficient recomputation on scroll. + fn collect_sticky_nodes(&mut self) { + use style::computed_values::position::T as CssPosition; + self.sticky_nodes.clear(); + for (node_id, node) in self.nodes.iter() { + if node.css_position == CssPosition::Sticky { + self.sticky_nodes.push(node_id); + } + } + } + + /// Find the nearest scroll port ancestor (overflow != visible on either axis). + /// Returns None if the viewport is the scroll port. + /// Per CSS Overflow Level 3: overflow hidden/scroll/auto all establish scroll ports. + fn find_scroll_port_ancestor(&self, node_id: usize) -> Option { + use style::values::computed::Overflow; + let mut current = self.nodes[node_id].parent?; + loop { + if let Some(style) = self.nodes[current].primary_styles() { + let ox = style.clone_overflow_x(); + let oy = style.clone_overflow_y(); + if !matches!(ox, Overflow::Visible) || !matches!(oy, Overflow::Visible) { + return Some(current); + } + } + match self.nodes[current].parent { + Some(p) => current = p, + None => return None, + } + } + } + + /// Compute a node's position relative to an ancestor by walking the layout_parent chain. + /// Accumulates final_layout.location and subtracts intermediate scroll_offsets. + /// If ancestor_id is None, walks all the way to the root (for viewport case). + fn position_relative_to_ancestor( + &self, + node_id: usize, + ancestor_id: Option, + ) -> crate::Point { + let mut x = 0.0; + let mut y = 0.0; + let mut current = node_id; + loop { + let node = &self.nodes[current]; + x += node.final_layout.location.x as f64; + y += node.final_layout.location.y as f64; + match node.layout_parent.get() { + Some(pid) if Some(pid) == ancestor_id => break, + Some(pid) => { + x -= self.nodes[pid].scroll_offset.x; + y -= self.nodes[pid].scroll_offset.y; + current = pid; + } + None => break, + } + } + crate::Point { x, y } + } + + /// Compute the sticky offset for a single node. + /// Implements CSS Positioned Layout Level 3 §3: + /// - Element stays within its scroll port (viewport or overflow ancestor) + /// - Clamped to its containing block (DOM parent) boundary + fn compute_sticky_offset_for_node(&self, node_id: usize) -> crate::Point { + let node = &self.nodes[node_id]; + let Some(styles) = node.primary_styles() else { + return crate::Point::ZERO; + }; + + let pos_style = styles.get_position(); + let element_width = node.final_layout.size.width as f64; + let element_height = node.final_layout.size.height as f64; + + // Find scroll port ancestor (or viewport) + let scroll_port_id = self.find_scroll_port_ancestor(node_id); + + // Get scroll state and port dimensions + let (scroll, port_width, port_height) = match scroll_port_id { + Some(sp_id) => { + let sp = &self.nodes[sp_id]; + ( + sp.scroll_offset, + sp.final_layout.size.width as f64, + sp.final_layout.size.height as f64, + ) + } + None => { + let w = self.viewport.window_size.0 as f64 / self.viewport.scale() as f64; + let h = self.viewport.window_size.1 as f64 / self.viewport.scale() as f64; + (self.viewport_scroll, w, h) + } + }; + + // Compute element's normal-flow position relative to scroll port + let node_pos = self.position_relative_to_ancestor(node_id, scroll_port_id); + + // Resolve sticky thresholds from Stylo computed styles + let top = resolve_sticky_inset(&pos_style.top, port_height); + let bottom = resolve_sticky_inset(&pos_style.bottom, port_height); + let left = resolve_sticky_inset(&pos_style.left, port_width); + let right = resolve_sticky_inset(&pos_style.right, port_width); + + // Y axis: compute visible position and clamp to thresholds + let visible_y = node_pos.y - scroll.y; + let mut min_y = f64::NEG_INFINITY; + let mut max_y = f64::INFINITY; + if let Some(t) = top { + min_y = t; + } + if let Some(b) = bottom { + max_y = port_height - b - element_height; + } + // Top wins over bottom per CSS spec when they conflict + if min_y > max_y { + max_y = min_y; + } + let clamped_y = visible_y.clamp(min_y, max_y); + let mut offset_y = clamped_y - visible_y; + + // X axis: compute visible position and clamp to thresholds + let visible_x = node_pos.x - scroll.x; + let mut min_x = f64::NEG_INFINITY; + let mut max_x = f64::INFINITY; + if let Some(l) = left { + min_x = l; + } + if let Some(r) = right { + max_x = port_width - r - element_width; + } + // Left wins over right per CSS spec when they conflict + if min_x > max_x { + max_x = min_x; + } + let clamped_x = visible_x.clamp(min_x, max_x); + let mut offset_x = clamped_x - visible_x; + + // Clamp to containing block (DOM parent): element must stay within parent's box + if let Some(parent_id) = node.parent { + let parent = &self.nodes[parent_id]; + let parent_pos = self.position_relative_to_ancestor(parent_id, scroll_port_id); + let parent_height = parent.final_layout.scroll_height() as f64; + let parent_width = parent.final_layout.scroll_width() as f64; + + let cb_min_y = parent_pos.y - node_pos.y; + let cb_max_y = (parent_pos.y + parent_height - element_height - node_pos.y) + .max(cb_min_y); + offset_y = offset_y.clamp(cb_min_y, cb_max_y); + + let cb_min_x = parent_pos.x - node_pos.x; + let cb_max_x = (parent_pos.x + parent_width - element_width - node_pos.x) + .max(cb_min_x); + offset_x = offset_x.clamp(cb_min_x, cb_max_x); + } + + crate::Point { + x: offset_x, + y: offset_y, + } + } + + /// Recompute sticky offsets for all sticky nodes. + /// Called after layout and after every scroll event. + pub fn recompute_sticky_offsets(&mut self) { + let sticky_nodes = std::mem::take(&mut self.sticky_nodes); + for &node_id in &sticky_nodes { + let offset = self.compute_sticky_offset_for_node(node_id); + self.nodes[node_id].sticky_offset = offset; + } + self.sticky_nodes = sticky_nodes; + } + pub fn resolve_layout(&mut self) { let size = self.stylist.device().au_viewport_size(); @@ -305,3 +589,23 @@ impl BaseDocument { // taffy::print_tree(self, root_node_id) } } + +/// Resolve a sticky inset threshold (top/bottom/left/right) from Stylo computed values to pixels. +/// Uses LengthPercentage::resolve() which handles length, percentage, and calc uniformly. +/// Returns None for `auto` values. +fn resolve_sticky_inset( + val: &style::values::generics::position::GenericInset< + style::values::computed::Percentage, + style::values::computed::LengthPercentage, + >, + reference_size: f64, +) -> Option { + use style::values::computed::Length; + use style::values::generics::position::GenericInset; + match val { + GenericInset::LengthPercentage(lp) => { + Some(lp.resolve(Length::new(reference_size as f32)).px() as f64) + } + _ => None, // Auto or anchor positioning + } +} diff --git a/packages/blitz-dom/src/stylo.rs b/packages/blitz-dom/src/stylo.rs index 971e8391b..c43ebcc41 100644 --- a/packages/blitz-dom/src/stylo.rs +++ b/packages/blitz-dom/src/stylo.rs @@ -8,7 +8,6 @@ use crate::layout::damage::ALL_DAMAGE; use crate::layout::damage::compute_layout_damage; use crate::node::Node; use crate::node::NodeData; -use atomic_refcell::{AtomicRef, AtomicRefMut}; use markup5ever::{LocalName, LocalNameStaticSet, Namespace, NamespaceStaticSet, local_name}; use selectors::bloom::BLOOM_HASH_MASK; use selectors::{ @@ -283,7 +282,7 @@ impl<'a> TNode for BlitzNode<'a> { } impl AttributeProvider for BlitzNode<'_> { - fn get_attr(&self, attr: &style::LocalName) -> Option { + fn get_attr(&self, attr: &style::LocalName, _namespace: &style::Namespace) -> Option { self.attr(attr.0.clone()).map(|s| s.to_string()) } } @@ -697,41 +696,29 @@ impl<'a> TElement for BlitzNode<'a> { unimplemented!() } - unsafe fn ensure_data(&self) -> AtomicRefMut<'_, style::data::ElementData> { - let mut stylo_data = self.stylo_element_data.borrow_mut(); - if stylo_data.is_none() { - *stylo_data = Some(style::data::ElementData { - damage: ALL_DAMAGE, - ..Default::default() - }); + unsafe fn ensure_data(&self) -> style::data::ElementDataMut<'_> { + if !self.stylo_element_data.has_data() { + let data = style::data::ElementDataWrapper::default(); + data.borrow_mut().damage = ALL_DAMAGE; + self.stylo_element_data.set(data); } - AtomicRefMut::map(stylo_data, |sd| sd.as_mut().unwrap()) + self.stylo_element_data.borrow_mut().unwrap() } unsafe fn clear_data(&self) { - *self.stylo_element_data.borrow_mut() = None; + self.stylo_element_data.clear(); } fn has_data(&self) -> bool { - self.stylo_element_data.borrow().is_some() + self.stylo_element_data.has_data() } - fn borrow_data(&self) -> Option> { - let stylo_data = self.stylo_element_data.borrow(); - if stylo_data.is_some() { - Some(AtomicRef::map(stylo_data, |sd| sd.as_ref().unwrap())) - } else { - None - } + fn borrow_data(&self) -> Option> { + self.stylo_element_data.borrow() } - fn mutate_data(&self) -> Option> { - let stylo_data = self.stylo_element_data.borrow_mut(); - if stylo_data.is_some() { - Some(AtomicRefMut::map(stylo_data, |sd| sd.as_mut().unwrap())) - } else { - None - } + fn mutate_data(&self) -> Option> { + self.stylo_element_data.borrow_mut() } fn skip_item_display_fixup(&self) -> bool { diff --git a/packages/blitz-dom/src/stylo_to_parley.rs b/packages/blitz-dom/src/stylo_to_parley.rs index d884c5e82..6594eabf7 100644 --- a/packages/blitz-dom/src/stylo_to_parley.rs +++ b/packages/blitz-dom/src/stylo_to_parley.rs @@ -104,6 +104,67 @@ pub(crate) fn white_space_collapse(input: stylo::WhiteSpaceCollapse) -> parley:: } } +/// Convert stylo's `alignment-baseline` longhand to parley's `AlignmentBaseline`. +pub(crate) fn alignment_baseline(style: &stylo::ComputedValues) -> parley::AlignmentBaseline { + use style::values::specified::box_::AlignmentBaseline; + match style.clone_alignment_baseline() { + AlignmentBaseline::Baseline => parley::AlignmentBaseline::Baseline, + AlignmentBaseline::TextBottom => parley::AlignmentBaseline::TextBottom, + AlignmentBaseline::Middle => parley::AlignmentBaseline::Middle, + AlignmentBaseline::TextTop => parley::AlignmentBaseline::TextTop, + // These variants exist in the enum but are css(skip) — they can't come from CSS parsing. + // Map them to the closest parley equivalent. + AlignmentBaseline::Alphabetic | AlignmentBaseline::Ideographic => { + parley::AlignmentBaseline::Baseline + } + AlignmentBaseline::Central => parley::AlignmentBaseline::Middle, + AlignmentBaseline::Mathematical | AlignmentBaseline::Hanging => { + parley::AlignmentBaseline::TextTop + } + } +} + +/// Convert stylo's `baseline-shift` longhand to parley's `BaselineShift`. +pub(crate) fn baseline_shift(style: &stylo::ComputedValues) -> parley::BaselineShift { + use style::values::generics::box_::{BaselineShiftKeyword, GenericBaselineShift}; + match style.clone_baseline_shift() { + GenericBaselineShift::Keyword(kw) => match kw { + BaselineShiftKeyword::Sub => parley::BaselineShift::Sub, + BaselineShiftKeyword::Super => parley::BaselineShift::Super, + BaselineShiftKeyword::Top => parley::BaselineShift::Top, + BaselineShiftKeyword::Bottom => parley::BaselineShift::Bottom, + // Center is not a CSS baseline-shift value; middle is handled via alignment-baseline + BaselineShiftKeyword::Center => parley::BaselineShift::None, + }, + GenericBaselineShift::Length(lp) => { + let font_styles = style.get_font(); + let font_size = font_styles.font_size.used_size.0.px(); + // CSS spec: vertical-align percentages resolve against computed line-height + let line_height = match font_styles.line_height { + stylo::LineHeight::Normal => font_size * 1.2, + stylo::LineHeight::Number(n) => font_size * n.0, + stylo::LineHeight::Length(v) => v.0.px(), + }; + let px = lp.resolve(Length::new(line_height)).px(); + if px == 0.0 { + parley::BaselineShift::None + } else { + parley::BaselineShift::Length(px) + } + } + } +} + +/// Convert stylo's `baseline-source` longhand to parley's `BaselineSource`. +pub(crate) fn baseline_source(style: &stylo::ComputedValues) -> parley::BaselineSource { + use style::values::specified::box_::BaselineSource; + match style.clone_baseline_source() { + BaselineSource::Auto => parley::BaselineSource::Auto, + BaselineSource::First => parley::BaselineSource::First, + BaselineSource::Last => parley::BaselineSource::Last, + } +} + pub(crate) fn style( span_id: usize, style: &stylo::ComputedValues, @@ -114,7 +175,7 @@ pub(crate) fn style( // Convert font size and line height let font_size = font_styles.font_size.used_size.0.px(); let line_height = match font_styles.line_height { - stylo::LineHeight::Normal => parley::LineHeight::FontSizeRelative(1.2), + stylo::LineHeight::Normal => parley::LineHeight::MetricsRelative(1.0), stylo::LineHeight::Number(num) => parley::LineHeight::FontSizeRelative(num.0), stylo::LineHeight::Length(value) => parley::LineHeight::Absolute(value.0.px()), }; @@ -219,5 +280,8 @@ pub(crate) fn style( strikethrough_offset: Default::default(), strikethrough_size: Default::default(), strikethrough_brush: Default::default(), + alignment_baseline: self::alignment_baseline(style), + baseline_shift: self::baseline_shift(style), + baseline_source: self::baseline_source(style), } } diff --git a/packages/blitz-paint/src/render.rs b/packages/blitz-paint/src/render.rs index 8dbac8a73..35b217a49 100644 --- a/packages/blitz-paint/src/render.rs +++ b/packages/blitz-paint/src/render.rs @@ -27,6 +27,7 @@ use style::{ dom::TElement, properties::{ ComputedValues, generated::longhands::visibility::computed_value::T as StyloVisibility, + generated::longhands::position::computed_value::T as CssPosition, style_structs::Font, }, values::{ @@ -238,7 +239,26 @@ impl<'dom> BlitzDomPainter<'dom> { || !matches!(overflow_y, Overflow::Visible); // Apply padding/border offset to inline root - let (layout, box_position) = self.node_position(node_id, location); + let (layout, mut box_position) = self.node_position(node_id, location); + + // Fixed-position elements should not scroll with the viewport. + // When the layout parent is the root element (no transform/filter ancestor captured it), + // compensate for the viewport scroll that was applied at the root. + if node.css_position == CssPosition::Fixed { + let root_id = self.dom.as_ref().root_element().id; + if node.layout_parent.get() == Some(root_id) { + let viewport_scroll = self.dom.as_ref().viewport_scroll(); + box_position.x += viewport_scroll.x; + box_position.y += viewport_scroll.y; + } + } + + // Sticky-position elements: apply pre-computed offset from resolve pass. + // The offset is recomputed on every scroll event in recompute_sticky_offsets(). + if node.css_position == CssPosition::Sticky { + box_position.x += node.sticky_offset.x; + box_position.y += node.sticky_offset.y; + } let taffy::Layout { size, border, @@ -358,11 +378,9 @@ impl<'dom> BlitzDomPainter<'dom> { layout: Layout, box_position: Point, ) -> ElementCx<'w> { - let style = node - .stylo_element_data + let style = node.stylo_element_data .borrow() - .as_ref() - .map(|element_data| element_data.styles.primary().clone()) + .map(|data| data.styles.primary().clone()) .unwrap_or( ComputedValues::initial_values_with_font_override(Font::initial_values()).to_arc(), ); diff --git a/packages/stylo_taffy/src/convert.rs b/packages/stylo_taffy/src/convert.rs index b5f9df5a4..2f32a5482 100644 --- a/packages/stylo_taffy/src/convert.rs +++ b/packages/stylo_taffy/src/convert.rs @@ -579,6 +579,7 @@ pub fn max_track( /// Eagerly convert an entire [`stylo::ComputedValues`] into a [`taffy::Style`] pub fn to_taffy_style(style: &stylo::ComputedValues) -> taffy::Style { let display = style.clone_display(); + let css_position = style.clone_position(); let pos = style.get_position(); let margin = style.get_margin(); let padding = style.get_padding(); @@ -590,7 +591,7 @@ pub fn to_taffy_style(style: &stylo::ComputedValues) -> taffy::Style { box_sizing: self::box_sizing(style.clone_box_sizing()), item_is_table: display.inside() == stylo::DisplayInside::Table, item_is_replaced: false, - position: self::position(style.clone_position()), + position: self::position(css_position), overflow: taffy::Point { x: self::overflow(style.clone_overflow_x()), y: self::overflow(style.clone_overflow_y()), @@ -616,11 +617,18 @@ pub fn to_taffy_style(style: &stylo::ComputedValues) -> taffy::Style { }, aspect_ratio: self::aspect_ratio(pos.aspect_ratio), - inset: taffy::Rect { - left: self::inset(&pos.left), - right: self::inset(&pos.right), - top: self::inset(&pos.top), - bottom: self::inset(&pos.bottom), + // Sticky insets define sticking thresholds, not layout offsets. + // Suppress them so Taffy doesn't apply them as relative offsets. + // Raw values remain accessible via Stylo computed styles for scroll-time use. + inset: if css_position == stylo::Position::Sticky { + taffy::Rect::AUTO + } else { + taffy::Rect { + left: self::inset(&pos.left), + right: self::inset(&pos.right), + top: self::inset(&pos.top), + bottom: self::inset(&pos.bottom), + } }, margin: taffy::Rect { left: self::margin(&margin.margin_left), diff --git a/position_plan.md b/position_plan.md new file mode 100644 index 000000000..d2cd92da3 --- /dev/null +++ b/position_plan.md @@ -0,0 +1,454 @@ +# CSS Positioning Implementation Plan for Blitz + Taffy + +## Context + +Blitz is a native browser renderer using Taffy for layout and Stylo for CSS. Currently, `position: absolute` children are positioned relative to their **direct DOM parent** in the Taffy layout tree, but CSS specifies they should be positioned relative to their **nearest positioned ancestor** (the "containing block"). Additionally, `position: fixed` is mapped to absolute (no viewport-relative behavior) and `position: sticky` is mapped to relative (no scroll-time clamping). This plan fixes all CSS positioning schemes. + +**Local Taffy**: `~/Development/taffy` — Cargo patch override available but no Taffy changes needed for this work. Taffy already handles absolute positioning correctly; the fix is entirely in how Blitz builds the layout tree. + +**Sticky-ready**: All architectural decisions below are designed so `position: sticky` can be added later without refactoring. Foundations for sticky (the `css_position` field, inset suppression, paint-time offset pattern) are laid in Unit 1. + +## Phase 0: Cargo Setup + +### 0.1 — Patch Taffy to use local copy (optional) + +In `Cargo.toml`, uncomment and fix the taffy patch if Taffy changes become needed: + +```toml +[patch."https://github.com/dioxuslabs/taffy"] +taffy = { path = "/Users/jonathankelley/Development/taffy" } +``` + +No Taffy source changes are required for absolute/fixed positioning. The patch is available for future sticky work or bugfixes. + +## Phase 1: Audit Findings (Summary) + +### Current Pipeline +``` +DOM Mutation → Damage → Style (Stylo) → Layout Tree (collect_layout_children) + → Style Flush (flush_styles_to_layout) → Layout (Taffy) → Paint (blitz-paint) +``` + +### Taffy Capabilities +- **Position enum**: Only `Relative` and `Absolute` (no Fixed/Sticky) +- **Absolute positioning**: Fully implemented in block, flex, grid algorithms + - Filters absolute children from normal flow + - Resolves insets against parent's border-box minus scrollbar + - Handles auto margins, RTL, static position fallback +- **No detached node layout API** — containing block is always the parent node + +### Blitz Current State +- **Style conversion** (`stylo_taffy/convert.rs:222-233`): `fixed→Absolute`, `sticky→Relative`, `static→Relative` +- **Stacking contexts** (`damage.rs:533`): Only hoists `position != static && z_index != 0` +- **Paint order** (`damage.rs:628-657`): neg-z → in-flow → pos-z +- **Inline abs positioning** (`inline.rs:599-622`): Custom handling for inline abs boxes +- **Layout tree** already diverges from DOM (anonymous blocks, display:contents) + +### The Critical Bug: Containing Block +Taffy positions absolute children against their layout-tree parent. Blitz's layout tree mirrors DOM structure. So: +```html +
← should be containing block +
← non-positioned +
← WRONG: positioned against middle div +``` + +## Phase 2: Architecture — Reparent Absolute Children in Layout Tree + +### Why Reparenting (not post-layout fixup) + +- **Post-layout fixup fails** because percentage widths/heights on absolute children resolve against the containing block during layout. Wrong containing block → wrong sizes → can't fix after. +- **Taffy API changes** (custom containing block per child) would be invasive and still need positional fixup. +- **Reparenting works** because Taffy already handles absolute positioning correctly when children are under the right parent. The layout tree already diverges from DOM (anonymous blocks, display:contents), so this is a natural extension. + +### Algorithm: Two-phase layout tree construction + +After the existing `resolve_layout_children_recursive` pass, add a `reparent_out_of_flow_children` post-pass: + +``` +resolve_layout_children() ← existing: builds layout_children from DOM + ↓ +reparent_out_of_flow_children() ← NEW: moves abs/fixed children to correct ancestor + ↓ +flush_styles_to_layout() ← existing: converts styles, builds stacking contexts + ↓ +resolve_layout() ← existing: Taffy compute_root_layout +``` + +### Data Structure Changes + +#### Node (`node.rs`) + +Add field to preserve original CSS position (since `stylo_taffy/convert.rs` loses Fixed→Absolute and Sticky→Relative): + +```rust +/// Original CSS position value (not the Taffy mapping) +pub css_position: style::computed_values::position::T, +``` + +Set during `flush_styles_to_layout_impl` alongside `node.style = stylo_taffy::to_taffy_style(style)`: +```rust +node.css_position = style.clone_position(); +``` + +### Sticky-Safe Style Conversion (`stylo_taffy/convert.rs`) + +**Do this in Unit 1** — sticky elements map to `taffy::Position::Relative`, but their insets define sticking thresholds, NOT layout offsets. If we pass insets through, Taffy applies them as relative offsets (wrong). Suppress now so sticky doesn't silently break: + +```rust +pub fn to_taffy_style(style: &stylo::ComputedValues) -> taffy::Style { + let css_position = style.clone_position(); + // ... + inset: if css_position == stylo::Position::Sticky { + // Sticky insets are sticking thresholds, not layout offsets. + // Raw values remain accessible via Stylo computed styles for scroll-time use. + taffy::Rect::AUTO + } else { + taffy::Rect { + left: self::inset(&pos.left), + right: self::inset(&pos.right), + top: self::inset(&pos.top), + bottom: self::inset(&pos.bottom), + } + }, + // ... +} +``` + +### Core Implementation + +#### File: `resolve.rs` — Add `reparent_out_of_flow_children` + +```rust +fn reparent_out_of_flow_children(&mut self) { + use style::computed_values::position::T as Position; + + // Collect reparenting operations: (child_id, old_parent_id, new_parent_id) + let mut reparent_list: Vec<(usize, usize, usize)> = Vec::new(); + + for (node_id, node) in self.nodes.iter() { + let Some(style) = node.primary_styles() else { continue }; + let position = style.clone_position(); + + let is_abs = position == Position::Absolute; + let is_fixed = position == Position::Fixed; + // NOTE: Sticky is intentionally NOT reparented. + // Sticky elements stay in normal flow under their DOM parent. + // Their visual offset is computed at scroll-time, not layout-time. + if !is_abs && !is_fixed { continue; } + + let Some(current_parent) = *node.layout_parent.borrow() else { continue }; + + let target = if is_fixed { + self.find_fixed_containing_block(node_id) + } else { + self.find_absolute_containing_block(node_id) + }; + + if let Some(target) = target { + if current_parent != target { + reparent_list.push((node_id, current_parent, target)); + } + } + } + + // Apply reparenting + for (child_id, old_parent, new_parent) in reparent_list { + if let Some(ref mut children) = *self.nodes[old_parent].layout_children.borrow_mut() { + children.retain(|&id| id != child_id); + } + if let Some(ref mut children) = *self.nodes[new_parent].layout_children.borrow_mut() { + children.push(child_id); + } + self.nodes[child_id].layout_parent.set(Some(new_parent)); + } +} +``` + +#### Containing Block Resolution + +```rust +fn find_absolute_containing_block(&self, node_id: usize) -> Option { + // Walk DOM parents to find nearest positioned ancestor + let mut current = self.nodes[node_id].parent?; + loop { + if self.is_positioned(&self.nodes[current]) { + return Some(current); + } + match self.nodes[current].parent { + Some(p) => current = p, + None => return Some(current), // root = initial containing block + } + } +} + +fn find_fixed_containing_block(&self, node_id: usize) -> Option { + // Walk DOM parents looking for transform/filter/perspective ancestor + // If none found, return root (viewport) + let mut current = self.nodes[node_id].parent?; + loop { + if self.creates_containing_block_for_fixed(&self.nodes[current]) { + return Some(current); + } + match self.nodes[current].parent { + Some(p) => current = p, + None => return Some(current), // root = viewport + } + } +} + +fn is_positioned(&self, node: &Node) -> bool { + node.primary_styles() + .map(|s| s.clone_position() != Position::Static) + .unwrap_or(false) +} + +fn creates_containing_block_for_fixed(&self, node: &Node) -> bool { + let Some(style) = node.primary_styles() else { return false }; + // CSS spec: transform, perspective, filter, will-change mentioning these + !style.get_box().transform.0.is_empty() + || !style.get_effects().filter.0.is_empty() + // || style.get_box().perspective != Perspective::None + // || will-change mentions transform/filter +} +``` + +### Integration Point in `resolve.rs` + +```rust +pub fn resolve(&mut self, current_time_for_animations: f64) { + // ... existing code ... + self.resolve_layout_children(); + self.reparent_out_of_flow_children(); // ← NEW + self.resolve_deferred_tasks(); + self.flush_styles_to_layout(root_node_id); + self.resolve_layout(); + // ... +} +``` + +### Paint Order — No Major Changes Needed + +The existing stacking context mechanism in `flush_styles_to_layout_impl` handles paint order separately from layout order. When iterating `layout_children` to build `paint_children`, reparented absolute children won't appear in their DOM parent's `layout_children` anymore. But they WILL appear in their containing block ancestor's `layout_children`. + +The current code in `damage.rs:521-541` decides whether each child goes into `paint_children` (in-flow order) or gets hoisted to `stacking_context` (z-indexed). After reparenting, absolute children appear as children of the containing block, and the existing logic will: +1. If `z_index != 0`: hoist to stacking context (correct) +2. If `z_index == 0`: add to `paint_children` with `position_to_order` returning 2 for absolute (paints after in-flow children, correct per CSS spec) + +**Potential issue**: The accumulated position offset for hoisted children (`damage.rs:557-562`) uses `final_layout.location` which is relative to the layout parent. After reparenting, this is relative to the containing block, which is correct for painting since the stacking context root is also the containing block ancestor. + +## Phase 3: position: relative + +### Status: Already Works + +Taffy handles `Position::Relative` with inset offsets. In `block.rs:914`, after laying out relative items, it computes `inset_offset` from the style's inset values and adds it to the location. Blitz converts `position: relative` to `taffy::Position::Relative` and converts inset values. No changes needed. + +**Note**: `position: static` also maps to `Relative` in Taffy. This is safe because Stylo computes inset as `auto` for static elements, so no offset gets applied. + +## Phase 4: position: fixed + +### Layout — Handled by Reparenting + +With the reparenting approach, fixed children get reparented to the root element (or nearest transform ancestor). The root's containing block is the viewport (`available_space` in `resolve_layout`). Taffy positions them against the root's border box, which equals the viewport dimensions. This is correct. + +### Paint — Scroll Compensation + +Fixed elements must not move when the page scrolls. Currently, `render.rs` applies scroll offsets during rendering. Fixed elements need the viewport scroll offset undone. + +#### File: `render.rs` — In `render_element` or `draw_children` + +When painting a fixed-position node, compensate for accumulated scroll: + +```rust +// When rendering a node that is position:fixed: +if node.css_position == Position::Fixed { + // Undo viewport scroll so element stays fixed on screen + pos.x += self.context.viewport_scroll.x as f64; + pos.y += self.context.viewport_scroll.y as f64; +} +``` + +This goes in the render path where child positions are computed, likely in `render_node` or at the start of `render_element`. + +## Phase 5: position: sticky + +### What's Done Now (Foundations) +These are included in Unit 1 to prevent sticky from silently breaking: +- **`css_position` field** on Node — stores `Position::Sticky` (not lost to the `Relative` mapping) +- **Inset suppression** in `convert.rs` — sticky insets are set to `Auto` for Taffy so they don't get applied as relative offsets +- **Not reparented** — reparenting pass explicitly skips sticky (they stay in normal flow) +- **Stacking context** — `is_stacking_context_root` already returns `true` for sticky + +### What's Needed Later (Scroll-Time Behavior) + +#### 1. Scroll Container Resolution +Sticky elements stick relative to their nearest **scroll container** (ancestor with `overflow: auto|scroll|hidden`). Need a helper: +```rust +fn find_scroll_container(&self, node_id: usize) -> Option { + // Walk DOM parents looking for overflow != visible + // Blitz already tracks scroll_offset on nodes, so the infrastructure exists +} +``` + +#### 2. Scroll-Time Offset Computation +Called during paint (not layout — no relayout needed on scroll): +```rust +fn compute_sticky_offset(&self, node_id: usize) -> Point { + // 1. Get sticky thresholds from Stylo computed styles (pos.top, pos.bottom, etc.) + // 2. Get element's normal-flow position from final_layout.location + // 3. Get scroll container's scroll_offset and visible area + // 4. Clamp position to stay within visible area minus thresholds + // 5. Clamp again to stay within containing block bounds (sticky stops at CB edge) + // 6. Return offset = clamped_position - normal_position +} +``` + +#### 3. Paint Integration +Apply sticky offset in `render.rs` as a translation, same pattern as fixed scroll compensation: +```rust +if node.css_position == Position::Sticky { + let offset = self.context.dom.compute_sticky_offset(node_id); + pos.x += offset.x as f64; + pos.y += offset.y as f64; +} +``` + +This pattern mirrors the fixed-positioning paint hook, so the two won't conflict. + +#### 4. Scroll Event Integration +When scroll events fire, sticky elements need repaint (not relayout). Blitz's existing `scroll_by` → repaint flow handles this — no architectural changes needed, just ensuring the paint path calls `compute_sticky_offset`. + +### Why This Can Be Added Later Without Refactoring +- `css_position` field is already populated → sticky detection works everywhere +- Insets already suppressed → no wrong offsets to undo +- Reparenting already skips sticky → no tree structure to change +- Paint system already has a per-position-type offset hook (fixed) → sticky adds another case +- Scroll infrastructure (scroll_offset per node, scroll events) already exists + +## Phase 6: Stacking Contexts + +### Current State (`node.rs:838-868`) + +`is_stacking_context_root` checks: +- opacity != 1.0 +- position: fixed | sticky → always +- position: relative | absolute with z-index set +- position: static with z-index set AND flex/grid item +- TODOs for: mix-blend-mode, transforms, filter, clip-path, mask, isolation, contain + +### Expand `is_stacking_context_root` + +```rust +pub fn is_stacking_context_root(&self, is_flex_or_grid_item: bool) -> bool { + // ... existing checks ... + + // Transform (any value other than none) + if !style.get_box().transform.0.is_empty() { return true; } + + // Filter (any value other than none) + if !style.get_effects().filter.0.is_empty() { return true; } + + // Mix-blend-mode (any value other than normal) + // if style.get_effects().mix_blend_mode != MixBlendMode::Normal { return true; } + + // Isolation: isolate + // if style.get_box().isolation == Isolation::Isolate { return true; } + + // Perspective (any value other than none) + // if style.get_box().perspective != Perspective::None { return true; } + + false +} +``` + +### Unify Hoisting Check in `damage.rs:533` + +Current hoisting condition is `position != Static && z_index != 0`. This should also hoist any child that creates a stacking context for other reasons: + +```rust +// Replace: +if position != Position::Static && z_index != 0 { +// With: +let creates_stacking_context = child.is_stacking_context_root(is_flex_or_grid); +let should_hoist = (position != Position::Static && z_index != 0) || creates_stacking_context; +if should_hoist { +``` + +## Phase 7: Edge Cases + +### Static Position Fallback After Reparenting + +When an absolute element has no insets (all auto), CSS says it should appear at its "static position" — where it would have been in normal flow. Taffy tracks this internally. After reparenting, the static position needs adjustment since it's now relative to a different parent. + +**Mitigation**: Taffy computes static position relative to the layout parent's content box. After reparenting, the element's static position will be relative to the containing block's content box — which happens to be at (0, 0) relative to the containing block's content edge. This is actually incorrect (should be at the DOM parent's position within the containing block), but this is a rare edge case that can be addressed later. + +### Inline Layout (`inline.rs`) Interaction + +After reparenting, absolute inline boxes are no longer in their inline context's layout_children. The inline layout code (`inline.rs:303`) already sets absolute inline boxes to zero size. After reparenting, these boxes won't appear in the inline box list at all, so the zero-size code path won't trigger. Instead, they'll be laid out by Taffy as normal absolute children of the containing block. This is correct behavior — the inline context should not size them. + +**One issue**: The inline box `ibox` entries in Parley's layout will reference node IDs that are no longer in the inline context's layout_children. These orphaned inline boxes should be filtered out. Add a check in `compute_inline_layout_inner` when iterating inline boxes to skip nodes that have been reparented. + +### Hit Testing (`node.rs:878`) + +The `hit()` method recursively descends DOM children. After reparenting, absolute children are no longer in the DOM parent's layout_children but ARE still in the DOM `children`. Hit testing uses `final_layout.location` which is now relative to the containing block, not the DOM parent. This means hit coordinates will be wrong. + +**Fix**: During hit testing, for absolute/fixed children, compute their position relative to the DOM parent by walking the containing block chain and subtracting ancestor positions. + +## Implementation Order (Prioritized) + +### Unit 1: Foundation (includes sticky-safe groundwork) +- Add `css_position` field to `Node` struct (`node.rs`) +- Populate it during `flush_styles_to_layout_impl` (`damage.rs`) +- Suppress insets for `position: sticky` in `to_taffy_style` (`convert.rs`) — prevents wrong relative offsets +- Add `is_positioned()` and `creates_containing_block_for_fixed()` helpers +- **Test**: `cargo build -p blitz-dom`, verify sticky elements don't get inset offsets applied + +### Unit 2: Containing Block Resolution + Reparenting +- Implement `find_absolute_containing_block()` +- Implement `find_fixed_containing_block()` +- Implement `reparent_out_of_flow_children()` +- Wire into `resolve()` pipeline after `resolve_layout_children()` +- **Test**: HTML with `position:relative` grandparent + `position:absolute` grandchild, insets resolve against grandparent + +### Unit 3: Fixed Positioning Paint Fix +- Add viewport scroll compensation in `render.rs` for `css_position == Fixed` +- **Test**: Fixed header stays in place while scrolling + +### Unit 4: Stacking Contexts Expansion +- Expand `is_stacking_context_root` (transform, filter) +- Unify hoisting check in `damage.rs` +- **Test**: Transform creates stacking context, z-index ordering with transforms + +### Unit 5: Inline Layout Cleanup +- Filter reparented nodes from inline box iteration +- **Test**: Inline context with absolute child renders correctly + +### Unit 6: Hit Testing Fix +- Update `hit()` to handle reparented absolute/fixed nodes +- **Test**: Click events hit absolute-positioned elements correctly + +### Unit 7: Sticky Positioning (Future — foundations already in place from Unit 1) +- Implement `find_scroll_container()` helper +- Implement `compute_sticky_offset()` with threshold clamping + containing block bounds +- Add sticky offset application in `render.rs` paint path (same pattern as fixed) +- **Test**: Sticky header sticks during scroll, stops at containing block edge + +## Key Files to Modify + +| File | Changes | +| -------------------------------- | ----------------------------------------------------------- | +| `Cargo.toml` | Uncomment taffy patch, point to local | +| `blitz-dom/src/node/node.rs` | Add `css_position` field, expand `is_stacking_context_root` | +| `blitz-dom/src/resolve.rs` | Add `reparent_out_of_flow_children()` call in pipeline | +| `blitz-dom/src/layout/damage.rs` | Unify stacking context hoisting check | +| `blitz-dom/src/layout/inline.rs` | Skip reparented nodes in inline box iteration | +| `stylo_taffy/src/convert.rs` | Suppress insets for sticky (Unit 1, day-1 fix) | +| `blitz-paint/src/render.rs` | Scroll compensation for fixed elements | + +## Verification + +1. `cargo build -p blitz-dom` — compiles with local taffy +2. `cargo test -p blitz-dom` — existing tests pass +3. Test HTML: absolute child inside nested non-positioned divs with positioned grandparent — child positions relative to grandparent +4. Test HTML: fixed header — stays in place during scroll +5. Test HTML: z-index ordering with transforms — correct stacking +6. Render real websites (e.g. news.ycombinator.com) — no regressions diff --git a/tests/align_content_block_test.html b/tests/align_content_block_test.html new file mode 100644 index 000000000..c6d1d8f55 --- /dev/null +++ b/tests/align_content_block_test.html @@ -0,0 +1,86 @@ + + + + + + + +

align-content in Block Layout Tests

+

+ CSS Box Alignment Module Level 3: align-content on block containers.
+ Distributes extra space along the block axis when height > content height.
+ (Roadmap: taffy#709) +

+ + +Case 1: align-content:start (default — content at top) +
+
Content at top
+
+ + +Case 2: align-content:center (vertically centered) +
+
Centered content
+
+ + +Case 3: align-content:end (content at bottom) +
+
Content at bottom
+
+ + +Case 4: align-content:space-between (3 items) +
+
Top
+
Middle
+
Bottom
+
+ + +Case 5: align-content:space-around (equal space around each) +
+
Item 1
+
Item 2
+
Item 3
+
+ + +Case 6: align-content:space-evenly (equal gaps everywhere) +
+
Item 1
+
Item 2
+
Item 3
+
+ + +Case 7: align-content:center but no extra height (no visible effect) +
+
Content fills naturally — no centering effect when height=auto
+
+ + +Case 8: align-content:center on block with text children only +
+ Plain text centered vertically in block. +
+ + +Case 9: Block align-content vs flex align-items (should look the same) +
+
+
Block: align-content:center
+
+
+
Flex: align-items:center
+
+
+ + + diff --git a/tests/bbc_dots_test.html b/tests/bbc_dots_test.html new file mode 100644 index 000000000..13b4941af --- /dev/null +++ b/tests/bbc_dots_test.html @@ -0,0 +1,170 @@ + + + + + + + +

BBC Nav Dots Debug Test

+ + +Case 1: Text nav item with underline (reference) + + + +Case 2: Dots nav item with underline (the bug) + + + +Case 3: Dots without underline + + + +Case 4: Minimal — dots wrapper in 56px line-height context +
+ Text + + + + + + + + After +
+ + +Case 5: SVG directly in 56px line-height (no wrapper) +
+ Text + + + + + + After +
+ + + diff --git a/tests/bbc_nav.html b/tests/bbc_nav.html new file mode 100644 index 000000000..318f79167 --- /dev/null +++ b/tests/bbc_nav.html @@ -0,0 +1,513 @@ + + + + + + Home - BBC News + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + diff --git a/tests/box_model_test.html b/tests/box_model_test.html new file mode 100644 index 000000000..0a2a54160 --- /dev/null +++ b/tests/box_model_test.html @@ -0,0 +1,106 @@ + + + + + + + +

Box Model Tests

+ + +Case 1: box-sizing — both boxes should be same visual width (200px) +
+
+ content-box: 200 + 40pad + 10border = 250px total +
+
+ border-box: 200px total (content = 150px) +
+
+ + +Case 2: Margin collapse — 20px + 30px = 30px gap (not 50px) +
+
margin-bottom: 20px
+
margin-top: 30px
+
+ + +Case 3: Parent-child margin collapse (no padding/border on parent) +
+
+
+ Child margin-top:30px collapses with parent margin-top:20px → 30px total +
+
+
+ + +Case 4: No collapse — parent has padding (gap = 20 + 30 = 50px) +
+
+
+ Parent padding blocks collapse → margin-top:30px added to parent's 20px +
+
+
+ + +Case 5: Negative margin — second box overlaps first +
+
First box
+
+ margin-top: -20px (overlaps by 20px) +
+
+ + +Case 6: margin:auto centering +
+
+ Centered with margin:auto +
+
+ + +Case 7: Percentage widths (25% + 50% + 25% = full width) +
+
25%
+
50%
+
25%
+
+ + +Case 8: padding: 10% (all sides relative to parent width, including top/bottom) +
+
+ All padding = 10% of 300px = 30px +
+
+ + +Case 9: min-width:150px, max-width:250px on 100% child +
+
+ Should be 150px (min-width wins over 100px parent) +
+
+ + +Case 10: Overflow visible (default) vs hidden +
+
+ This text overflows the small box and should be visible outside it by default. +
+
+ This text overflows but is clipped by overflow:hidden so you won't see the end. +
+
+ + + diff --git a/tests/calc_sizing_test.html b/tests/calc_sizing_test.html new file mode 100644 index 000000000..35cd2e7bc --- /dev/null +++ b/tests/calc_sizing_test.html @@ -0,0 +1,209 @@ + + + + + + + +

calc() Widths, Percentage Padding, min/max Constraints

+

+ Tests calc() resolution, percentage padding (resolved against parent width),
+ and min-width/max-width/min-height/max-height interactions. +

+ + + + + + +Case 1: calc(100% - 80px) in a 400px parent → 320px +
+
+ Should be 320px wide +
+
+ + +Case 2: calc(50% + 40px) in a 400px parent → 240px +
+
+ Should be 240px wide +
+
+ + +Case 3: calc(20px * 5) = 100px +
+
+
+ + +Case 4: calc(300px / 3) = 100px +
+
+
+ + +Case 5: calc(100% - calc(20px + 30px)) in 400px → 350px +
+
+ Should be 350px +
+
+ + +Case 6: height: calc(80px - 20px) = 60px +
+
+ 60px tall +
+
+ + +Case 7: padding: calc(4px + 1%) in 400px parent → ~8px padding +
+
+ Padding combines fixed + percentage +
+
+ + +Case 8: margin-left: calc(50% - 100px) → centered 200px box +
+
+ Centered via calc margin +
+
+ + +Case 9: Three columns calc(33.333% - 6px) with 8px gaps +
+
+
Col 1
+
Col 2
+
Col 3
+
+
+ + + + + + +Case 10: padding:10% in 300px parent → 30px all sides (even top/bottom!) +
+
+ All padding = 30px (10% of 300px parent width) +
+
+ + +Case 11: padding: 8px 5% in 400px parent → 8px top/bottom, 20px left/right +
+
+ Top/bottom=8px, left/right=20px +
+
+ + +Case 12: Percentage padding in flex children (resolves against flex container width) +
+
5% pad
+
5% pad
+
5% pad
+
+ + +Case 13: Percentage padding-top for 16:9 aspect ratio (padding-top:56.25%) +
+
+
+ 16:9 via padding-top trick (320 x 180) +
+
+
+ + + + + + +Case 14: width:50px, min-width:150px → 150px wins +
+
+ min-width wins +
+
+ + +Case 15: width:500px, max-width:200px → 200px wins +
+
+ max-width wins +
+
+ + +Case 16: width:20% of 400px=80px, min-width:150px → 150px +
+
+ 80px → clamped to 150px +
+
+ + +Case 17: width:90% of 400px=360px, max-width:250px → 250px +
+
+ 360px → clamped to 250px +
+
+ + +Case 18: min-width:200px > max-width:100px → min wins (200px per spec) +
+
+ min-width beats max-width +
+
+ + +Case 19: min-height:80px on short content + max-height:40px on tall content +
+
+ Short text, but min-height:80px +
+
+ Tall content clamped to max-height:40px. This extra text should be clipped because the container is only 40px tall. +
+
+ + +Case 20: width:calc(100% - 40px), min:100px, max:300px in 400px parent +
+
+ calc=360px, clamped to max:300px +
+
+Case 20b: Same calc in 120px parent → calc=80px → min:100px wins +
+
+ calc=80px, clamped to min:100px +
+
+ + +Case 21: width:auto, min-width:50%, max-width:75% in 400px parent +
+
+ Auto width, clamped between 200px-300px +
+
+ + + diff --git a/tests/dioxus_footer.html b/tests/dioxus_footer.html new file mode 100644 index 000000000..ab70e44c3 --- /dev/null +++ b/tests/dioxus_footer.html @@ -0,0 +1,2490 @@ + + + + + +Dioxus Footer Test + + + + + + diff --git a/tests/dioxus_topnav.html b/tests/dioxus_topnav.html new file mode 100644 index 000000000..a5141f806 --- /dev/null +++ b/tests/dioxus_topnav.html @@ -0,0 +1,97 @@ + + + + +Dioxus Top Nav - selector bisect (fixed specificity) + + + + +
A: reset = *, no pseudo-elements (control)
+
+
+
+ DIOXUS + Learn + Components + Blog +
+
+ +
+
+
+ +
B: reset includes ::backdrop
+
+
+
+ DIOXUS + Learn + Components + Blog +
+
+ +
+
+
+ +
C: reset includes ::file-selector-button
+
+
+
+ DIOXUS + Learn + Components + Blog +
+
+ +
+
+
+ +
D: reset includes both ::backdrop and ::file-selector-button
+
+
+
+ DIOXUS + Learn + Components + Blog +
+
+ +
+
+
+ +
+ Layout classes use 2-class specificity (.a .inner) so they always beat the reset (.a *).
+ If C or D break but A doesn't → ::file-selector-button poisons the selector list. +
+ + + diff --git a/tests/dioxuslabs.html b/tests/dioxuslabs.html new file mode 100644 index 000000000..93cb15343 --- /dev/null +++ b/tests/dioxuslabs.html @@ -0,0 +1,2500 @@ + + Dioxus | Fullstack crossplatform app framework for Rust + + + + + + + + + + + \ No newline at end of file diff --git a/tests/display_test.html b/tests/display_test.html new file mode 100644 index 000000000..2cdab6681 --- /dev/null +++ b/tests/display_test.html @@ -0,0 +1,105 @@ + + + + + + + +

Display Property Tests

+ + +Case 1: display:block — stacks vertically, full width +
+
Block 1
+
Block 2
+
Block 3
+
+ + +Case 2: display:inline — flows horizontally, ignores width/height +
+ Inline (w/h ignored) + Next inline + And another +
+ + +Case 3: display:inline-block — inline flow but respects width/height +
+ 100x40 + 80x60 + 120x30 +
+ + +Case 4: display:none — middle element invisible, no space +
+
Visible A
+
Hidden B
+
Visible C (directly after A)
+
+ + +Case 5: visibility:hidden — invisible but occupies space +
+
Visible A
+
Hidden B (space reserved)
+
Visible C (gap above from hidden B)
+
+ + +Case 6: display:table layout +
+
+
+
Cell 1
+
Cell 2
+
Cell 3
+
+
+
Cell 4
+
Cell 5
+
Cell 6
+
+
+
+ + +Case 7: display:inline-flex — flex container that flows inline +
+ Text before + + A + B + + text after. +
+ + +Case 8: display:inline-grid — grid container that flows inline +
+ Text before + + 1 + 2 + 3 + 4 + + text after. +
+ + +Case 9: div children in flex (become flex items regardless of display) +
+
Block A
+ Inline B +
Block C
+
+ + + diff --git a/tests/dots_test.html b/tests/dots_test.html new file mode 100644 index 000000000..7ae91bda3 --- /dev/null +++ b/tests/dots_test.html @@ -0,0 +1,72 @@ + + + + + + + +

Dots-only test

+ + + + + + diff --git a/tests/flexbox_test.html b/tests/flexbox_test.html new file mode 100644 index 000000000..aa112c4ca --- /dev/null +++ b/tests/flexbox_test.html @@ -0,0 +1,159 @@ + + + + + + + +

Flexbox Layout Tests

+ + +Case 1: flex-direction:row (default) +
+
A
+
B
+
C
+
+ + +Case 2: flex-direction:column +
+
A
+
B
+
C
+
+ + +Case 3a: justify-content:flex-start (default) +
+
A
+
B
+
C
+
+Case 3b: justify-content:center +
+
A
+
B
+
C
+
+Case 3c: justify-content:space-between +
+
A
+
B
+
C
+
+Case 3d: justify-content:space-around +
+
A
+
B
+
C
+
+Case 3e: justify-content:space-evenly +
+
A
+
B
+
C
+
+ + +Case 4: align-items — stretch vs flex-start vs center vs flex-end vs baseline +
+
+
stretch
+
+
+
start
+
+
+
center
+
+
+
end
+
+
+
Aa
+
Bb
+
+
+ + +Case 5: flex-grow — A:1, B:2, C:1 (B gets double share) +
+
A (1)
+
B (2)
+
C (1)
+
+ + +Case 6: flex-shrink — A shrinks, B doesn't (flex-shrink:0) +
+
A (shrinks from 200px)
+
B (stays at 200px)
+
+ + +Case 7: flex-wrap:wrap — items wrap to next line +
+
A
+
B
+
C (wraps)
+
D (wraps)
+
+ + +Case 8: align-content:center with wrap +
+
A
+
B
+
C
+
D
+
+ + +Case 9: order — visual order differs from DOM (C=1, A=2, B=3) +
+
A (order:2)
+
B (order:3)
+
C (order:1)
+
+ + +Case 10: gap:10px (uniform 10px gaps between items) +
+
A
+
B
+
C
+
+ + +Case 11: Nested flex containers +
+
+
Row 1
+
Row 2
+
+
+
Main content
+
+
Sub A
+
Sub B
+
+
+
+ + +Case 12: flex-direction:row-reverse +
+
A (last visually)
+
B
+
C (first visually)
+
+ + + diff --git a/tests/float_test.html b/tests/float_test.html new file mode 100644 index 000000000..88d20ef1e --- /dev/null +++ b/tests/float_test.html @@ -0,0 +1,82 @@ + + + + + + + +

Float & Clear Tests

+ + +Case 1: float:left — text wraps around right side +
+
+

This text flows around the floated box on the right side. The float is removed from normal flow but adjacent inline content wraps around it. More text to show the wrapping behavior.

+
+ + +Case 2: float:right — text wraps around left side +
+
+

This text flows around the right-floated box on the left side. The float is positioned to the right of the container and text wraps on the left.

+
+ + +Case 3: Multiple float:left — stack horizontally +
+
+
+
+

Text after three left floats.

+
+ + +Case 4: clear:both — forces content below floats +
+
+
+
Cleared: below both floats
+
+ + +Case 5: clear:left — clears left float only +
+
+
+
Cleared left only (may still overlap right float)
+
+ + +Case 6: Parent without clearfix — collapses to 0 height +
+
+
Float
+ +
+
This should overlap the float (parent collapsed)
+
+ + +Case 7: Parent with overflow:hidden — contains float +
+
+
Float
+
+
This is properly below the contained float
+
+ + +Case 8: Float drop — floats wrap when container too narrow +
+
A
+
B
+
C (drops)
+
+ + + diff --git a/tests/grid_test.html b/tests/grid_test.html new file mode 100644 index 000000000..7bad17fba --- /dev/null +++ b/tests/grid_test.html @@ -0,0 +1,113 @@ + + + + + + + +

CSS Grid Layout Tests

+ + +Case 1: 3-column fixed grid (100px each) +
+
1
+
2
+
3
+
4
+
5
+
6
+
+ + +Case 2: grid-template-columns: 1fr 2fr 1fr +
+
1fr
+
2fr
+
1fr
+
+ + +Case 3: 100px + 1fr + 20% columns +
+
100px
+
1fr
+
20%
+
+ + +Case 4: repeat(4, 1fr) — 4 equal columns +
+
1
+
2
+
3
+
4
+
+ + +Case 5: Explicit row heights (40px, 60px, 40px) +
+
R1
+
R1
+
R2 (taller)
+
R2
+
R3
+
R3
+
+ + +Case 6: grid-column span (item 1 spans 2 cols) +
+
Span 2
+
1
+
1
+
1
+
1
+
+ + +Case 7: grid-row span (item 1 spans 2 rows) +
+
Span 2 rows
+
A
+
B
+
C
+
D
+
+ + +Case 8: grid-template-areas (header/sidebar/main/footer) +
+
Header
+
Sidebar
+
Main
+
Footer
+
+ + +Case 9: repeat(auto-fill, minmax(80px, 1fr)) +
+
1
+
2
+
3
+
4
+
5
+
6
+
7
+
+ + +Case 10: justify-items + align-items: center +
+
Centered
+
In
+
Cell
+
+ + + diff --git a/tests/hfull_debug.html b/tests/hfull_debug.html new file mode 100644 index 000000000..875a1cc6b --- /dev/null +++ b/tests/hfull_debug.html @@ -0,0 +1,198 @@ + + + + +h-full overflow debug + + + + +

h-full overflow debug (Tailwind v4 patterns)

+ + +
+

Case 1: padding-top/bottom with explicit px (CONTROL - should work)

+
+
+
Button (should be 30px)
+
+ +
Reference (30px)
+
+
+ + +
+

Case 2: padding-block (CSS logical property) - should match Case 1

+
+
+
Button (logical props)
+
+ +
Reference (30px)
+
+
+ + +
+

Case 3: calc(var(--spacing) * N) - should match Case 1

+
+
+
Button (calc+var)
+
+ +
Reference (30px)
+
+
+ + +
+

Case 4: padding-block + calc(var()) (EXACT Tailwind v4 pattern)

+
+
+
Button (TW4 pattern)
+
+ +
Reference (30px)
+
+
+ +
+ What to check: All 4 cases should look identical. + The red-bordered button should be the same height as the green reference (30px). + If a case overflows, the button will extend below the grey line. +

+ Case 1 = control (basic CSS). Case 2 tests logical properties. + Case 3 tests calc(var()). Case 4 tests both together (what Tailwind v4 uses). +
+ + + diff --git a/tests/inline_baseline_test.html b/tests/inline_baseline_test.html new file mode 100644 index 000000000..60e30589a --- /dev/null +++ b/tests/inline_baseline_test.html @@ -0,0 +1,108 @@ + + + + + + + +

Inline-Block Baseline Tests

+

+ Cross-reference these against Chrome to verify correct baseline alignment. +

+ + +Case 1: Multi-line inline-block (baseline = last line) +
+ Parent text + + First line
Second line
Last line +
+ Aligned here (should match "Last line") +
+CSS2.1 §10.8.1: baseline of inline-block = baseline of its last in-flow line box + + +Case 2: Single-line inline-block (baseline = only line) +
+ Parent text + + Single line + + Aligned here +
+ + +Case 3: overflow:visible vs overflow:hidden +
+ Baseline + + visible (text baseline aligns) + + + hidden (bottom at baseline) + + After +
+overflow != visible → bottom margin edge sits at baseline + + +Case 4: overflow:auto and overflow:scroll +
+ Baseline + + auto + + + scroll + + After (both bottom-aligned) +
+ + +Case 5: Empty inline-block (bottom at baseline) +
+ Text before + + Text after (gold box bottom at baseline) +
+No in-flow line boxes → bottom margin edge at baseline + + +Case 6: Empty inline-blocks of different heights +
+ Text + + + + All bottoms at baseline +
+ + +Case 7: Inline-block with block child +
+ Text before + +

Block paragraph inside inline-block

+
+ Text after +
+Block child baseline (taffy returns Point::NONE for block layout) + + +Case 8: Multi-line inline-block with line-height: 40px +
+ Parent (lh=40px) + + Line 1 (lh=24px)
Line 2
Line 3 +
+ Aligns with Line 3 +
+ + + diff --git a/tests/inline_block_heights_test.html b/tests/inline_block_heights_test.html new file mode 100644 index 000000000..1b51953b5 --- /dev/null +++ b/tests/inline_block_heights_test.html @@ -0,0 +1,151 @@ + + + + + + + +

Inline-Block Heights & Baseline Alignment

+

+ CSS2.1 §10.8.1: baseline of inline-block with in-flow content = last line box baseline.
+ Empty inline-block or overflow != visible: bottom margin edge sits on baseline. +

+ + +Case 1: Three equal-height inline-blocks, all vertical-align:baseline +
+ Text + + + + After +
+ + +Case 2: Different heights (20/40/60px), all vertical-align:baseline — bottoms should align +
+ Text + + + + After +
+ + +Case 3: baseline + middle + top + bottom on same line +
+ Text + + + + + After +
+ + +Case 4: Inline-blocks with text content — baseline = last line of text +
+ Align here + + One line + + + Two
lines +
+ + Three
whole
lines +
+ ← all baselines should differ +
+ + +Case 5: Tiny (8px) inline-block at baseline next to 32px text +
+ Big text + + more +
+ + +Case 6: Tall (80px) inline-block at baseline next to 14px text +
+ Small text + + after +
+ + +Case 7: vertical-align:middle — boxes centered on x-height +
+ Text + + + + After +
+ + +Case 8: vertical-align:top — all tops flush with line box top +
+ Text + + + + After +
+ + +Case 9: vertical-align:bottom — all bottoms flush with line box bottom +
+ Text + + + + After +
+ + +Case 10: Padding + border + margin on inline-blocks (box model affects position) +
+ Text + + + + + After +
+ + +Case 11: overflow:hidden → bottom at baseline; overflow:visible → text baseline +
+ Align + + Line 1
Line 2 +
+ + Line 1
Line 2 +
+ ← visible aligns text, hidden aligns bottom +
+ + +Case 12: Inline span + inline-block + text + inline-block with text — all interleaved +
+ A + + B + + iblock text + + C + + D +
+ + + diff --git a/tests/inline_span_test.html b/tests/inline_span_test.html new file mode 100644 index 000000000..783d50655 --- /dev/null +++ b/tests/inline_span_test.html @@ -0,0 +1,100 @@ + + + + + + + +

Inline Span Background, Margin, Border, Padding Tests

+

+ Tests background painting, margin/border/padding on inline spans,
+ including multi-line wrapping and bidi-aware start/end edges. +

+ + +Case 1: Background on inline span (should highlight just the text) +
+ Normal text highlighted span and back to normal. +
+ + +Case 2: Inline span with padding:8px (adds space around text) +
+ Before padded span after. Padding adds space but doesn't affect line height. +
+ + +Case 3: Inline span with border +
+ Text bordered span continues. +
+ + +Case 4: Inline span with margin:0 16px (horizontal margins only) +
+ Beforemargin spanafter. Note the gaps. +
+ + +Case 5: margin + border + padding + background on inline span +
+ Text full box model text. +
+ + +Case 6: Background on long inline span (wraps across 2+ lines) +
+ Start of paragraph this is a long span that should wrap across multiple lines, and the background should paint on each line separately end of paragraph. +
+ + +Case 7: Border on wrapping span (left border on first line, right on last) +
+ Before this span wraps across lines. The border should appear on the left of the first fragment and the right of the last fragment after. +
+ + +Case 8: Nested inline spans with different backgrounds +
+ Outer red blue green blue red outer. +
+ + +Case 9: RTL text with inline span (start/end edges should flip) +
+ טקסט רגיל + טקסט עם גבול + טקסט אחרי +
+ + +Case 10: LTR container with embedded RTL span +
+ English text עברית more English. +
+ + +Case 11: Superscript span with background + border +
+ Normal textsup continues. +
+ + +Case 12: Empty span with border + padding (should show just the box) +
+ Beforeafter (empty span with border). +
+ + +Case 13: Adjacent spans (backgrounds should touch, not overlap) +
+ RedBlueGreen +
+ + + diff --git a/tests/kitchen_sink_test.html b/tests/kitchen_sink_test.html new file mode 100644 index 000000000..cd7a15f63 --- /dev/null +++ b/tests/kitchen_sink_test.html @@ -0,0 +1,1059 @@ + + + + + + + + + + + + + + + +
+
CSS Layout Kitchen Sink
+
+ Comprehensive stress test: block, inline, flex, grid, position, sizing, text, overflow, + forms, tables, images, shadows, transforms, calc(), pseudo-elements, and custom properties. +
+ + +
+ +
+ + +

1. Block Flow & Box Model

+ + +
+

Margin collapse + box-sizing

+
+
+ content-box (w=200 + pad + border) +
+
+ border-box (w=200 total). Gap = max(20,30) = 30px. +
+
+ +

Auto margins + negative margins + opacity

+
+
Centered block
+
Overlapping -10px, opacity:0.7
+
+ +

calc() widths + percentage padding + min/max

+
+
+ width: calc(100% - 80px) = 320px +
+
+ w:75%=300, pad:5%=20px, max:350 +
+
+ width: calc(50% + 40px) = 240px +
+
+ +

display:none vs visibility:hidden

+
+
Visible A
+
display:none B
+
C (directly after A — B takes no space)
+
visibility:hidden D
+
E (gap above — D is invisible but reserves space)
+
+
+ + +

2. Inline & Inline-Block

+ + +
+

Mixed font sizes + vertical-align

+
+ 12px + 20px + sup + sub + mid + +8px +
+ +

Inline-block with different heights + baselines

+
+ Text + + + + After +
+ +

Multi-line inline-block baseline

+
+ Align + + Line 1
Line 2
Line 3 +
+ ← should align with "Line 3" +
+ +

Inline span decoration (background + border + padding, wrapping)

+
+ Before decorated span that wraps across multiple lines in this narrow container after. +
+ +

Inline SVG icons + text alignment

+
+ Icon: + + + + + Check + Arrow: + + + + Right + Star: + + + + Rating +
+
+ + +

3. Flexbox

+ + +
+

Row + justify-content variants

+
+
+
A
B
+
+
+
A
B
+
+
+
A
B
+
+
+ +

Column + align-items + flex-grow

+
+
+
grow:1
+
grow:2
+
+
+
centered
+
items
+
+
+ +

Flex wrap + gap + order

+
+
3
+
1
+
2
+
4 (wraps)
+
+ +

Truncation in flex child (min-width:0 trick)

+
+
+ This is a very long title that should truncate with an ellipsis inside a flex child with min-width:0 +
+
Fixed
+
+ +

Nested flex (sidebar + main + sub-grid)

+
+
+
Sidebar top
+
Sidebar bot
+
+
+
Main content
+
+
Sub A
+
Sub B
+
Sub C
+
+
+
+
+ + +

4. Grid

+ + +
+

Template areas + fr + fixed + span

+
+
Header
+
Side
+
Main (2fr wide)
+
Footer
+
+ +

auto-fill + minmax

+
+
1
+
2
+
3
+
4
+
5
+
6
+
+ +

Row/column span

+
+
Col span 2
+
Row span 2
+
Normal
+
Normal
+
Normal
+
+
+ + +

5. Positioning

+ + +
+

Relative + Absolute + z-index

+
+
Relative +5,+10
+
Absolute z:2
+
Absolute z:1 (behind)
+
+ +

Absolute stretched (all 4 insets)

+
+
+ Stretched with 8px inset +
+
+ +

Sticky inside scroll container

+
+
Sticky header (scroll me)
+
+

Row 1: Content below sticky header.

+

Row 2: More content to scroll through.

+

Row 3: The header should stick to the top.

+

Row 4: Keep scrolling to verify behavior.

+

Row 5: Almost at the end now.

+

Row 6: Final row of scrollable content.

+
+
+ +

Pseudo-element tooltip (::after + position:absolute + transform)

+
+ Normal text, then a + hover target + with a tooltip positioned above via ::after. +
+
+ + +

6. Sizing

+ + +
+

min-content / max-content / fit-content

+
+
min-content wraps to longest word
+
max-content stays on one line
+
+
+
fit-content(150px)
+
+ +

aspect-ratio

+
+
1:1
+
16:9
+
3:4
+
+ +

calc() in various contexts

+
+
+
calc(33% - 6px)
+
calc(33% - 6px)
+
calc(33% - 6px)
+
+
+
+ + +

7. Text & Inline Formatting

+ + +
+

text-align + white-space

+
+
Left
+
Center
+
Right
+
+
+
+ Ellipsis: this very long text truncates with dots at 200px +
+
+
+
pre: preserves spaces + and newlines + indented
+
+
+
pre-wrap: spaces preserved +but wraps when the line gets too long for the container width.
+
+ +

text-decoration + text-transform + letter/word-spacing

+
+ underline + strike + colored underline + caps + spaced + wide words here +
+ +

text-shadow

+
+ Shadow text + Glow text + Outline text +
+ +

line-height comparison

+
+
LH:1 tight spacing on wrap lines.
+
LH:1.5 normal readable spacing.
+
LH:2.5 spacious vertical gap.
+
+ +

overflow-wrap:break-word on long strings

+
+
+ Supercalifragilisticexpialidocious and https://example.com/very/long/url/that/should/break/nicely +
+
+ +

Pseudo-element decorations (::before / ::after)

+
+ Status indicator (::before green dot) +

+ This quote has a big curly open-quote via ::before +

+ Required field label (::after red asterisk) +
+
+ + +

8. Overflow & Clipping

+ + +
+
+
+

visible

+
+ Overflows visibly outside box. +
+
+
+

hidden

+
+ Clipped at overflow boundary. +
+
+
+

scroll

+
+ Scrollable. Extra text extra text extra text more. +
+
+
+

auto

+
+ Auto scrollbar. Enough text to overflow the box boundary here. +
+
+
+ +

border-radius + overflow:hidden (clip children to rounded corners)

+
+
+
+
Content clipped to rounded container
+
+
+
+ + +

9. Floats & Clearfix

+ + +
+

Float left + right with text wrap

+
+
+
+

Text wraps around both floats. Left coral circle and right blue box are floated. This text fills the remaining space. The clearfix ::after pseudo-element on the parent ensures it wraps the floats.

+
+ +

Media object pattern (extremely common on real sites)

+
+
JK
+
+
Jonathan Kelley
+
2 hours ago
+

This is the media object pattern — avatar + content. Used in comment threads, chat messages, notification lists, and social feeds. The avatar uses flex-shrink:0, content uses flex:1 min-width:0.

+
+
+
+ + +

10. Tables (real elements)

+ + +
+

HTML table with thead/tbody, borders, alignment

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
NameStatusAmount
Alice JohnsonActive$1,234.56
Bob SmithPending$567.89
Carol DavisInactive$0.00
+
+ +

Table with colspan/rowspan

+
+ + + + + + + + + + + + +
Colspan 2Rowspan 2
AB
Full width bottom
+
+
+ + +

11. Forms & Input Elements

+ + +
+

Common form controls

+
+
+ + + + + + + + + + + + + + + + + +
+
+ +

Checkboxes, radios, range

+
+
+ + + + + +
+
+ +

Buttons (various styles)

+
+
+ + + + + + +
+
+ +

Search bar pattern

+
+
+ + + + + + + + +
+
+
+ + +

12. Images & Replaced Elements

+ + +
+

SVG at various sizes + vertical-align

+
+ Small: + + Medium: + + Large: + + after +
+ +

Image-like boxes with object-fit simulation

+
+
+ contain +
+
+ cover +
+
+ circle crop +
+
+ +

SVG icons in a row (icon bar pattern)

+
+ + + + + star / close / add / user +
+
+ + +

13. Shadows & Visual Effects

+ + +
+

box-shadow variants

+
+
shadow-sm
+
shadow-md
+
shadow-lg
+
inset
+
+ +

Multiple box-shadows + colored shadows

+
+
+ Layered shadows +
+
+ Colored glow +
+
+ +

Borders: styles + radius + asymmetric

+
+
solid
+
dashed
+
dotted
+
double
+
+
+
8px radius
+
circle
+
top accent
+
left accent (alert)
+
+ +

Gradients

+
+
+
+
+
+
+ +

opacity + stacking contexts

+
+
opacity:1
+
opacity:0.7
+
opacity:0.4
+
+ +

transform

+
+
rotate(-5deg)
+
scale(1.15)
+
skewX(-5deg)
+
translateY(-6px)
+
+
+ + +

14. Lists

+ + +
+
+
+

Unordered list

+
+
    +
  • First item
  • +
  • Second item +
      +
    • Nested A
    • +
    • Nested B
    • +
    +
  • +
  • Third item
  • +
+
+
+
+

Ordered list

+
+
    +
  1. First step
  2. +
  3. Second step +
      +
    1. Sub-step a
    2. +
    3. Sub-step b
    4. +
    +
  4. +
  5. Third step
  6. +
+
+
+
+

Custom list (::before)

+
+
    +
  • Completed task
  • +
  • Another done
  • +
  • All finished
  • +
+
+
+
+ +

Description list

+
+
+
Term 1
+
Definition of term 1
+
Term 2
+
Definition of term 2
+
+
+
+ + +

15. Details/Summary & Semantic HTML

+ + +
+

details/summary disclosure

+
+
+ Expanded by default (open attribute) +

This content is visible because the details element has the open attribute. Click summary to toggle.

+
+
+ Collapsed by default +

This content is hidden until the user clicks the summary.

+
+
+ +

Semantic elements (should render as blocks)

+
+
<header>
+ +
+
<article>
+
<section>
+
+ +
<footer>
+
+
+ + +

16. CSS Custom Properties (var())

+ + +
+

Using --variables for colors, spacing, shadows, radii

+
+
--color-primary
+
--color-accent
+
--color-success
+
--shadow-md
+
--shadow-lg
+
+
+ + +

17. Combined Stress: Realistic Page

+ + +
+

A realistic content page exercising all layout modes simultaneously.

+ + +
+ Home + / + Products + / + Current Page +
+ + +
+
+
+
+
Card 1
+
Grid auto-fill card with gradient header and shadow.
+
+
+
+
+
+
Card 2
+
Tests border-radius + overflow:hidden clipping.
+
+
+
+
+
+
Card 3
+
Minmax responsive columns.
+
+
+
+
+
+
Card 4
+
Should fill available columns.
+
+
+
+ + +
+
+
+
+
Card 5
+
Flex-wrap fallback for comparison.
+
+
+
+
+
+
Card 6
+
flex: 1 1 140px + max-width.
+
+
+
+
+
+
Card 7
+
Compare with auto-fill above.
+
+
+
+ + +
+ +
+
+
Sidebar
+ +
+
+ + +
+ +
+
JK
+

+ This paragraph wraps around the floated avatar circle. It tests float interaction with inline text, + bold spans, + italic text, + super and + sub text, + decorated inlines, + inline code, and + links. + All on the same line with proper baseline alignment across mixed inline content. +

+
+ + +
+ Info: This is an info alert with left border accent. +
+
+ Success: Operation completed successfully. +
+
+ Error: Something went wrong. Please try again. +
+
+
+ + +
+

Tags / badges

+
+ CSS + HTML + Layout + Flexbox + Grid + Inline +
+
+ + +
+ < + 1 + 2 + 3 + > +
+ + +

Modal overlay (position + z-index + backdrop)

+
+ +
+

This is page content behind the modal overlay.

+

It should be dimmed by the backdrop.

+
+ +
+ +
+
+
Modal Title
+ × +
+
+

Modal body content. Tests absolute centering with transform, z-index stacking, box-shadow, and border-radius.

+
+
+ + +
+
+
+ +
+ +
+ + + + + + + + diff --git a/tests/line_box_expansion_test.html b/tests/line_box_expansion_test.html new file mode 100644 index 000000000..9db18fbcc --- /dev/null +++ b/tests/line_box_expansion_test.html @@ -0,0 +1,91 @@ + + + + + + + +

Line Box Expansion Tests

+

+ CSS2.1 §10.8: line-height is the *minimum* height of line boxes.
+ Shifted inline boxes should expand the line box. Unshifted should not. +

+ + +Case 1: No shift (reference — border should tightly wrap text) +
+ Normal text at line-height:40px +
+ + +Case 2: Inline-block shifted 20px up (container should expand) +
+ Text + + After (coral box should not overflow top) +
+ + +Case 3: Inline-block shifted 20px down (container should expand) +
+ Text + + After (blue box should not overflow bottom) +
+ + +Case 4: Super text (line should expand slightly) +
+ Normal + superscript + text +
+ + +Case 5: Sub text (line should expand slightly) +
+ Normal + subscript + text +
+ + +Case 6: 48px image with vertical-align: middle +
+ Text + + + + After (container should fit the image) +
+ + +Case 7: Multiple shifts — sup + sub + raised box (all should fit) +
+ Up + Normal + Down + +
+ + +Case 8: line-height:0.5 (text overflows intentionally — NO expansion) +
+ Tight line-height (text overflows box, that's correct) +
+ + +Case 9: vertical-align: top + bottom (should not expand beyond text) +
+ Normal text + + +
+ + + diff --git a/tests/line_height_debug.html b/tests/line_height_debug.html new file mode 100644 index 000000000..6dc4a84c6 --- /dev/null +++ b/tests/line_height_debug.html @@ -0,0 +1,78 @@ + + + + + + + + +
Case 1: line-height: normal — 10 lines (drift should stack)
+
+A
B
C
D
E
F
G
H
I
J +
+ + +
Case 2: line-height: 1 — 10 lines (zero leading, pure font metrics)
+
+A
B
C
D
E
F
G
H
I
J +
+ + +
Case 3: line-height: 19px — 10 lines (fixed, near Chrome normal)
+
+A
B
C
D
E
F
G
H
I
J +
+ + +
Case 4: line-height: 20px — 10 lines
+
+A
B
C
D
E
F
G
H
I
J +
+ + +
Case 5: line-height: 1.2 — 10 lines
+
+A
B
C
D
E
F
G
H
I
J +
+ + +
Case 6: font-size: 48px, line-height: normal — 5 lines (3x font = 3x error)
+
+A
B
C
D
E +
+ + +
Case 7: font-size: 10px, line-height: normal — 20 lines
+
+A
B
C
D
E
F
G
H
I
J
K
L
M
N
O
P
Q
R
S
T +
+ + +
Case 8: Georgia, line-height: normal — 10 lines
+
+A
B
C
D
E
F
G
H
I
J +
+ +

+
+
+
+
+
diff --git a/tests/multiline_baseline_test.html b/tests/multiline_baseline_test.html
new file mode 100644
index 000000000..ac587f67a
--- /dev/null
+++ b/tests/multiline_baseline_test.html
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+

Multi-Line Inline-Block Baseline Tests

+

+ CSS2.1 §10.8.1: baseline of inline-block = last in-flow line box.
+ Tests clipping, overflow, and multi-line baseline behavior. +

+ + +Case 1: 3-line inline-block (baseline = "Line 3") +
+ Align here + + Line 1
Line 2
Line 3 +
+ (should match "Line 3") +
+ + +Case 2: 3 lines, height:36px (clips to ~2 lines, baseline = visible last line) +
+ Align here + + Line 1
Line 2
Line 3 +
+ (overflow:hidden → bottom at baseline) +
+ + +Case 3: 3 lines, height:36px, overflow:visible (baseline = last visible line) +
+ Align here + + Line 1
Line 2
Line 3 +
+ (overflow:visible → text baseline aligns) +
+ + +Case 4: Single-line vs 2-line vs 3-line baseline comparison +
+ + One line + + + Line 1
Line 2 +
+ + Line 1
Line 2
Line 3 +
+ All baselines should differ +
+ + +Case 5: Inline-block with nested block child (no inline line boxes) +
+ Align here + +
Block child inside
+
+ (block child → bottom at baseline) +
+ + +Case 6: Text + position:absolute sibling (abs content shouldn't affect baseline) +
+ Align here + + Visible line + Absolute text + + (baseline = "Visible line", not abs text) +
+ + +Case 7: Parent line-height:40px, inline-block wraps naturally +
+ Tall line + + This text wraps to two lines + + (baseline = last wrapped line) +
+ + + diff --git a/tests/nav_test.html b/tests/nav_test.html new file mode 100644 index 000000000..eeeb82651 --- /dev/null +++ b/tests/nav_test.html @@ -0,0 +1,326 @@ + + + + + + + +

Simplified BBC Nav Test

+ + + + + diff --git a/tests/nested_inline_block_test.html b/tests/nested_inline_block_test.html new file mode 100644 index 000000000..bb201c3a7 --- /dev/null +++ b/tests/nested_inline_block_test.html @@ -0,0 +1,93 @@ + + + + + + + +

Nested Inline-Block Line-Height Tests

+

+ Tests inline-blocks nested inside containers with different line-heights.
+ Cross-reference against Chrome to verify correct baseline alignment and containment. +

+ + +Case 1: inline-block (line-height:3em) inside normal context +
+ Normal text + + Tall line-height + + After +
+ + +Case 2: inline-block (line-height:1) inside line-height:3em context +
+ Tall context + + Small line-height + + After +
+ + +Case 3: Three levels of inline-block nesting +
+ Outer text + + Level 1 + + Level 2 + + Level 3 + + + + After +
+ + +Case 4: Tiny box (h:4px) inheriting line-height:56px +
+ Text + + After (coral box should be near middle) +
+ + +Case 5: inline-block with height:20px clipping a 56px line-height +
+ Text + + Clipped content + + After +
+ + +Case 6: inline-block with line-height:0 +
+ Normal + + Zero line-height + + After +
+ + +Case 7: Mixed line-heights side by side +
+ LH:1 + LH:2 + LH:3 + Normal text +
+ + + diff --git a/tests/overflow_baseline_test.html b/tests/overflow_baseline_test.html new file mode 100644 index 000000000..e39f39a54 --- /dev/null +++ b/tests/overflow_baseline_test.html @@ -0,0 +1,119 @@ + + + + + + + +

overflow:hidden Inline-Block Baseline & Container Height

+

+ CSS2.1 §10.8.1: inline-block with overflow != visible → bottom margin edge at baseline.
+ The entire box sits above the baseline, so the container must be tall enough to hold it. +

+ + +Case 1: overflow:visible (text baseline) vs overflow:hidden (bottom at baseline) +
+ Align + + Line 1
Line 2 +
+ + Line 1
Line 2 +
+ After +
+ + +Case 2: Tall (100px) overflow:hidden box — bottom at baseline, box extends far above +
+ Text + + After +
+ + +Case 3: overflow:hidden with single line of text — bottom at baseline, not text baseline +
+ Align + + Visible text + + + Hidden text + + After +
+ + +Case 4: overflow:hidden with padding + border — margin edge (outermost) at baseline +
+ Align + + + After — red has padding+border, blue has 10px bottom margin +
+ + +Case 5: overflow:scroll and overflow:auto — same rule as hidden (bottom at baseline) +
+ Align + + + + + After — all except first should have bottom at baseline +
+ + +Case 6: Same content, visible vs hidden — container heights should differ +
+
+ overflow: visible +
+ X + + A
B
C +
+
+
+
+ overflow: hidden +
+ X + + A
B
C +
+
+
+
+ + +Case 7: Empty overflow:hidden boxes at 20/40/60/80px — all bottoms at baseline +
+ Text + + + + + After — tallest box determines container height +
+ + +Case 8: overflow:hidden next to inline-block with text (text baseline vs bottom) +
+ Align + + Two
lines +
+ + After — blue bottom at same baseline as red's last line text +
+ + + diff --git a/tests/overflow_case6_debug.html b/tests/overflow_case6_debug.html new file mode 100644 index 000000000..59b4962ed --- /dev/null +++ b/tests/overflow_case6_debug.html @@ -0,0 +1,48 @@ + + + + + + + +
+
+
overflow: visible
+
+ X + + A
B
C +
+
+
+
+
overflow: hidden
+
+ X + + A
B
C +
+
+
+
+ +

+
+
+
+
+
diff --git a/tests/overflow_hidden_baseline_debug.html b/tests/overflow_hidden_baseline_debug.html
new file mode 100644
index 000000000..9cab032a1
--- /dev/null
+++ b/tests/overflow_hidden_baseline_debug.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+Align + + Visible text + + + Hidden text + +After +
+ + + diff --git a/tests/overflow_test.html b/tests/overflow_test.html new file mode 100644 index 000000000..6d076d604 --- /dev/null +++ b/tests/overflow_test.html @@ -0,0 +1,104 @@ + + + + + + + +

Overflow & Scrolling Tests

+ + +Case 1: overflow:visible — content spills outside box +
+
+ This text content overflows the small box and should be visible outside it. +
+
Content below (may be overlapped)
+
+ + +Case 2: overflow:hidden — content clipped at boundary +
+
+ This text content overflows but is clipped by overflow:hidden. +
+
+ + +Case 3: overflow:scroll — always shows scrollbars +
+
+ This box has overflow:scroll. It should show scrollbars even if content fits. + Extra content to make it scrollable vertically. More lines below. + And another line. And one more for good measure. +
+
+ + +Case 4a: overflow:auto — no scrollbar (content fits) +
+
+ Short content that fits. +
+
+Case 4b: overflow:auto — scrollbar appears (content overflows) +
+
+ This box has overflow:auto with enough content to overflow. The scrollbar should appear only when needed. Extra text to force scrolling here. +
+
+ + +Case 5: overflow-x:scroll, overflow-y:hidden +
+
+ This text is nowrap and scrollable horizontally only. +
+
+ + +Case 6: Nested overflow — outer hidden, inner scrollable +
+
+ Outer (hidden) +
+ Inner scrollable. This content is tall enough to scroll within the inner box, but the outer box clips everything. + More text. And more. And more lines. +
+
+
+ + +Case 7: overflow:hidden with absolute positioned child +
+
+ Container +
+ Abs child (clipped at top) +
+
+
+ + +Case 8: text-overflow:ellipsis (requires nowrap + overflow:hidden) +
+
+ This is a very long line of text that should show an ellipsis at the end when it overflows. +
+
+ + +Case 9: -webkit-line-clamp:2 (2-line text truncation) +
+
+ This is multi-line text content that should be clamped to only two lines with an ellipsis indicating more content is hidden below the visible area. +
+
+ + + diff --git a/tests/positioning_test.html b/tests/positioning_test.html new file mode 100644 index 000000000..6a4eb183d --- /dev/null +++ b/tests/positioning_test.html @@ -0,0 +1,102 @@ + + + + + + + +

CSS Positioning Tests

+ + +Case 1: position:static — top/left have no effect +
+
+ Static: top/left ignored +
+
After (should be directly below)
+
+ + +Case 2: position:relative — shifted 20px right + 10px down, space preserved +
+
Before
+
+ Relative: shifted but space held +
+
After (gap where relative box was)
+
+ + +Case 3: position:absolute — removed from flow, positioned in container +
+
Normal flow
+
+ Absolute: top:10 right:10 +
+
Ignores absolute box
+
+ + +Case 4: position:fixed — pinned to viewport (top:auto, left:auto falls back) +
+
Normal content under fixed
+ +
+ Fixed: bottom:20 right:20 +
+
+ + +Case 5: Absolute positions relative to nearest positioned ancestor +
+
+
+
+ Anchored to lightblue box +
+
+
+
+ + +Case 6: z-index — red on top of blue (z-index:2 > z-index:1) +
+
z:1
+
z:2 (on top)
+
+ + +Case 7: Absolute with top:10 right:10 bottom:10 left:10 (stretches to fill) +
+
+ Stretched by all four insets +
+
+ + +Case 8: position:sticky — behaves like relative until scroll threshold +
+
Scroll down ↓
+
+ Sticky: top:0 (sticks when scrolled) +
+
Tall content to enable scroll
+
+ + +Case 9: Relative with negative top (shifts upward) +
+
Before
+
+ Shifted 15px up (overlaps "Before") +
+
After (original space preserved)
+
+ + + diff --git a/tests/replaced_baseline_test.html b/tests/replaced_baseline_test.html new file mode 100644 index 000000000..d668f087d --- /dev/null +++ b/tests/replaced_baseline_test.html @@ -0,0 +1,118 @@ + + + + + + + +

Replaced Element Baseline Tests

+

+ Images and SVGs are replaced elements. Their default baseline is the bottom margin edge. +

+ + +Case 1: img default vertical-align (bottom at baseline) +
+ Text before + + + + Text after +
+Default: bottom of image sits at text baseline + + +Case 2: img with vertical-align: middle +
+ Text + + + + Middle-aligned +
+Middle: center of image at baseline + half x-height + + +Case 3: img with vertical-align: text-top +
+ Text + + + + Text-top aligned +
+ + +Case 4: img with vertical-align: text-bottom +
+ Text + + + + Text-bottom aligned +
+ + +Case 5: Different-sized images, all default baseline +
+ Text + + + + + + + + + + All bottoms at baseline +
+ + +Case 6: line-height: 60px with image +
+ Text (lh=60px) + + + + After +
+ + +Case 7: img with vertical-align: sub vs super +
+ Normal + + + + sub + + + + super +
+ + +Case 8: img with vertical-align: -5px and 10px +
+ Baseline + + + + -5px + + + + +10px +
+ + + diff --git a/tests/sizing_test.html b/tests/sizing_test.html new file mode 100644 index 000000000..7f2b1aeb2 --- /dev/null +++ b/tests/sizing_test.html @@ -0,0 +1,111 @@ + + + + + + + +

Intrinsic & Extrinsic Sizing Tests

+

+ Tests min-content, max-content, fit-content(), and auto sizing behavior. +

+ + +Case 1: width:min-content (shrinks to longest word) +
+
+ This text wraps to the longest individual word width. +
+
+ + +Case 2: width:max-content (as wide as content wants) +
+
+ This text stays on one line, expanding the box. +
+
+ + +Case 3: width:fit-content(200px) — short text (less than 200px) +
+
+ Short text +
+
+Case 3b: width:fit-content(200px) — long text (wraps at 200px) +
+
+ This is a much longer text that should wrap when it reaches the 200px clamp. +
+
+ + +Case 4: Flex with min-content + 1fr + max-content children +
+
Min content sidebar
+
Flexible middle
+
Max content end
+
+ + +Case 5: Grid — min-content | 1fr | max-content columns +
+
Min content col
+
Flexible column fills remaining space
+
Max content column
+
+ + +Case 6: height:min-content on a block +
+
+ Height shrinks to content. Multiple lines of text to see the minimum height is just enough to contain everything. +
+
+ + +Case 7: max-content constrained by max-width:150px +
+
+ max-content wants wider but max-width clips it. +
+
+ + +Case 8: width:auto — block fills parent, inline shrinks to content +
+
Block: auto width fills parent
+ Inline: auto shrinks to content +
+ + +Case 9: 50% width in flex container +
+
50% = 200px
+
Fills remaining
+
+ + +Case 10: aspect-ratio:16/9 with width:200px +
+
+ 200 x 112.5 +
+
+ + +Case 11: aspect-ratio:1 with width:80px (square) +
+
+ 80x80 +
+
+ + + diff --git a/tests/strut_test.html b/tests/strut_test.html new file mode 100644 index 000000000..5e7782f3c --- /dev/null +++ b/tests/strut_test.html @@ -0,0 +1,48 @@ + + + + + + + + +
+ Hello text +
+ + +
+ +
+ + +
+ Text + +
+ + +
+ +
+ + +
+ Text + +
+ + + + + + diff --git a/tests/stylo_usage.rs b/tests/stylo_usage.rs deleted file mode 100644 index 88a1f2069..000000000 --- a/tests/stylo_usage.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! Minimal example of using Stylo -//! TODO: clean up and upstream to stylo repo - -// pub use blitz::style_impls::{BlitzNode, RealDom}; -// use dioxus::prelude::*; -// use style::{ -// animation::DocumentAnimationSet, -// context::{QuirksMode, SharedStyleContext}, -// driver, -// global_style_data::GLOBAL_STYLE_DATA, -// media_queries::MediaType, -// media_queries::{Device as StyleDevice, MediaList}, -// selector_parser::SnapshotMap, -// servo_arc::Arc, -// shared_lock::{SharedRwLock, StylesheetGuards}, -// stylesheets::{AllowImportRules, DocumentStyleSheet, Origin, Stylesheet}, -// stylist::Stylist, -// thread_state::ThreadState, -// traversal::DomTraversal, -// traversal_flags::TraversalFlags, -// }; - -// fn main() { -// let css = r#" -// h1 { -// background-color: red; -// } - -// h2 { -// background-color: green; -// } - -// h3 { -// background-color: blue; -// } - -// h4 { -// background-color: yellow; -// } - -// "#; - -// let nodes = rsx! { -// h1 { } -// h2 { } -// h3 { } -// h4 { } -// }; - -// let styled_dom = style_lazy_nodes(css, nodes); - -// // print_styles(&styled_dom); -// } - -// // pub fn style_lazy_nodes(css: &str, markup: LazyNodes) -> RealDom { -// // const QUIRKS_MODE: QuirksMode = QuirksMode::NoQuirks; - -// // // Figured out a single-pass system from the servo repo itself: -// // // -// // // components/layout_thread_2020/lib.rs:795 -// // // handle_reflow -// // // tests/unit/style/custom_properties.rs -// // style::thread_state::enter(ThreadState::LAYOUT); - -// // // make the guards that we use to thread everything together -// // let guard = SharedRwLock::new(); -// // let guards = StylesheetGuards { -// // author: &guard.read(), -// // ua_or_user: &guard.read(), -// // }; - -// // // Make some CSS -// // let stylesheet = Stylesheet::from_str( -// // css, -// // servo_url::ServoUrl::from_url("data:text/css;charset=utf-8;base64,".parse().unwrap()), -// // Origin::UserAgent, -// // Arc::new(guard.wrap(MediaList::empty())), -// // guard.clone(), -// // None, -// // None, -// // QUIRKS_MODE, -// // AllowImportRules::Yes, -// // ); - -// // // Make the real domtree by converting dioxus vnodes -// // let markup = RealDom::from_dioxus(markup); - -// // // Now we need to do what handle_reflow does in servo -// // // Reflows should be fast due to caching in the Stylist object -// // // -// // // -// // // We can force reflows. Happens in the script/document section -// // // The browser keeps track of pending restyles itself when attributes are changed. -// // // When something like val.set_attribute() happens, the pending reflow is inserted into the list. -// // // Once ticks are finished, the ScriptReflow object is created and sent to the layout thread. -// // // The layout thread uses the ScriptReflow object to inform itself on what changes need to happen. -// // // Zooming and touching causes full reflows. -// // // For this demo we want to do complete reflows (have yet to figure it out) -// // // But eventually we'll want to queue up modifications and then build the script-reflow type object. -// // // Unfortunately, this API assumes nodes are backed by pointers which adds some unsafe where we wouldn't want it. -// // // -// // // Reflow allows us to specify a dirty root node and a list of nodes to reflow. -// // // -// // // Notes: -// // // - https://developers.google.com/speed/docs/insights/browser-reflow -// // // - components/script/dom/window.rs:force_reflow -// // // -// // // Create a styling context for use throughout the following passes. -// // // In servo we'd also create a layout context, but since servo isn't updated with the new layout code, we're just using the styling context -// // // In a different world we'd use both -// // // Build the stylist object from our screen requirements -// // // Todo: pull this in from wgpu -// // let mut stylist = Stylist::new( -// // StyleDevice::new( -// // MediaType::screen(), -// // QUIRKS_MODE, -// // euclid::Size2D::new(800., 600.), -// // euclid::Scale::new(1.0), -// // ), -// // QUIRKS_MODE, -// // ); - -// // // We have no snapshots on initial render, but we will need them for future renders -// // let snapshots = SnapshotMap::new(); - -// // // Add the stylesheets to the stylist -// // stylist.append_stylesheet(DocumentStyleSheet(Arc::new(stylesheet)), &guard.read()); - -// // // We don't really need to do this, but it's worth keeping it here anyways -// // stylist.force_stylesheet_origins_dirty(Origin::Author.into()); - -// // // Note that html5ever parses the first node as the document, so we need to unwrap it and get the first child -// // // For the sake of this demo, it's always just a single body node, but eventually we will want to construct something like the -// // // BoxTree struct that servo uses. -// // stylist.flush(&guards, Some(markup.root_element()), Some(&snapshots)); - -// // // Build the style context used by the style traversal -// // let context = SharedStyleContext { -// // traversal_flags: TraversalFlags::empty(), -// // stylist: &stylist, -// // options: GLOBAL_STYLE_DATA.options.clone(), -// // guards, -// // visited_styles_enabled: false, -// // animations: (&DocumentAnimationSet::default()).clone(), -// // current_time_for_animations: 0.0, -// // snapshot_map: &snapshots, -// // registered_speculative_painters: &style_impls::RegisteredPaintersImpl, -// // }; - -// // // components/layout_2020/lib.rs:983 -// // println!("------Pre-traversing the DOM tree -----"); -// // let token = style_traverser::RecalcStyle::pre_traverse(markup.root_element(), &context); - -// // // Style the elements, resolving their data -// // println!("------ Traversing domtree ------",); -// // let traverser = style_traverser::RecalcStyle::new(context); -// // driver::traverse_dom(&traverser, token, None); - -// // markup -// // } diff --git a/tests/table_test.html b/tests/table_test.html new file mode 100644 index 000000000..a1d3d0ee0 --- /dev/null +++ b/tests/table_test.html @@ -0,0 +1,323 @@ + + + + + + + +

Table Layout Tests

+

+ Tests real <table> elements with colspan, rowspan, border-collapse,
+ alignment, nested content, and edge cases. +

+ + +Case 1: Basic 3x3 table, border-collapse:collapse +
+ + + + + + + + + + + + + + + + +
A1A2A3
B1B2B3
C1C2C3
+
+ + +Case 2: border-collapse:separate, border-spacing:6px +
+ + + + + + + + + + + +
A1A2A3
B1B2B3
+
+ + +Case 3: colspan=2 on first row, colspan=3 on last row +
+ + + + + + + + + + + + + +
Spans 2 columnsNormal
ABC
Spans all 3 columns
+
+ + +Case 4: rowspan=2 on first column, rowspan=3 on last column +
+ + + + + + + + + + + + + +
Rows 1-2A2Rows 1-3
B2
C1C2
+
+ + +Case 5: Mixed colspan + rowspan in same table +
+ + + + + + + + + + + + +
Header (colspan 2)Tall cell (rowspan 2)
LeftMid
Footer (colspan 3)
+
+ + +Case 6: Proper thead/tbody/tfoot structure +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameScoreGrade
Alice95A
Bob72C
Carol88B
Average85B
+
+ + +Case 7: vertical-align in table cells (top, middle, bottom) +
+ + + + + + +
+ Top aligned + + Middle aligned + + Bottom aligned +
+
+ + +Case 8: Percentage column widths (20% + 50% + 30%) +
+ + + + + + + + + + + +
20%50%30%
NarrowWide column with more content to see distributionMedium
+
+ + +Case 9: table-layout:fixed — columns stay at declared widths regardless of content +
+ + + + + + +
100px col100px col with much more content that would normally stretch it100px
+
+ + +Case 10: table-layout:auto — columns sized by content +
+ + + + + + + + + + + +
ShortThis cell has much more content and drives the column widerMed
ABC
+
+ + +Case 11: Table nested inside a table cell +
+ + + + + +
Normal cell + + + + + + + + + +
Nested ANested B
Nested CNested D
+
+
+ + +Case 12: Empty cells (should still render borders and maintain size) +
+ + + + + + + + + + + +
Has contentHas content
Center
+
+ + +Case 13: Rich cell content — badges, monospace, mixed alignment +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
ItemStatusPrice
Widget Pro + Active + $1,234.00
Gadget Basic + Pending + $56.78
Doohickey + Discontinued + $0.00
+
+ + +Case 14: Table with caption element +
+ + + + + + + +
Table Caption (above)
ABC
+
+ + +Case 15: display:table on divs — should match real table behavior +
+
+
+
Cell 1
+
Cell 2
+
Cell 3
+
+
+
D
+
E
+
F
+
+
+
+ + + diff --git a/tests/test_sticky.html b/tests/test_sticky.html new file mode 100644 index 000000000..e41a1172e --- /dev/null +++ b/tests/test_sticky.html @@ -0,0 +1,363 @@ + + + + + +Sticky Position Test Cases + + + + +

Sticky Position Test Cases — Compare with Chrome

+ + +
TEST 1: Sticky top-only in flex row (like Dioxus docs sidebar)
+
+
+ Left Sidebar +

Navigation links here

+
    +
  • Link 1
  • +
  • Link 2
  • +
  • Link 3
  • +
  • Link 4
  • +
  • Link 5
  • +
+
+
+

Main Content

+

The right sidebar (yellow) should stick at top:96px when scrolling.

+

It must NOT shift horizontally — it should stay in its flex column.

+

Still scrolling...

+

More content...

+

Even more content...

+
+
+ Right Sidebar (sticky top:96px) +

On this page:

+
    +
  • Section 1
  • +
  • Section 2
  • +
  • Section 3
  • +
+

This should stay in the right column!

+
+
+ + +
TEST 2: Sticky inside overflow-y:auto scroll container
+
+
+
+
+ Sticky header (top:10px) — should stick inside this scrollable box +
+

Scroll this container. The green box should stick at 10px from the top of this box.

+

Keep scrolling...

+

More content in scroll container...

+

End of scroll content.

+
+
+
+ Reference (not scrollable) +

The sticky element in the left box should NOT affect this area.

+
+
+ + +
TEST 3: Sticky inside parent with overflow-x:hidden
+
+
+
+ Left Nav +
    +
  • Page 1
  • +
  • Page 2
  • +
  • Page 3
  • +
+
+
+

Content Area

+

The pink sidebar on the right has sticky top:96px.

+

Its grandparent has overflow-x:hidden.

+

The sidebar should stick vertically but NOT shift horizontally.

+

Scrolling down...

+

More content...

+
+
+ Table of Contents (sticky) +
    +
  • Heading 1
  • +
  • Heading 2
  • +
  • Heading 3
  • +
+
+
+
+ + +
TEST 4: Sticky wrapped in extra div (DOM parent ≠ flex container)
+
+
+ Left Sidebar +

Navigation

+
+
+

Center Content

+

The right sidebar is wrapped in an extra div.

+

It should still stick correctly without horizontal shift.

+

Scrolling...

+

More...

+
+
+
+ Sticky (inside wrapper) +

This has an extra wrapper div between it and the flex container.

+

Should stick at top:96px without horizontal displacement.

+
+
+
+ + +
TEST 5: Containing block constraint (sticky unsticks when parent leaves)
+
+
+
Section 1 sticky (top:40px)
+

This sticky should unstick when Section 1 scrolls out of view.

+
+
+
Section 2 sticky (top:40px)
+

Same behavior for section 2.

+
+
+
Section 3 sticky (top:40px)
+

And section 3.

+
+
+ + +
TEST 6: Sticky with both top and left (2D scroll container)
+
+
+
+ Sticky (top:10px, left:10px) +
+

Scroll both horizontally and vertically.

+

The purple box should stick to the top-left corner of this scroll box.

+

Far content (scroll right and down to see).

+
+
+ +
+ + + diff --git a/tests/text_layout_test.html b/tests/text_layout_test.html new file mode 100644 index 000000000..abb489bd9 --- /dev/null +++ b/tests/text_layout_test.html @@ -0,0 +1,132 @@ + + + + + + + +

Text Layout Tests

+ + +Case 1: text-align — left, center, right, justify +
+
Left aligned text
+
Center aligned text
+
Right aligned text
+
+ Justified text that needs to be long enough to wrap to multiple lines so we can see the justify effect working correctly on the lines. +
+
+ + +Case 2: white-space:nowrap — text overflows, does not wrap +
+
+ This text should not wrap and will overflow the 200px container width. +
+
+ + +Case 3: white-space:pre — preserves whitespace and newlines +
+
Line 1 + Indented line 2 + Double indented + Multiple spaces preserved
+
+ + +Case 4: white-space:pre-wrap — preserves whitespace but wraps +
+
Preserves spaces but wraps when the line gets long enough to overflow the container width. +And newlines too.
+
+ + +Case 5: overflow-wrap:break-word — breaks long words +
+
+ Supercalifragilisticexpialidocious should break mid-word. +
+
+ + +Case 6: text-overflow:ellipsis + nowrap + overflow:hidden +
+
+ This text is too long and should end with an ellipsis character. +
+
+ + +Case 7: line-height — 1.0, 1.5, 2.0 +
+
+ Line height 1.0 — tight spacing. Second line here to show the effect on multi-line text blocks. +
+
+ Line height 1.5 — normal spacing. Second line here to show the effect on multi-line text blocks. +
+
+ Line height 2.0 — wide spacing. Second line here to show the effect on multi-line text blocks. +
+
+ + +Case 8: letter-spacing +
+
Normal letter-spacing
+
letter-spacing: 2px
+
letter-spacing: 5px
+
+ + +Case 9: word-spacing +
+
Normal word spacing here
+
Extra word spacing here (10px)
+
Wide word spacing here (20px)
+
+ + +Case 10: text-indent:30px (first line only) +
+
+ This paragraph has a 30px indent on the first line only. The second line wraps normally without any indent applied to it. +
+
+ + +Case 11: text-decoration — underline, overline, line-through +
+ underline + overline + line-through + both +
+ + +Case 12: text-transform +
+
this should be uppercase
+
THIS SHOULD BE LOWERCASE
+
every word capitalized here
+
+ + +Case 13: Mixed font sizes — baseline alignment +
+ 12px + 16px + 24px + 32px + 12px again +
+ + + diff --git a/tests/valign_100_test.html b/tests/valign_100_test.html new file mode 100644 index 000000000..c3dac7a8e --- /dev/null +++ b/tests/valign_100_test.html @@ -0,0 +1,19 @@ + + + + + + + + +
+ Baseline + 100% + 40px +
+ + + diff --git a/tests/valign_lineheight_test.html b/tests/valign_lineheight_test.html new file mode 100644 index 000000000..c2746fcc3 --- /dev/null +++ b/tests/valign_lineheight_test.html @@ -0,0 +1,85 @@ + + + + + + + +

Vertical-Align % with Varied Line-Heights

+

+ CSS spec: vertical-align percentage resolves against computed line-height, NOT font-size.
+ So 50% with line-height:40px = 20px shift, regardless of font-size. +

+ + +Case 1: 50% with line-height:40px (should shift 20px up) +
+ Base + 50% of 40px = 20px up + After +
+ + +Case 2: 50% with line-height:80px (should shift 40px up) +
+ Base + 50% of 80px = 40px up + After +
+ + +Case 3: 50% with line-height:1 (=16px at 16px font) — shift 8px up +
+ Base + 50% of 16px = 8px up + After +
+ + +Case 4: -25% with line-height:40px (should shift 10px down) +
+ Base + -25% of 40px = 10px down + After +
+ + +Case 5: font:32px, line-height:20px, 50% → shift 10px (NOT 16px) +
+ Big font + 50% resolves to 10px + After +
+ + +Case 6: font:12px, line-height:60px, 50% → shift 30px (NOT 6px) +
+ Small font + 50% resolves to 30px + After +
+ + +Case 7: 50% vs 20px at line-height:40px (should be identical positions) +
+ Base + 50% + 20px + (coral and green should align exactly) +
+ + +Case 8: 100% with line-height:40px (shift = full 40px) +
+ Base + 100% = 40px up + After +
+ + + diff --git a/tests/valign_percent_test.html b/tests/valign_percent_test.html new file mode 100644 index 000000000..790dc1f4f --- /dev/null +++ b/tests/valign_percent_test.html @@ -0,0 +1,91 @@ + + + + + + + +

Vertical-Align Percentage Tests

+

+ CSS spec: vertical-align percentages resolve against the element's computed line-height.
+ All test rows use line-height: 40px unless noted otherwise. +

+ + +Case 1: vertical-align: 50% (50% of 40px = 20px up) +
+ Baseline + 50% shifted + reference +
+ + +Case 2: vertical-align: -25% (25% of 40px = 10px down) +
+ Baseline + -25% shifted + reference +
+ + +Case 3: 50% vs 20px (should be identical positions) +
+ Baseline + 50% + 20px + (should overlap if correct) +
+ + +Case 4: -25% vs -10px (should be identical positions) +
+ Baseline + -25% + -10px + (should overlap if correct) +
+ + +Case 5: vertical-align: 100% (full line-height shift) +
+ Baseline + 100% + 40px + (should match) +
+ + +Case 6: line-height: 60px, vertical-align: 50% (= 30px) +
+ Baseline + 50% + 30px + (should match) +
+ + +Case 7: vertical-align: 50% on inline-block +
+ Baseline + + + (should match) +
+ + +Case 8: vertical-align: 0% (same as baseline) +
+ Baseline + 0% + baseline + (should match) +
+ + + diff --git a/tests/valign_test.html b/tests/valign_test.html new file mode 100644 index 000000000..5c8b57e82 --- /dev/null +++ b/tests/valign_test.html @@ -0,0 +1,159 @@ + + + + + + + +

Vertical Align Test Cases

+

Each row has a colored box + text inside a 56px line-height container (same as BBC nav).

+ +
+ + middle (logo pattern) + vertical-align: middle +
+ +
+ + neg-em (burger pattern) + vertical-align: -0.5em +
+ +
+ + neg-rem (account icon) + vertical-align: -0.6875rem +
+ +
+ + pos-rem (dots pattern) + vertical-align: 0.1875rem +
+ +
+ + baseline (default) + vertical-align: baseline +
+ +
+

Combined (simulated nav bar)

+ + + +