From bdbf07779881aa0d1f90f8ffc33d452f4861cd7d Mon Sep 17 00:00:00 2001 From: Jonathan Kelley Date: Wed, 18 Mar 2026 17:33:55 -0700 Subject: [PATCH 01/14] add vertical align --- Cargo.lock | 223 ++++++++++++++------- Cargo.toml | 10 +- PARLEY_ANALYSIS.md | 160 +++++++++++++++ PARLEY_VERTICAL_ALIGN_NOTE.md | 69 +++++++ packages/blitz-dom/src/debug.rs | 11 +- packages/blitz-dom/src/layout/construct.rs | 25 ++- packages/blitz-dom/src/layout/inline.rs | 144 +++---------- packages/blitz-dom/src/stylo_to_parley.rs | 49 +++++ 8 files changed, 483 insertions(+), 208 deletions(-) create mode 100644 PARLEY_ANALYSIS.md create mode 100644 PARLEY_VERTICAL_ALIGN_NOTE.md diff --git a/Cargo.lock b/Cargo.lock index c311d79c0..d39549f75 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" @@ -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", @@ -6291,6 +6336,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 +6355,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 +6509,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]] @@ -6904,9 +6965,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 +7004,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 +7142,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", ] @@ -7216,7 +7276,7 @@ dependencies = [ "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -7239,9 +7299,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 +7317,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 +7349,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 +7444,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 +7517,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 +8185,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 +9205,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 +9537,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 0.7.15", "zbus_macros", "zbus_names", "zvariant", @@ -9520,7 +9589,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "winnow", + "winnow 0.7.15", "zvariant", ] @@ -9538,18 +9607,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 +9716,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 0.7.15", "zvariant_derive", "zvariant_utils", ] @@ -9675,5 +9744,5 @@ dependencies = [ "quote", "serde", "syn 2.0.117", - "winnow", + "winnow 0.7.15", ] diff --git a/Cargo.toml b/Cargo.toml index b3efc32d9..b202f7b5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -267,6 +267,10 @@ tracing-subscriber = "0.3" # [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" } diff --git a/PARLEY_ANALYSIS.md b/PARLEY_ANALYSIS.md new file mode 100644 index 000000000..93da0ac3f --- /dev/null +++ b/PARLEY_ANALYSIS.md @@ -0,0 +1,160 @@ + --- + Browser-Grade Text Layout: Gap Analysis + + Critical Gaps in Parley (inline text layout) + + Tier 1 — Needed for basic correctness + + ┌────────────────────────────────┬───────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Feature │ CSS Property │ Status │ + ├────────────────────────────────┼───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ Vertical align │ vertical-align │ Only bottom-aligned inline boxes; no baseline, middle, sub, super, top, bottom, text-top, text-bottom │ + ├────────────────────────────────┼───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ Text overflow / ellipsis │ text-overflow, -webkit-line-clamp │ Not implemented │ + ├────────────────────────────────┼───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ Text transform │ text-transform │ Not implemented (uppercase, lowercase, capitalize) │ + ├────────────────────────────────┼───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ Overline decoration │ text-decoration-line: overline │ Not implemented (underline + strikethrough only) │ + ├────────────────────────────────┼───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ Decoration styles │ text-decoration-style │ Only solid; no dashed, dotted, wavy, double │ + ├────────────────────────────────┼───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ text-align-last │ text-align-last │ Not implemented (last line of justified text) │ + ├────────────────────────────────┼───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ Inline box margins/padding │ box model on etc. │ InlineBox is width+height only; no margin, padding, border │ + ├────────────────────────────────┼───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ Lines with only inline boxes │ — │ FIXME in line_break.rs:1065 — not fully supported │ + ├────────────────────────────────┼───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ Mixed-direction content widths │ — │ TODO in data.rs:543 — not handled at all │ + └────────────────────────────────┴───────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────┘ + + Tier 2 — Needed for real-world web content + + ┌───────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────┐ + │ Feature │ CSS Property │ Status │ + ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ + │ Hyphenation │ hyphens, hyphenate-character │ Not implemented │ + ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ + │ Writing modes │ writing-mode (vertical-rl, vertical-lr) │ Not implemented — horizontal only │ + ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ + │ ::first-line / ::first-letter │ pseudo-elements │ No hooks for style changes mid-layout │ + ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ + │ Text shadow │ text-shadow │ Not implemented │ + ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ + │ Hanging punctuation │ hanging-punctuation │ Not implemented │ + ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ + │ text-justify │ text-justify │ Only basic space distribution; no inter-character │ + ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ + │ text-underline-position │ text-underline-position │ Not implemented (under, left, right) │ + ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ + │ text-decoration-skip-ink │ text-decoration-skip-ink │ Not implemented │ + ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ + │ Font variant shorthands │ font-variant-caps, font-variant-numeric, font-variant-position, font-variant-east-asian │ Must use raw OpenType tags via FontFeature │ + ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ + │ Soft hyphen / │ U+00AD, manual break hints │ No explicit API │ + ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ + │ CJK text spacing │ text-autospace, text-spacing-trim │ Not implemented │ + ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ + │ Ruby annotations │ │ Not implemented │ + └───────────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────┘ + + Tier 3 — Nice to have for completeness + + ┌──────────────────────────────────────────────────┬─────────────────────────────┐ + │ Feature │ Status │ + ├──────────────────────────────────────────────────┼─────────────────────────────┤ + │ dominant-baseline, alignment-baseline (SVG-like) │ Not implemented │ + ├──────────────────────────────────────────────────┼─────────────────────────────┤ + │ initial-letter (drop caps) │ Not implemented │ + ├──────────────────────────────────────────────────┼─────────────────────────────┤ + │ text-emphasis marks │ Not implemented │ + ├──────────────────────────────────────────────────┼─────────────────────────────┤ + │ Emoji detection completeness │ TODO in shape/mod.rs:244 │ + ├──────────────────────────────────────────────────┼─────────────────────────────┤ + │ font-synthesis control │ Low-level only via fontique │ + └──────────────────────────────────────────────────┴─────────────────────────────┘ + + Known Bugs / Incomplete Behavior + + - Ligatures don't break with letter-spacing (parley_tests/tests/styles/interactions.rs:73) + - word-spacing doesn't expand content box for justified text (:161) + - font-style doesn't auto-map to slnt axis for variable fonts (font_selection.rs:118) + - Line metrics HACK: copies from previous line when unavailable (line_break.rs:796) + + --- + Critical Gaps in Taffy (box layout engine) + + Taffy currently has no inline formatting context at all. From its CHANGELOG: + ▎ "full flow layout: inline, inline-block and float layout have not been implemented." + + ┌──────────────────────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────┐ + │ Feature │ Status │ + ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ + │ display: inline │ Not supported │ + ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ + │ display: inline-block │ Not supported │ + ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ + │ display: inline-flex / inline-grid │ Not supported │ + ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ + │ Inline formatting context │ No implementation │ + ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ + │ Line box generation │ Not implemented — must live in parley or a bridge layer │ + ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ + │ vertical-align on inline elements │ Not supported │ + ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ + │ text-indent │ Not supported │ + ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ + │ white-space (layout-affecting parts: nowrap preventing line wrap, pre preserving breaks) │ Delegated to measure functions │ + ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ + │ Float interaction with inline content │ Not supported (float layout is block-only) │ + ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ + │ text-align │ Legacy only (for
/ align="") │ + ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ + │ Baseline alignment │ Only in flexbox/grid, not inline flow │ + └──────────────────────────────────────────────────────────────────────────────────────────┴─────────────────────────────────────────────────────────┘ + + --- + Where Each Feature Should Live + + ┌──────────────────────────────────┬──────────────────────────────────────────┬───────────────────────────────────────┬──────────────────────────────────────────────────────┐ + │ Responsibility │ Parley │ Taffy │ Bridge/Blitz │ + ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ + │ Text shaping & glyph positioning │ Yes │ — │ — │ + ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ + │ Line breaking & wrapping │ Yes │ — │ — │ + ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ + │ vertical-align (inline) │ Yes — needs implementing │ — │ — │ + ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ + │ Inline box margin/padding/border │ Partially — needs box model on InlineBox │ — │ Could compute externally │ + ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ + │ text-overflow: ellipsis │ Yes │ — │ — │ + ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ + │ text-transform │ — │ — │ Blitz (before passing text to parley) │ + ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ + │ text-shadow │ — │ — │ Blitz (rendering layer) │ + ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ + │ Inline formatting context │ — │ Yes — or bridge │ Line box generation could be shared │ + ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ + │ display: inline-block sizing │ — │ Yes — needs IFC │ measure function exists but no IFC │ + ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ + │ Float exclusions in text │ — │ Yes — adjust available width per line │ Parley already takes max_advance per break_all_lines │ + ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ + │ ::first-line / ::first-letter │ Yes — needs style-change hooks │ — │ Blitz triggers re-style │ + ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ + │ writing-mode │ Yes — deep architectural change │ Yes — logical vs physical │ Both need work │ + └──────────────────────────────────┴──────────────────────────────────────────┴───────────────────────────────────────┴──────────────────────────────────────────────────────┘ + + --- + Recommended Priority Order + + 1. Vertical-align in parley — you're already on this; it's a prerequisite for almost all real web content + 2. Inline box model in parley — add margin/padding/border to InlineBox so styling works + 3. Inline formatting context in taffy (or bridge) — without this, taffy can't participate in inline layout at all; display: inline-block needs to size itself then hand off to parley + 4. Text overflow / ellipsis in parley — extremely common in real UIs + 5. Text-transform in blitz — easy win, just transform the string before handing to parley + 6. Hyphenation in parley — important for justified text and narrow columns + 7. Decoration styles (wavy/dashed/dotted) — common for spell-check UIs, links + 8. Float exclusions — taffy adjusts max_advance per line and passes to parley + 9. Writing modes — deep work in both parley and taffy, but needed for CJK markets + + The biggest architectural decision is where the inline formatting context lives. Right now there's a gap: taffy handles block/flex/grid and parley handles text shaping + line breaking, but nobody + owns the IFC that bridges inline boxes, text runs, and line box generation. That bridge layer (whether in taffy, parley, or blitz) is the single most important missing piece. diff --git a/PARLEY_VERTICAL_ALIGN_NOTE.md b/PARLEY_VERTICAL_ALIGN_NOTE.md new file mode 100644 index 000000000..7dd890875 --- /dev/null +++ b/PARLEY_VERTICAL_ALIGN_NOTE.md @@ -0,0 +1,69 @@ +# Parley VerticalAlign API — alignment with CSS Inline 3 + +## Current state + +Parley has a single `VerticalAlign` enum: + +```rust +pub enum VerticalAlign { + Baseline, Sub, Super, Top, Bottom, TextTop, TextBottom, Middle, Length(f32), +} +``` + +This mirrors the legacy CSS2 `vertical-align` property, which was a single value. + +## CSS Inline Level 3 decomposition + +Modern CSS (CSS Inline 3) decomposes `vertical-align` into a **shorthand** for three longhands: + +| Longhand | Values | Purpose | +|---|---|---| +| `alignment-baseline` | `baseline`, `text-bottom`, `middle`, `text-top` (+ `alphabetic`, `ideographic`, `central`, `mathematical` in full spec) | Which baseline of the element aligns with which baseline of the parent | +| `baseline-shift` | `sub`, `super`, `top`, `center`, `bottom`, `` | How much to shift from the chosen baseline | +| `baseline-source` | `auto`, `first`, `last` | Which baseline set (first or last) to use for alignment | + +Stylo (the CSS engine used by Blitz/Servo) already parses `vertical-align` into these three longhands. Blitz currently does a lossy best-effort mapping from the three longhands back into parley's single enum (see `stylo_to_parley::vertical_align()`). + +## Recommendation + +Consider splitting parley's `VerticalAlign` into separate types that match the CSS Inline 3 model: + +```rust +pub enum AlignmentBaseline { + Baseline, + TextBottom, + Middle, + TextTop, + // Future: Alphabetic, Ideographic, Central, Mathematical +} + +pub enum BaselineShift { + Sub, + Super, + Top, + Center, + Bottom, + Length(f32), +} + +pub enum BaselineSource { + Auto, + First, + Last, +} +``` + +Then `TextStyle` and `InlineBox` would carry these as separate fields instead of a single `vertical_align`. + +### Benefits +- **1:1 mapping** from CSS engines (Stylo, etc.) — no lossy conversion needed +- **Spec-correct semantics** — `alignment-baseline` and `baseline-shift` are independent axes; combining them into one enum loses the ability to set e.g. `alignment-baseline: text-top` with a non-zero `baseline-shift` simultaneously +- **Forward-compatible** — `baseline-source: last` (for bottom-aligned content in table cells, etc.) can be supported without enum bloat + +### Current workaround in Blitz +In `stylo_to_parley.rs`, the conversion prioritizes `baseline-shift` keywords, then falls through to `alignment-baseline` when the shift is zero. `baseline-source` is ignored entirely. This covers ~95% of real-world usage but is technically incorrect for combined values like `vertical-align: text-top sub`. + +## References +- CSS Inline 3 spec: https://drafts.csswg.org/css-inline-3/#transverse-alignment +- Stylo shorthand impl: `stylo-0.12.0/properties/shorthands.rs` (vertical_align module) +- Blitz conversion: `packages/blitz-dom/src/stylo_to_parley.rs` (`vertical_align` function) 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/layout/construct.rs b/packages/blitz-dom/src/layout/construct.rs index 89f5162c3..dc8c4f3d8 100644 --- a/packages/blitz-dom/src/layout/construct.rs +++ b/packages/blitz-dom/src/layout/construct.rs @@ -3,7 +3,7 @@ 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; @@ -908,13 +908,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) => { @@ -944,12 +945,14 @@ 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, }); } else if *tag_name == local_name!("br") { // node.remove_damage(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX); @@ -1021,12 +1024,14 @@ 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, }); } }; diff --git a/packages/blitz-dom/src/layout/inline.rs b/packages/blitz-dom/src/layout/inline.rs index 169ed7cfd..8578034d7 100644 --- a/packages/blitz-dom/src/layout/inline.rs +++ b/packages/blitz-dom/src/layout/inline.rs @@ -7,8 +7,6 @@ use taffy::{ SizingMode, }; -#[cfg(feature = "floats")] -use parley::YieldData; #[cfg(feature = "floats")] use taffy::{Clear, Float, prelude::TaffyMaxContent}; @@ -443,115 +441,13 @@ impl BaseDocument { } // 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 +471,7 @@ impl BaseDocument { .unwrap_or(parley::layout::Alignment::Start); inline_layout.layout.align( + Some(width), alignment, AlignmentOptions { align_when_overflowing: false, @@ -671,9 +568,32 @@ 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; diff --git a/packages/blitz-dom/src/stylo_to_parley.rs b/packages/blitz-dom/src/stylo_to_parley.rs index d884c5e82..3204f53b3 100644 --- a/packages/blitz-dom/src/stylo_to_parley.rs +++ b/packages/blitz-dom/src/stylo_to_parley.rs @@ -104,6 +104,52 @@ 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, + } +} + +/// 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) => { + // TODO: percentages should resolve against line-height, not font-size + let font_size = style.get_font().font_size.used_size.0.px(); + let px = lp.resolve(Length::new(font_size)).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, @@ -219,5 +265,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), } } From d21baa7d7961461ca6dab589038d98d1965dd7fa Mon Sep 17 00:00:00 2001 From: Jonathan Kelley Date: Thu, 19 Mar 2026 11:39:33 -0700 Subject: [PATCH 02/14] add several test cases, vertical-align percentages resolve against computed line-height, CSS struts --- examples/assets/bbc_dots_test.html | 170 +++++++ examples/assets/bbc_nav.html | 513 ++++++++++++++++++++ examples/assets/dots_test.html | 72 +++ examples/assets/inline_baseline_test.html | 108 +++++ examples/assets/nav_test.html | 326 +++++++++++++ examples/assets/replaced_baseline_test.html | 118 +++++ examples/assets/strut_test.html | 48 ++ examples/assets/valign_100_test.html | 19 + examples/assets/valign_percent_test.html | 91 ++++ examples/assets/valign_test.html | 159 ++++++ packages/blitz-dom/src/layout/construct.rs | 5 +- packages/blitz-dom/src/layout/inline.rs | 150 +++++- packages/blitz-dom/src/stylo_to_parley.rs | 12 +- 13 files changed, 1781 insertions(+), 10 deletions(-) create mode 100644 examples/assets/bbc_dots_test.html create mode 100644 examples/assets/bbc_nav.html create mode 100644 examples/assets/dots_test.html create mode 100644 examples/assets/inline_baseline_test.html create mode 100644 examples/assets/nav_test.html create mode 100644 examples/assets/replaced_baseline_test.html create mode 100644 examples/assets/strut_test.html create mode 100644 examples/assets/valign_100_test.html create mode 100644 examples/assets/valign_percent_test.html create mode 100644 examples/assets/valign_test.html diff --git a/examples/assets/bbc_dots_test.html b/examples/assets/bbc_dots_test.html new file mode 100644 index 000000000..13b4941af --- /dev/null +++ b/examples/assets/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/examples/assets/bbc_nav.html b/examples/assets/bbc_nav.html new file mode 100644 index 000000000..318f79167 --- /dev/null +++ b/examples/assets/bbc_nav.html @@ -0,0 +1,513 @@ + + + + + + Home - BBC News + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/assets/dots_test.html b/examples/assets/dots_test.html new file mode 100644 index 000000000..7ae91bda3 --- /dev/null +++ b/examples/assets/dots_test.html @@ -0,0 +1,72 @@ + + + + + + + +

Dots-only test

+ + + + + + diff --git a/examples/assets/inline_baseline_test.html b/examples/assets/inline_baseline_test.html new file mode 100644 index 000000000..60e30589a --- /dev/null +++ b/examples/assets/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/examples/assets/nav_test.html b/examples/assets/nav_test.html new file mode 100644 index 000000000..eeeb82651 --- /dev/null +++ b/examples/assets/nav_test.html @@ -0,0 +1,326 @@ + + + + + + + +

Simplified BBC Nav Test

+ + + + + diff --git a/examples/assets/replaced_baseline_test.html b/examples/assets/replaced_baseline_test.html new file mode 100644 index 000000000..d668f087d --- /dev/null +++ b/examples/assets/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/examples/assets/strut_test.html b/examples/assets/strut_test.html new file mode 100644 index 000000000..5e7782f3c --- /dev/null +++ b/examples/assets/strut_test.html @@ -0,0 +1,48 @@ + + + + + + + + +
+ Hello text +
+ + +
+ +
+ + +
+ Text + +
+ + +
+ +
+ + +
+ Text + +
+ + + + + + diff --git a/examples/assets/valign_100_test.html b/examples/assets/valign_100_test.html new file mode 100644 index 000000000..c3dac7a8e --- /dev/null +++ b/examples/assets/valign_100_test.html @@ -0,0 +1,19 @@ + + + + + + + + +
+ Baseline + 100% + 40px +
+ + + diff --git a/examples/assets/valign_percent_test.html b/examples/assets/valign_percent_test.html new file mode 100644 index 000000000..790dc1f4f --- /dev/null +++ b/examples/assets/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/examples/assets/valign_test.html b/examples/assets/valign_test.html new file mode 100644 index 000000000..5c8b57e82 --- /dev/null +++ b/examples/assets/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)

+ + + + diff --git a/packages/blitz-dom/src/layout/construct.rs b/packages/blitz-dom/src/layout/construct.rs index dc8c4f3d8..3c1ee1419 100644 --- a/packages/blitz-dom/src/layout/construct.rs +++ b/packages/blitz-dom/src/layout/construct.rs @@ -77,7 +77,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, @@ -916,7 +916,6 @@ pub(crate) fn build_inline_layout_into( 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); @@ -953,6 +952,7 @@ pub(crate) fn build_inline_layout_into( 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); @@ -1032,6 +1032,7 @@ pub(crate) fn build_inline_layout_into( alignment_baseline, baseline_shift, baseline_source, + first_baseline: None, }); } }; diff --git a/packages/blitz-dom/src/layout/inline.rs b/packages/blitz-dom/src/layout/inline.rs index 8578034d7..e1e68ed9c 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, @@ -10,8 +12,10 @@ use taffy::{ #[cfg(feature = "floats")] use taffy::{Clear, Float, prelude::TaffyMaxContent}; +use super::construct::resolve_line_height; use super::resolve_calc_value; use crate::BaseDocument; +use crate::stylo_to_parley; impl BaseDocument { pub(crate) fn compute_inline_layout( @@ -293,6 +297,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; @@ -300,6 +308,34 @@ 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) + }; + } + + // 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, + }; } } @@ -435,6 +471,18 @@ 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)); @@ -576,10 +624,8 @@ impl BaseDocument { 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 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(), @@ -613,6 +659,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 @@ -641,7 +704,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 @@ -649,6 +715,80 @@ 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 and line height in CSS pixels + let font_size_px = font_styles.font_size.used_size.0.px(); + let line_height = match font_styles.line_height { + stylo_to_parley::stylo::LineHeight::Normal => parley::LineHeight::FontSizeRelative(1.2), + stylo_to_parley::stylo::LineHeight::Number(n) => { + parley::LineHeight::FontSizeRelative(n.0) + } + stylo_to_parley::stylo::LineHeight::Length(v) => parley::LineHeight::Absolute(v.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 = resolve_line_height(line_height, font_size_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/stylo_to_parley.rs b/packages/blitz-dom/src/stylo_to_parley.rs index 3204f53b3..224cbf555 100644 --- a/packages/blitz-dom/src/stylo_to_parley.rs +++ b/packages/blitz-dom/src/stylo_to_parley.rs @@ -128,9 +128,15 @@ pub(crate) fn baseline_shift(style: &stylo::ComputedValues) -> parley::BaselineS BaselineShiftKeyword::Center => parley::BaselineShift::None, }, GenericBaselineShift::Length(lp) => { - // TODO: percentages should resolve against line-height, not font-size - let font_size = style.get_font().font_size.used_size.0.px(); - let px = lp.resolve(Length::new(font_size)).px(); + 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 { From f78faa5acfd2f88db8a728d27dacb369037b636f Mon Sep 17 00:00:00 2001 From: Jonathan Kelley Date: Thu, 19 Mar 2026 11:40:21 -0700 Subject: [PATCH 03/14] remove markdown --- PARLEY_ANALYSIS.md | 160 ---------------------------------- PARLEY_VERTICAL_ALIGN_NOTE.md | 69 --------------- 2 files changed, 229 deletions(-) delete mode 100644 PARLEY_ANALYSIS.md delete mode 100644 PARLEY_VERTICAL_ALIGN_NOTE.md diff --git a/PARLEY_ANALYSIS.md b/PARLEY_ANALYSIS.md deleted file mode 100644 index 93da0ac3f..000000000 --- a/PARLEY_ANALYSIS.md +++ /dev/null @@ -1,160 +0,0 @@ - --- - Browser-Grade Text Layout: Gap Analysis - - Critical Gaps in Parley (inline text layout) - - Tier 1 — Needed for basic correctness - - ┌────────────────────────────────┬───────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────┐ - │ Feature │ CSS Property │ Status │ - ├────────────────────────────────┼───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤ - │ Vertical align │ vertical-align │ Only bottom-aligned inline boxes; no baseline, middle, sub, super, top, bottom, text-top, text-bottom │ - ├────────────────────────────────┼───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤ - │ Text overflow / ellipsis │ text-overflow, -webkit-line-clamp │ Not implemented │ - ├────────────────────────────────┼───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤ - │ Text transform │ text-transform │ Not implemented (uppercase, lowercase, capitalize) │ - ├────────────────────────────────┼───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤ - │ Overline decoration │ text-decoration-line: overline │ Not implemented (underline + strikethrough only) │ - ├────────────────────────────────┼───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤ - │ Decoration styles │ text-decoration-style │ Only solid; no dashed, dotted, wavy, double │ - ├────────────────────────────────┼───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤ - │ text-align-last │ text-align-last │ Not implemented (last line of justified text) │ - ├────────────────────────────────┼───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤ - │ Inline box margins/padding │ box model on etc. │ InlineBox is width+height only; no margin, padding, border │ - ├────────────────────────────────┼───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤ - │ Lines with only inline boxes │ — │ FIXME in line_break.rs:1065 — not fully supported │ - ├────────────────────────────────┼───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────┤ - │ Mixed-direction content widths │ — │ TODO in data.rs:543 — not handled at all │ - └────────────────────────────────┴───────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────┘ - - Tier 2 — Needed for real-world web content - - ┌───────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────┐ - │ Feature │ CSS Property │ Status │ - ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ - │ Hyphenation │ hyphens, hyphenate-character │ Not implemented │ - ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ - │ Writing modes │ writing-mode (vertical-rl, vertical-lr) │ Not implemented — horizontal only │ - ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ - │ ::first-line / ::first-letter │ pseudo-elements │ No hooks for style changes mid-layout │ - ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ - │ Text shadow │ text-shadow │ Not implemented │ - ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ - │ Hanging punctuation │ hanging-punctuation │ Not implemented │ - ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ - │ text-justify │ text-justify │ Only basic space distribution; no inter-character │ - ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ - │ text-underline-position │ text-underline-position │ Not implemented (under, left, right) │ - ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ - │ text-decoration-skip-ink │ text-decoration-skip-ink │ Not implemented │ - ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ - │ Font variant shorthands │ font-variant-caps, font-variant-numeric, font-variant-position, font-variant-east-asian │ Must use raw OpenType tags via FontFeature │ - ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ - │ Soft hyphen / │ U+00AD, manual break hints │ No explicit API │ - ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ - │ CJK text spacing │ text-autospace, text-spacing-trim │ Not implemented │ - ├───────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ - │ Ruby annotations │ │ Not implemented │ - └───────────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────┘ - - Tier 3 — Nice to have for completeness - - ┌──────────────────────────────────────────────────┬─────────────────────────────┐ - │ Feature │ Status │ - ├──────────────────────────────────────────────────┼─────────────────────────────┤ - │ dominant-baseline, alignment-baseline (SVG-like) │ Not implemented │ - ├──────────────────────────────────────────────────┼─────────────────────────────┤ - │ initial-letter (drop caps) │ Not implemented │ - ├──────────────────────────────────────────────────┼─────────────────────────────┤ - │ text-emphasis marks │ Not implemented │ - ├──────────────────────────────────────────────────┼─────────────────────────────┤ - │ Emoji detection completeness │ TODO in shape/mod.rs:244 │ - ├──────────────────────────────────────────────────┼─────────────────────────────┤ - │ font-synthesis control │ Low-level only via fontique │ - └──────────────────────────────────────────────────┴─────────────────────────────┘ - - Known Bugs / Incomplete Behavior - - - Ligatures don't break with letter-spacing (parley_tests/tests/styles/interactions.rs:73) - - word-spacing doesn't expand content box for justified text (:161) - - font-style doesn't auto-map to slnt axis for variable fonts (font_selection.rs:118) - - Line metrics HACK: copies from previous line when unavailable (line_break.rs:796) - - --- - Critical Gaps in Taffy (box layout engine) - - Taffy currently has no inline formatting context at all. From its CHANGELOG: - ▎ "full flow layout: inline, inline-block and float layout have not been implemented." - - ┌──────────────────────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────┐ - │ Feature │ Status │ - ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ - │ display: inline │ Not supported │ - ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ - │ display: inline-block │ Not supported │ - ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ - │ display: inline-flex / inline-grid │ Not supported │ - ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ - │ Inline formatting context │ No implementation │ - ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ - │ Line box generation │ Not implemented — must live in parley or a bridge layer │ - ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ - │ vertical-align on inline elements │ Not supported │ - ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ - │ text-indent │ Not supported │ - ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ - │ white-space (layout-affecting parts: nowrap preventing line wrap, pre preserving breaks) │ Delegated to measure functions │ - ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ - │ Float interaction with inline content │ Not supported (float layout is block-only) │ - ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ - │ text-align │ Legacy only (for
/ align="") │ - ├──────────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ - │ Baseline alignment │ Only in flexbox/grid, not inline flow │ - └──────────────────────────────────────────────────────────────────────────────────────────┴─────────────────────────────────────────────────────────┘ - - --- - Where Each Feature Should Live - - ┌──────────────────────────────────┬──────────────────────────────────────────┬───────────────────────────────────────┬──────────────────────────────────────────────────────┐ - │ Responsibility │ Parley │ Taffy │ Bridge/Blitz │ - ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ - │ Text shaping & glyph positioning │ Yes │ — │ — │ - ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ - │ Line breaking & wrapping │ Yes │ — │ — │ - ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ - │ vertical-align (inline) │ Yes — needs implementing │ — │ — │ - ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ - │ Inline box margin/padding/border │ Partially — needs box model on InlineBox │ — │ Could compute externally │ - ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ - │ text-overflow: ellipsis │ Yes │ — │ — │ - ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ - │ text-transform │ — │ — │ Blitz (before passing text to parley) │ - ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ - │ text-shadow │ — │ — │ Blitz (rendering layer) │ - ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ - │ Inline formatting context │ — │ Yes — or bridge │ Line box generation could be shared │ - ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ - │ display: inline-block sizing │ — │ Yes — needs IFC │ measure function exists but no IFC │ - ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ - │ Float exclusions in text │ — │ Yes — adjust available width per line │ Parley already takes max_advance per break_all_lines │ - ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ - │ ::first-line / ::first-letter │ Yes — needs style-change hooks │ — │ Blitz triggers re-style │ - ├──────────────────────────────────┼──────────────────────────────────────────┼───────────────────────────────────────┼──────────────────────────────────────────────────────┤ - │ writing-mode │ Yes — deep architectural change │ Yes — logical vs physical │ Both need work │ - └──────────────────────────────────┴──────────────────────────────────────────┴───────────────────────────────────────┴──────────────────────────────────────────────────────┘ - - --- - Recommended Priority Order - - 1. Vertical-align in parley — you're already on this; it's a prerequisite for almost all real web content - 2. Inline box model in parley — add margin/padding/border to InlineBox so styling works - 3. Inline formatting context in taffy (or bridge) — without this, taffy can't participate in inline layout at all; display: inline-block needs to size itself then hand off to parley - 4. Text overflow / ellipsis in parley — extremely common in real UIs - 5. Text-transform in blitz — easy win, just transform the string before handing to parley - 6. Hyphenation in parley — important for justified text and narrow columns - 7. Decoration styles (wavy/dashed/dotted) — common for spell-check UIs, links - 8. Float exclusions — taffy adjusts max_advance per line and passes to parley - 9. Writing modes — deep work in both parley and taffy, but needed for CJK markets - - The biggest architectural decision is where the inline formatting context lives. Right now there's a gap: taffy handles block/flex/grid and parley handles text shaping + line breaking, but nobody - owns the IFC that bridges inline boxes, text runs, and line box generation. That bridge layer (whether in taffy, parley, or blitz) is the single most important missing piece. diff --git a/PARLEY_VERTICAL_ALIGN_NOTE.md b/PARLEY_VERTICAL_ALIGN_NOTE.md deleted file mode 100644 index 7dd890875..000000000 --- a/PARLEY_VERTICAL_ALIGN_NOTE.md +++ /dev/null @@ -1,69 +0,0 @@ -# Parley VerticalAlign API — alignment with CSS Inline 3 - -## Current state - -Parley has a single `VerticalAlign` enum: - -```rust -pub enum VerticalAlign { - Baseline, Sub, Super, Top, Bottom, TextTop, TextBottom, Middle, Length(f32), -} -``` - -This mirrors the legacy CSS2 `vertical-align` property, which was a single value. - -## CSS Inline Level 3 decomposition - -Modern CSS (CSS Inline 3) decomposes `vertical-align` into a **shorthand** for three longhands: - -| Longhand | Values | Purpose | -|---|---|---| -| `alignment-baseline` | `baseline`, `text-bottom`, `middle`, `text-top` (+ `alphabetic`, `ideographic`, `central`, `mathematical` in full spec) | Which baseline of the element aligns with which baseline of the parent | -| `baseline-shift` | `sub`, `super`, `top`, `center`, `bottom`, `` | How much to shift from the chosen baseline | -| `baseline-source` | `auto`, `first`, `last` | Which baseline set (first or last) to use for alignment | - -Stylo (the CSS engine used by Blitz/Servo) already parses `vertical-align` into these three longhands. Blitz currently does a lossy best-effort mapping from the three longhands back into parley's single enum (see `stylo_to_parley::vertical_align()`). - -## Recommendation - -Consider splitting parley's `VerticalAlign` into separate types that match the CSS Inline 3 model: - -```rust -pub enum AlignmentBaseline { - Baseline, - TextBottom, - Middle, - TextTop, - // Future: Alphabetic, Ideographic, Central, Mathematical -} - -pub enum BaselineShift { - Sub, - Super, - Top, - Center, - Bottom, - Length(f32), -} - -pub enum BaselineSource { - Auto, - First, - Last, -} -``` - -Then `TextStyle` and `InlineBox` would carry these as separate fields instead of a single `vertical_align`. - -### Benefits -- **1:1 mapping** from CSS engines (Stylo, etc.) — no lossy conversion needed -- **Spec-correct semantics** — `alignment-baseline` and `baseline-shift` are independent axes; combining them into one enum loses the ability to set e.g. `alignment-baseline: text-top` with a non-zero `baseline-shift` simultaneously -- **Forward-compatible** — `baseline-source: last` (for bottom-aligned content in table cells, etc.) can be supported without enum bloat - -### Current workaround in Blitz -In `stylo_to_parley.rs`, the conversion prioritizes `baseline-shift` keywords, then falls through to `alignment-baseline` when the shift is zero. `baseline-source` is ignored entirely. This covers ~95% of real-world usage but is technically incorrect for combined values like `vertical-align: text-top sub`. - -## References -- CSS Inline 3 spec: https://drafts.csswg.org/css-inline-3/#transverse-alignment -- Stylo shorthand impl: `stylo-0.12.0/properties/shorthands.rs` (vertical_align module) -- Blitz conversion: `packages/blitz-dom/src/stylo_to_parley.rs` (`vertical_align` function) From 52caed30c47c01dc7e7f34154bd716f39696b26a Mon Sep 17 00:00:00 2001 From: Jonathan Kelley Date: Thu, 19 Mar 2026 15:36:59 -0700 Subject: [PATCH 04/14] more tests cases --- examples/assets/align_content_block_test.html | 86 +++ examples/assets/box_model_test.html | 106 +++ examples/assets/display_test.html | 105 +++ examples/assets/flexbox_test.html | 159 +++++ examples/assets/float_test.html | 82 +++ examples/assets/grid_test.html | 113 +++ examples/assets/inline_span_test.html | 100 +++ examples/assets/kitchen_sink_test.html | 433 ++++++++++++ examples/assets/line_box_expansion_test.html | 91 +++ examples/assets/multiline_baseline_test.html | 96 +++ examples/assets/nested_inline_block_test.html | 93 +++ examples/assets/overflow_test.html | 104 +++ examples/assets/positioning_test.html | 102 +++ examples/assets/reddit-astral.html | 665 ++++++++++++++++++ examples/assets/sizing_test.html | 111 +++ examples/assets/text_layout_test.html | 132 ++++ examples/assets/valign_lineheight_test.html | 85 +++ packages/blitz-dom/src/layout/inline.rs | 6 +- 18 files changed, 2666 insertions(+), 3 deletions(-) create mode 100644 examples/assets/align_content_block_test.html create mode 100644 examples/assets/box_model_test.html create mode 100644 examples/assets/display_test.html create mode 100644 examples/assets/flexbox_test.html create mode 100644 examples/assets/float_test.html create mode 100644 examples/assets/grid_test.html create mode 100644 examples/assets/inline_span_test.html create mode 100644 examples/assets/kitchen_sink_test.html create mode 100644 examples/assets/line_box_expansion_test.html create mode 100644 examples/assets/multiline_baseline_test.html create mode 100644 examples/assets/nested_inline_block_test.html create mode 100644 examples/assets/overflow_test.html create mode 100644 examples/assets/positioning_test.html create mode 100644 examples/assets/reddit-astral.html create mode 100644 examples/assets/sizing_test.html create mode 100644 examples/assets/text_layout_test.html create mode 100644 examples/assets/valign_lineheight_test.html diff --git a/examples/assets/align_content_block_test.html b/examples/assets/align_content_block_test.html new file mode 100644 index 000000000..c6d1d8f55 --- /dev/null +++ b/examples/assets/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/examples/assets/box_model_test.html b/examples/assets/box_model_test.html new file mode 100644 index 000000000..0a2a54160 --- /dev/null +++ b/examples/assets/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/examples/assets/display_test.html b/examples/assets/display_test.html new file mode 100644 index 000000000..2cdab6681 --- /dev/null +++ b/examples/assets/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/examples/assets/flexbox_test.html b/examples/assets/flexbox_test.html new file mode 100644 index 000000000..aa112c4ca --- /dev/null +++ b/examples/assets/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/examples/assets/float_test.html b/examples/assets/float_test.html new file mode 100644 index 000000000..88d20ef1e --- /dev/null +++ b/examples/assets/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/examples/assets/grid_test.html b/examples/assets/grid_test.html new file mode 100644 index 000000000..7bad17fba --- /dev/null +++ b/examples/assets/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/examples/assets/inline_span_test.html b/examples/assets/inline_span_test.html new file mode 100644 index 000000000..783d50655 --- /dev/null +++ b/examples/assets/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/examples/assets/kitchen_sink_test.html b/examples/assets/kitchen_sink_test.html new file mode 100644 index 000000000..5496550c2 --- /dev/null +++ b/examples/assets/kitchen_sink_test.html @@ -0,0 +1,433 @@ + + + + + + + +

CSS Layout Kitchen Sink

+

+ Comprehensive test combining block, inline, flex, grid, positioning, sizing, text, and overflow. +

+ + +

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

+
+
Centered block
+
Overlapping -10px
+
+ +

Percentage width/padding + min/max

+
+
+ w:75% of 400=300, min:200, max:350 → 300px. pad:5% of 400=20px. +
+
+ w:30% of 400=120 → clamped to min:200px +
+
+
+ + +

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)

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

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)
+
+ +

Nested flex

+
+
+
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 header (scroll to test)

+
+
Sticky header
+
+ Scroll down to test sticky behavior ↓ +
+
+
+ + +

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
+
+
+ + +

7. Text & Inline Formatting

+ + +
+

text-align + white-space

+
+
Left
+
Center
+
Right
+
+
+
+ Ellipsis: this very long text truncates with dots… +
+
+
+
pre: preserves spaces + and newlines
+
+ +

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

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

line-height comparison

+
+
LH:1 — tight lines that demonstrate compact spacing on wrap.
+
LH:1.5 — normal lines that are readable and have normal spacing.
+
LH:2.5 — spacious lines with generous vertical gap.
+
+
+ + +

8. Overflow & Clipping

+ + +
+
+
+

visible

+
+ Overflows visibly outside box boundaries. +
+
+
+

hidden

+
+ Clipped at overflow hidden boundary. +
+
+
+

scroll

+
+ Scrollable content. Extra text extra text extra text. +
+
+
+
+ + +

9. Floats

+ + +
+
+
+
+

Text wraps around both floats. The left coral box and right blue box are floated, and this text fills the remaining space between them. When the text is long enough, it wraps below the shorter float first.

+
+
+
+ + +

10. Display Table

+ + +
+
+
+
+
Header 1
+
Header 2
+
Header 3
+
+
+
Data A
+
Data B with more text
+
C
+
+
+
+
+ + +

11. Borders & Backgrounds

+ + +
+

Border styles + radius

+
+
solid
+
dashed
+
dotted
+
double
+
+
+
8px radius
+
circle
+
asymmetric
+
+ +

Background color + gradient

+
+
Linear gradient
+
Radial gradient
+
+
+ + +

12. Combined Stress Test

+ + +
+

Simulated page header with nav, hero, cards, and footer — exercises all layout modes together.

+ + +
+
Logo
+
+
+ HomeAboutProductsContact +
+
+ + +
+
Kitchen Sink Hero
+
Testing block centering, text align, gradients, and font sizing together.
+
+ + +
+
+
+
+
Card 1
+
Description text that might overflow the card area.
+
+
+
+
+
+
Card 2
+
Another card with different content length.
+
+
+
+
+
+
Card 3
+
Short.
+
+
+
+
+
+
Card 4
+
Grid auto-fill handles the responsive layout here.
+
+
+
+ + +
+
+

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

+
+ + +
+ © 2026 Kitchen Sink + + PrivacyTermsHelp + +
+
+ + + diff --git a/examples/assets/line_box_expansion_test.html b/examples/assets/line_box_expansion_test.html new file mode 100644 index 000000000..9db18fbcc --- /dev/null +++ b/examples/assets/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/examples/assets/multiline_baseline_test.html b/examples/assets/multiline_baseline_test.html new file mode 100644 index 000000000..ac587f67a --- /dev/null +++ b/examples/assets/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/examples/assets/nested_inline_block_test.html b/examples/assets/nested_inline_block_test.html new file mode 100644 index 000000000..bb201c3a7 --- /dev/null +++ b/examples/assets/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/examples/assets/overflow_test.html b/examples/assets/overflow_test.html new file mode 100644 index 000000000..6d076d604 --- /dev/null +++ b/examples/assets/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/examples/assets/positioning_test.html b/examples/assets/positioning_test.html new file mode 100644 index 000000000..6a4eb183d --- /dev/null +++ b/examples/assets/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/examples/assets/reddit-astral.html b/examples/assets/reddit-astral.html new file mode 100644 index 000000000..6407981ed --- /dev/null +++ b/examples/assets/reddit-astral.html @@ -0,0 +1,665 @@ +OpenAI to acquire Astral : Python
this post was submitted on
488 points (95% upvoted)

Python

The Python Discord

+ +

News about the dynamic, interpreted, interactive, object-oriented, extensible programming language Python

+ +

Upcoming Events

+ +

Full Events Calendar

+ +

Please read the rules

+ +

You can find the rules here.

+ +

If you are about to ask a "how do I do this in python" question, please try r/learnpython, the Python discord, or the #python IRC channel on Libera.chat.

+ +

Please don't use URL shorteners. Reddit filters them out, so your post or comment will be lost.

+ +

Posts require flair. Please use the flair selector to choose your topic.

+ +

Posting code to this subreddit:

+ +

Add 4 extra spaces before each line of code

+ +
def fibonacci():
+    a, b = 0, 1
+    while True:
+        yield a
+        a, b = b, a + b
+
+ +

Online Resources

+ + + +

Online exercices

+ + + +

programming challenges

+ + + +

Asking Questions

+ + + +

Try Python in your browser

+ + + +

Docs

+ + + +

Libraries

+ + + +

Related subreddits

+ + + +

Python jobs

+ + + +

Newsletters

+ + + +

Screencasts

+ + +
+
a community for
×
top 200 commentsshow all 248

[–]gingimli 323 points324 points  (9 children)

Anthropic bought Bun and now OpenAI buys Astral. Who knew building a package manager would be so lucrative in 2025-26.

+
+

[–]deadwisdomgreenlet revolution 63 points64 points  (2 children)

Yeah, I wonder if this is the start of buying up open source tooling to control everything. Everyone start a tooling library! See if we can get 3rd tier companies to pay too much on a bunch of shitty scripts.

+
+

[–]gingimli 28 points29 points  (1 child)

I agree, they want to own the whole supply chain starting from “uv init” all the way to production. I have to wonder if one of them is eyeing GitLab, because that’s a relatively cheap way to own a large chunk of the supply chain.

+
+

[–]noshowthrow 6 points7 points  (0 children)

Yep. Once they buy all the open source stuff they'll start making it expensive beyond belief.

+
+

[–]critterheist 21 points22 points  (2 children)

Uh oh Pixi shit the bed

+
+

[–]pwang99 6 points7 points  (1 child)

? Pixi is fine

+
+

[–]SSX_Elise [score hidden]  (0 children)

pixi depends on uv but I do know they had their own alternative prior to shelving it in favor of uv

+
+

[–]cats_catz_kats_katz 2 points3 points  (0 children)

I’m so annoyed by all of this

+
+

[–]sebovzeoueb 1 point2 points  (0 children)

I mean, Bun is a bit more than a package manager, it's an all in one that replaces Node.js and a bunch of JS tooling.

+
+

[–]CSI_Tech_Dept [score hidden]  (0 children)

Their goal is to force people into their product.

+ +

I'm pretty sure they mostly care about uv. It will have ChatGPT integration and be modified that you can disable it, but without the integration it won't be as useful.

+
+

[–]menge101 418 points419 points  (46 children)

Keep in mind, ruff and ty are MIT licensed.

+ +

UV is apache2 and MIT licensed.

+ +

We can fork these things if needed to stop from being trapped into anything by OpenAI.

+
+

[–]MoreRespectForQA 126 points127 points  (15 children)

This looks more like an acquihire a bit like when zoom bought keybase.

+ +

As in, I doubt openai will try to monetize ruff, uv, etc. but new development will probably slow to a crawl or cease entirely as they move the devs on to other projects.

+ +

If we're lucky the purchase conditions will carve out a bit of time for them to work on it, as was the case with keybase but it'll be a dribble.

+
+

[–]zupzupper 22 points23 points  (2 children)

Which was a damn shame because keybase was awesome

+
+

[–]MoreRespectForQA 9 points10 points  (1 child)

it still is awesome.

+ +

it's a shame they stopped improving it but it's still running.

+
+

[–]zupzupper 6 points7 points  (0 children)

Thats true, though all my contacts bailed on it. Just a few lonely stragglers these days.

+
+

[–]wRAR_ 31 points32 points  (8 children)

+

new development will probably slow to a crawl or cease entirely as they move the devs on to other projects.

+
+ +

I feel relatively fine about this because:

+ +
    +
  • ruff is in a good shape and is immensely useful in the current state for any kinds of projects, and also hopefully the community can work on it successfully
  • +
  • ty isn't finished and widely adopted anyway
  • +
  • uv is widely adopted but I haven't used it that much still (mostly because it's still not packaged in Debian), OTOH as it's immensely popular probably the community would also be able to work on it?
  • +
+
+

[–]ROFLLOLSTER 42 points43 points  (6 children)

uv is definitely worth switching to, and I say that as someone who was initially quite hesitant (came from poetry).

+
+

[–]axonxorzpip'ing aint easy, especially on windows 6 points7 points  (5 children)

Here I am still using pip. What's the benefit for projects like mine with fairly uncomplicated dependencies?

+
+

[–]Stromcor 5 points6 points  (1 child)

For me it’s not about dependencies, it’s about uv being self sufficient, as in uv does not need Python to run and it manages Python versions for each projects. So no bootstrapping issue, no conflict, even venv do not need activation (most of the time), everything is neatly isolated and taken care of, including Python, without needing Python. And yes, it’s freaking fast.

+
+

[–]axonxorzpip'ing aint easy, especially on windows 2 points3 points  (0 children)

+

it’s about uv being self sufficient

+
+ +

That makes perfect sense. I never understood the "fast" arguments, how much time is everyone spending managing dependencies?

+
+

[–]jesusrambo 9 points10 points  (0 children)

It’s fast as hell

+ +

If you don’t need it, don’t use it

+
+

[–]JJJSchmidt_etAl 3 points4 points  (1 child)

The benefit is that you can just drop in uv without changing anything and it should still work, just a whole lot faster and with fewer commands.

+
+

[–]gerardwx 0 points1 point  (0 children)

Not quite. Doesn’t support private repos in same way as pip.

+
+

[–]catcint0s 0 points1 point  (0 children)

There is also pyx, I wonder if it will be finished.

+
+

[–]thisdude415 0 points1 point  (1 child)

I actually disagree here -- I think they will especially focus on ruff/ty to provide better error messages in Python so that they can train more effective AI agents.

+
+

[–]MoreRespectForQA 1 point2 points  (0 children)

A pull request could achieve that.

+
+

[–]CSI_Tech_Dept [score hidden]  (0 children)

If the acquisition goes through, the uv will have ChatGPT integration, and will be modified to not be very useful if you chose to not use the AI.

+
+

[–]PaintItPurple 51 points52 points  (1 child)

"Don't worry, you can just become the primary maintainer of a massive open-source project" is not that comforting to me as somebody using these projects. Realistically, I am not going to do that. My employer is not going to pay me to do that.

+
+

[–]Vresa 4 points5 points  (0 children)

I mean, the tools from astral as great because they’re well designed and fast. They aren’t nearly as large of a scope as many bedrock projects.

+
+

[–]Oct8-Danger 2 points3 points  (0 children)

Hopefully these projects join an OSS foundation like Linux foundation or other reputable one.

+ +

This happened recently to sqlmesh after fivetran bought the company. I think that’s the best outcome for the community and for open ai and astral.

+ +

Good PR, keeps community alive and trusting it. Trying to monetize and or close sourcing it or change in licensing never seems to pan out well. For example Redis and MinIO come to mind

+
+

[–]Eric_12345678 4 points5 points  (8 children)

Doesn't uv need a lot of remote infrastructure to work, for all the precompiled packages?

+ +

Edit: not really. Thanks for the info!

+
+

[–]latkdeTuple unpacking gone wrong 17 points18 points  (1 child)

Not really. There are no “precompiled packages” other than the Wheels that package authors (≠ Astral) upload to PyPI, and the pre-built Python binaries that are built via GitHub Actions infrastructure and distributed via the Cloudflare CDN. None of this is uv-specific, and there is little Astral-controlled infrastructure.

+
+

[–]bjorneylol 10 points11 points  (0 children)

99% of the remote infrastructure needs is just PyPi for packages, the rest is just downloading build artifacts from the github repo

+
+

[–]wRAR_ 2 points3 points  (4 children)

Do you mean interpreters or does it also keep some binary wheels separately from PyPI?

+
+

[–]Eric_12345678 1 point2 points  (3 children)

Binary wheels I think? Similar to anaconda.

+
+

[–]Smallpaul 5 points6 points  (0 children)

No. uv uses pypi for that just as poetry and pip do.

+
+

[–]wRAR_ 0 points1 point  (1 child)

Do you have a link?

+
+

[–]Eric_12345678 1 point2 points  (0 children)

No, I apparently was wrong.

+ +

Sorry.

+
+

[–]iaurp 318 points319 points  (18 children)

fuck

+
+

[–]Darwinmate 56 points57 points  (14 children)

fuck

+
+

[–]xAragon_ 37 points38 points  (13 children)

fuck

+
+

[–]really_not_unreal 8 points9 points  (1 child)

fuck

+
+

[–]LackingAGoodNamePythoneer 5 points6 points  (0 children)

fuck

+
+

[–]bigsassy 24 points25 points  (2 children)

aw fuck

+
+

[–]ricckyo 11 points12 points  (1 child)

fuckity fuck

+
+

[–]daddy_stool 2 points3 points  (0 children)

Frak

+
+

[–]latkdeTuple unpacking gone wrong 142 points143 points  (8 children)

oh no :'(

+ +

Too be fair though, Astral's business model always seemed unclear, and an acquihire is a relatively unsurprising outcome. We've all built on Astral tooling knowing that it was unsustainable. But having the fate of these tools chained to what may be the biggest bubble in tech economy history doesn't exactly soothe my worries.

+
+

[–]wRAR_ 47 points48 points  (0 children)

+

Astral's business model always seemed unclear,

+
+ +

Yeah, my second thought was "oh that's how they will monetize"

+
+

[–]MoreRespectForQA 35 points36 points  (2 children)

To be equally fair uv, ruff, etc. being abandoned is probably a better outcome than whatever plan to trap and extract money from devs they might come up with if they went on the IPO path.

+
+

[–]Smallpaul 9 points10 points  (1 child)

I don’t think IPO was ever in the cards but they could have been acquired by Red Hat or GitHub or a security vendor and their product plan might be more compatible than OpenAI.

+
+

[–]turbothyIt works on my machine 3 points4 points  (0 children)

GitHub and OpenAI are effectively the same thing in 2026.

+
+

[–]redditusername58 10 points11 points  (3 children)

Why would OpenAI need to hire developers when they have Codex?

+
+

[–]Vresa 15 points16 points  (2 children)

The folks at Astral have clearly demonstrated that they are extremely capable developers who can execute long term plans and design good tooling.

+ +

Codex unseats juniors, sloppy developers, and people getting paid 6 figures to make CRUD.

+ +

Extremely talented developers who can lead projects like this will always be in demand

+
+

[–]Black_Magic100 4 points5 points  (1 child)

I think you missed the sarcasm 😁

+
+

[–]Quant32 [score hidden]  (0 children)

It’s important to be said even if it is sarcasm lol fling people these days are losing any sense of nuance. Someone’s going to read the og comment and think “AI SLOP!!!”

+
+

[–]Consistent-Quiet6701 132 points133 points  (0 children)

Noooooooo

+
+

[–]masteroflich 50 points51 points  (10 children)

With what money

+
+

[–]axonxorzpip'ing aint easy, especially on windows 94 points95 points  (2 children)

Your future bailout.

+
+

[–]wunderspud7575 30 points31 points  (0 children)

Also, your 401k value reduction when they IPO and their stock plummets.

+
+

[–]PipePistoleer 5 points6 points  (0 children)

the bailout funded by the $39 trillion negative dollars in the US bank account

+
+

[–]Consistent-Quiet6701 9 points10 points  (5 children)

Nvidia or Oracle or one of the other market manipulation schemes

+
+

[–]CyclopsRock 13 points14 points  (4 children)

Nvidia isn't really like the others, though. They're not mining for gold, they're selling the shovels.

+
+

[–]VEMODMASKINEN 7 points8 points  (3 children)

How many shovel sellers were there after the gold rush had ended?

+
+

[–]CyclopsRock 3 points4 points  (1 child)

I'm not sure - people bought shovels before the rush and people still buy shovels today.

+ +

My argument is not that Nvidia will always and forever have insanely high revenue driven by insanely high demand for their products. My argument is that a business whose value and cash goes up when they sell lots of stuff is not an example of market manipulation.

+
+

[–]axonxorzpip'ing aint easy, especially on windows 0 points1 point  (0 children)

+

My argument is that a business whose value and cash goes up when they sell lots of stuff is not an example of market manipulation.

+
+ +

When people talk about manpulation in the AI space, I think they mean the nebulous and circular funding deals that have been made. We know NVIDIA's stock wouldn't be this high if they were "simply" selling the same price-adjusted volume in consumer GPUs and server interconnect hardware. A lot of these deals are contingent on infrastructure build-out that is completely separate from the product they're selling, but that's nobody's problem until the bag-holding party starts.

+
+

[–]mDodd 0 points1 point  (0 children)

From the Department of War, no?

+
+

[–]UltraPoci 34 points35 points  (0 children)

Time to fork it I guess

+
+

[–]farkinga 39 points40 points  (3 children)

upvoted for visibility; not because I think this is good news...

+ +

I've even gotten to the point where Microsoft can purchase something like Github and I can tolerate it. But this is just next-level in terms of the dystopian role OpenAI play in our present context. What a crap development...

+
+

[–]fivetoedslothbear 4 points5 points  (2 children)

To be fair, the reaction to buying GitHub was like someone announced the Apocalypse, but we lean heavily on GitHub at work, and it's not been that bad.

+
+

[–]turbothyIt works on my machine 2 points3 points  (0 children)

Organisation-wise, GitHub has been folded in under MS AI as of August 2025. Make of that what you will.

+
+

[–]farkinga 0 points1 point  (0 children)

It totally did feel like the apocalypse - and yet somehow, this seems worse. I know, uv isn't anything like github, but now openai has a particular "ick" that just lands poorly.

+ +

And btw, github probably was a bit apocalyptic insofar as they used all our code to train language models to be better coders than humans. So there's that too.

+ +

This timeline, yo...

+
+

[–]EmberQuill 30 points31 points  (0 children)

Well, we had a good run.

+
+

[–]xAmorphous 23 points24 points  (4 children)

The Python foundation has the opportunity to do the funniest thing

+
+

[–]jiminiminimini 3 points4 points  (1 child)

I say fork them! uv, ty, and ruff, I mean.

+
+

[–]tehfrod [score hidden]  (0 children)

Go ahead.

+
+

[–]PipePistoleer 1 point2 points  (0 children)

diabolical

+
+

[–]tehfrod [score hidden]  (0 children)

With what dev resources?

+
+

[–]All_I_Can 32 points33 points  (0 children)

Sad news. In an ideal world, I think uv should be part of Python itself, just as Cargo is for Rust.

+
+

[–]KwpolskaNikola co-maintainer 6 points7 points  (1 child)

Congrats to everyone who adopted VC-funded Python tools not written in Python for their projects!

+
+

[–]sudomatrix 3 points4 points  (0 children)

*shrug* I adopted the best tools for the job. uv and ruff are worlds better than what came before.

+
+

[–]danted002 19 points20 points  (6 children)

I’ve read the article and there is no mention of what happens to the tools themselves. They only mention that the people working on the tools will work on Codex… so who will work on the tools?

+
+

[–]wRAR_ 21 points22 points  (2 children)

"OpenAI plans to support Astral’s open source products", "we’ll continue to support these open source projects while exploring ways they can work more seamlessly with Codex"

+
+

[–]nemec 9 points10 points  (1 child)

aka in a few months we'll reduce new investment into the tools to near zero

+
+

[–]wRAR_ 2 points3 points  (0 children)

Yup.

+
+

[–]lucas1853 7 points8 points  (1 child)

+

They only mention that the people working on the tools will work on Codex… so who will work on the tools?

+
+ +

Codex.

+
+

[–]senatorium 0 points1 point  (0 children)

Judging from an email I received it looks like they might be axing their pyx product. "We'll continue supporting you as normal until the deal closes, and partner on next steps from there as we determine the long-term plan for the product." Doesn't say they're killing it but it certainly sounds wobbly.

+
+

[–]Civilanimal 20 points21 points  (1 child)

Fuuuuuuuuuuuck!

+ +

It's the Microslop strategy from the 90s all over again. https://en.wikipedia.org/wiki/Embrace,_extend,_and_extinguish

+
+

[–]PipePistoleer 1 point2 points  (0 children)

this is the thing I was trying to recall but me old brain is shite at remembering

+
+

[–]wRAR_ 10 points11 points  (0 children)

That's... certainly an interesting development.

+
+

[–]ideamotor 26 points27 points  (2 children)

This was inevitable. These companies absolutely want to pull the ladder up. They don’t even want you to be able to code. They want people to have to use their products. There’s barely anything on this announcement about continuing to support open source development. Just a little hand waving note, nothing about governance or foundation involvement. Letting such primary and significant python contributing entities be VC funded or otherwise private companies that have very poor plans for funding is really gonna backfire.

+
+

[–]harttrav 0 points1 point  (0 children)

This acquisition makes me uncomfortable too but they aren’t necessarily going to pull the ladder up. The more likely outcome is that they just enshittify uv, like adding tool fields in pyproject for codex specific configuration options that ship with uv. TBD whether switching back to miniconda is worth it for me personally, though my cynical side puts a 70% probability on an intolerable level of enshittification within 5 years.

+
+

[–]myke_ 4 points5 points  (0 children)

It feels like uv has stalled a bit recently, even some basic important issues like https://github.com/astral-sh/uv/issues/8253 have seen no progress despite being upvoted.

+
+

[–]HexamonNexus 14 points15 points  (0 children)

And another reason added to the list of why I'm taking early retirement. They won't be happy until everything is ruined.

+
+

[–]chub79 6 points7 points  (0 children)

Good for them but not great for the Python ecosystem.

+
+

[–]AC1colossus 3 points4 points  (0 children)

Well shit

+
+

[–]tristan957 17 points18 points  (5 children)

I hope that the additional resources from OpenAI allow Astral to develop these tools even faster. They are the best tools in the Python ecosystem.

+
+

[–]trisul-108 18 points19 points  (0 children)

OpenAI hopes the opposite ... that Astral will allow them to develop their proprietary tools even faster.

+
+

[–]strange_norrell 12 points13 points  (1 child)

Per statement, "Astral team will join the Codex team at OpenAI" (not continue to operate separately) and "we’ll continue to support these open source projects while exploring ways they can work more seamlessly with Codex". "Continue to support" phrasing does not give me any excitement here. More like "whatever our next AI bullshit product needs, we will add first".

+
+

[–]nemec 3 points4 points  (0 children)

100%. They'll do minimal investment, probably just security fixes and some minor stuff here and there (likely driven by OpenAI's needs rather than users'), but I have zero hope of significant long term support.

+
+

[–]gerardwx 1 point2 points  (0 children)

The interpreter is the best tool in the ecosystem

+
+

[–]Smallpaul -1 points0 points  (0 children)

What makes you think that these projects will get additional resources? What would be the motivation for giving them additional resources?

+
+

[–]downerison 12 points13 points  (1 child)

Rip

+
+

[–]_redmist 25 points26 points  (0 children)

*pip

+
+

[–]dusktreader 9 points10 points  (0 children)

Fuck. No.

+
+

[–]edcculus 9 points10 points  (0 children)

Well it was fun UV and Ruff. I hope the people smarter than me can fork these tools and make other versions we can use that aren’t tied to Open AI.

+
+

[–]No_Lingonberry1201pip needs updating 6 points7 points  (0 children)

First they took mah' RAM, then they took mah' GPU, then they came for mah' SSD, but I'll be dammed if they take my uv!

+
+

[–]Competitive_Lie2628 2 points3 points  (0 children)

Guess is as good time as any to consider other languages.

+ +

rip, you made starting new projects so much easier and I refuse to go back.

+
+

[–]hcmar [score hidden]  (0 children)

NOOOOO!

+
+

[–]12candycanes [score hidden]  (0 children)

Well gross. I hope that the licenses keep these going strong in the public interest.

+
+

[–]TheVincibleIronMan 8 points9 points  (0 children)

Fuck... 

+
+

[–]MarcelLecture 7 points8 points  (0 children)

Fckkkk noooo

+
+

[–]SpareIntroduction721 5 points6 points  (0 children)

There goes the good thing… wait for this shit to get locked with subscriptions now… they have to make money somehow….

+ +

Can’t wait for the next “uv” alternative

+
+

[–]Reasonable_Tie_5543 5 points6 points  (0 children)

OH GOD NO

+
+

[–]pioniere 4 points5 points  (0 children)

Booo. Fuck OpenAI.

+
+

[–]Giddius 4 points5 points  (0 children)

Hahahahahhahahhahah

+ +

It was so fucking inevitable.

+ +

Please we need an actual law like murphys law, that says „if there is python packaging system that has large scale adoption by the community, it will shoot itself in the knee and make the packaging situation actually worse“

+
+

[–]Aggressive-Prior4459 1 point2 points  (0 children)

I have really liked astral's work on uv and ruff. This OpenAI acquisition feels a bit off to me. I hope it doesn't change what made their tools good!

+
+

[–]firefrommoonlight 1 point2 points  (3 children)

Would there be any interest in me fixing the bugs in Pyflow and getting it updated to install newer python versions? It's almost identical to uv in concept, but I haven't touched it in 6 years.

+ +

Astral has demonstrated that there is desire for this sort of "just works" thing, which I struggled with, and led me to abandoning it. (I.e.: "pip/venv/conda/poetry are fine, why do I want this?", despite my personal experience with those as high-friction)

+
+

[–]max123246 1 point2 points  (1 child)

It might be easier to fork uv and help maintain it instead. We need our efforts to be concentrated, not split across a bunch of different tooling

+
+

[–]firefrommoonlight 0 points1 point  (0 children)

+

not split

+
+ +

This is the core problem / tragedy of the commons scenario. You could also ask why Astral made UV instead of forking and patching PyFlow.

+
+

[–]holy_macanoli 0 points1 point  (0 children)

Yes please.

+
+

[–]sudomatrix 1 point2 points  (1 child)

+$ uv init +I noticed you're setting up a new Python project. If you describe it in a paragraph I can write it for you to get you started. +

+
+

[–]l_dang [score hidden]  (0 children)

my eyesss

+
+

[–]xeow [score hidden]  (0 children)

Man, I just started using uv and ty a couple months ago and really like them both. I don't plan to stop using them unless/until something better comes along. Sucks that OpenAI is pulling the Astral devs off these projects, but we don't know yet what's going to happen. Maybe the core Astral people will quit in disgust and fork the tools. (I mean, I doubt it, but it's possible.) I guess the tools' future depends on how much $$$ OpenAI is throwing at the core devs and whether they allow them to work on the Astral tools as much as they'd like to, without being forced to work on Codex stuff too much. In any case, I'm just glad and grateful that uv and the other big Astral tools are open-source and that the community can pick up the pieces if things start falling apart. uv is a total game-changer for the Python ecosystem and is too important to let it languish.

+ +

Question: Does uv have a plugin system like git does? Is it possible to extend its functionality without forking it?

+
+

[–]NGTTwo 4 points5 points  (0 children)

God-fucking-dammit.

+ +

I so can't wait for all this generative AI idiocy to wind up in the dumpster of stupid tech ideas alongside NFTs and SOAP.

+
+

[–]martin7274 1 point2 points  (0 children)

Oops, we ran out of money, just like Bun

+ +

- Astral Founders

+
+

[–]thuiop1 2 points3 points  (0 children)

Well, shit. This is so fucking annoying. AI companies really are there to fuck up everything good in this world.

+
+

[–]_redmist 7 points8 points  (19 children)

Kinda glad i stuck with venv/pip now ngl.

+
+

[–]cinicDiver 5 points6 points  (4 children)

Hahaha, funny thing is I was just writing some Python tutorials for my company and said:

+ +

"we can work just fine with venv, theres uv but no need to overcomplicate things".

+
+

[–]max123246 1 point2 points  (0 children)

I was literally promoting uv at my company because the UX is far better

+
+

[–]Veggies-are-okay 3 points4 points  (2 children)

It’s funny because imo using base venv does overcomplicate things. I can propagate my testing, limiting, formatting, and type checking into my CI with a simple “COPY puproject.toml” and “uv sync —dev”. I can manage subsets of packages via “uv add <package> —group <xyz>. I can specify all my configurations for each of these, and dependency tracking is a thing of the past. No need to find the needle in the haystack of that one slightly out of date dependency or the chain that’s slightly conflicting as uv fixes all of it.

+ +

Like the learning curve is so straightforward that it took maybe 30min to get the basics down and another 30 to switch out poetry.

+ +

I honestly would rather have uv be acquired by OpenAI than just abandoned because of lack of funding. In the former at least we don’t have to go back to poetry or shudders pip…

+
+

[–]KwpolskaNikola co-maintainer 0 points1 point  (1 child)

uv might be easier to use than plain venv, but at the same time, it adds complexity by insisting on managing Pythons on its own.

+
+

[–]diegoasecas 1 point2 points  (0 children)

are you kidding? that's its best feature

+
+

[–]diegoasecas -1 points0 points  (12 children)

how does this affect you in any way

+
+

[–]_redmist 5 points6 points  (11 children)

It affects the ecosystem; not me directly.

+ +

The greatest lesson out of tech the past few years is that you must never hop onto the next cool thing because the finance bros will turn it to sh*t right away. +This makes me somewhat sad. Maybe that is how i am affected. 

+ +

Thank you for asking.

+
+

[–]AlpacaDC 0 points1 point  (0 children)

Kinda glad I can lock my uv version ngl.

+
+

[–]sweetbeems 3 points4 points  (0 children)

So they’re going to add codex to my freakin’ linter?? Sounds GREAT 🫠

+
+

[–]VEMODMASKINEN 4 points5 points  (4 children)

Lol, Astral's tools made Python tolerable. I'll just invest 100% of my time in Go instead.

+
+

[–]AtlAWSConsultant -3 points-2 points  (3 children)

I wonder if this might cause more people to move to Go.

+
+

[–]ebits21 0 points1 point  (0 children)

I don’t think I can go back to pre uv to be honest.

+ +

I won’t switch fully but would definitely consider another language where I can more often.

+
+

[–]updated_at 3 points4 points  (7 children)

yeah, going back to poetry and black

+
+

[–]AlpacaDC 4 points5 points  (6 children)

You can just lock uv’s, ruff’s and ty’s version you know.

+
+

[–]gingimli 7 points8 points  (5 children)

Until the security team comes calling you’re using tooling with CVEs that will never get fixed unless you upgrade or switch to something else.

+
+

[–]AlpacaDC 2 points3 points  (2 children)

I’m sure someone will fork it and keep it up to date if it comes to that.

+
+

[–]gingimli 5 points6 points  (1 child)

Hopefully! That plan worked out well for opentofu vs terraform

+
+

[–]syklemil 1 point2 points  (0 children)

Also opensearch vs elasticsearch, valkey vs redis. There's a history of companies trying to do stupid things with open source software, but also a history of people just creating a fork which grows until the company reconsiders.

+
+

[–]ThiefMaster 1 point2 points  (1 child)

If your security team pesters you about "vulnerabilities" in your dev tooling, then there's a good chance that your security team sucks. There are only few areas in dev tooling where bugs are actually vulnerabilities, when used on trusted code and not caring about ReDoS and the likes.

+ +

One example that comes to my mind would be a package manager writing outside the package's installation folder. But besides that...not much danger in this type of tool.

+
+

[–]Deux87 4 points5 points  (8 children)

So so, good that I didn't switch completely to uv

+
+

[–]FitBoog 17 points18 points  (7 children)

uv is here to stay, if they choose to be evil about uv people will fork it. People will not tolerate go back to pip + 8 other tools.

+
+

[–]PaintItPurple 0 points1 point  (0 children)

Very few times in history has this "if if goes bad, fork it" approach actually worked. LibreOffice is a very clear example of that working, but most software just dies a slow death until people just stopped using it in favor or something else that was actively developed.

+
+

[–]chub79 [score hidden]  (0 children)

I could easily replace uv with pdm personally. ruff is a more difficult one because I'm really used to its speed. (uv's speed never was a major benefit to me because I don't run uv as often).

+
+

[–]_OMGTheyKilledKenny_ 2 points3 points  (0 children)

It was good while it lasted but this was a predictable outcome.

+
+

[–]GreatBigBagOfNope 2 points3 points  (0 children)

Ah shit

+
+

[–]AlpacaDC 2 points3 points  (0 children)

Happy for the Astral team, sad for us

+
+

[–]KimPeek 2 points3 points  (0 children)

Laughs in pip

+
+

[–]-LeopardShark- 2 points3 points  (0 children)

There were always questions about the funding model, but I trusted them nonetheless.

+ +

What a betrayal, especially given how acutely awfully OpenAI has behaved recently.

+
+

[–]cellularcone 3 points4 points  (1 child)

I thought there was nothing to worry about and everyone should use UV because rust makes the internet faster or something.

+
+

[–]mmmboppe -1 points0 points  (0 children)

safer, not faster!

+ +

the irony is that an useful tool written in a safe language just became socially unsafe to be used

+
+

[–]WowSoHuTao 1 point2 points  (0 children)

Here is our AI powered super fast intelligent pkg manager!!!1!1

+
+

[–]HugeCannoli 1 point2 points  (0 children)

and here is finally the core of their business model unfolded.
+Get acquired, then fuck off with the money.

+
+

[–]Ok-Selection-2227 1 point2 points  (1 child)

I've never been a big fan. There are other tools that work fine for me. Now I have another reason for not using ruff and uv.

+
+

[–]max123246 1 point2 points  (0 children)

Any suggestions?

+
+

[–]rcap107 2 points3 points  (0 children)

Well that sucks.

+
+

[–]aspublic 0 points1 point  (0 children)

Acquisition might focus more on acquiring talent to strengthen applied machine learning and research teams rather than software.

+
+

[–]gordinmitya 0 points1 point  (0 children)

codex can’t work with uv

+
+

[–]nekokattt [score hidden]  (0 children)

Silly question but what is stopping a community fork similar to OpenTofu?

+ +

UV currently has both MIT and Apache licenses attached to it.

+
+

[–]vexatious-big [score hidden]  (1 child)

For alternatives:

+ +
    +
  • Poetry is a very solid package manager, very fast.
  • +
  • Pyright still yields better results than Ty for me. I.e. Ty can't properly figure out types inside a lambda function.
  • +
  • The Black formatter is still developed and relevant.
  • +
+ +

We'll be fine.

+
+

[–]chub79 [score hidden]  (0 children)

Same but with pdm instead of poetry.

+
+

[–]Worldly_Dish_48 [score hidden]  (0 children)

Implications?

+
+

[–]epiecs 1 point2 points  (0 children)

oh ffs

+
+

[–]xrabbit 0 points1 point  (0 children)

RIP

+
+

[–]Looploop420 0 points1 point  (0 children)

FUCK

+
+

[–]roastedfunction 0 points1 point  (0 children)

This was entirely predictable. Would love to see all these projects forked as soon as the rug pull comes or development is abandoned. Maybe PyPA can take these projects on or steward them?

+
+

[–]Chemical-Fault-7331 0 points1 point  (3 children)

I swear, every good thing that gets developed, they always sell out. My god. Can there not be a single company that doesn’t sell out?

+
+

[–]wRAR_ 4 points5 points  (0 children)

They needed money, where would have they got them?

+
+

[–]max123246 1 point2 points  (1 child)

To be fair, open source tooling isn't a way to make money. This was always going to happen. I just wish it wasn't OpenAI and could've been a company that has a stake in improving Python's ecosystem

+
+

[–]Chemical-Fault-7331 [score hidden]  (0 children)

How does Python make money?

+
+

[–]gromain 0 points1 point  (0 children)

Ah fuck. Here goes a good thing.

+
+

[–]skool_101git push -f 0 points1 point  (0 children)

guess we are cooked now

+
+

[–]me_myself_ai 0 points1 point  (0 children)

WTAF

+
+

[–]iengmind 0 points1 point  (0 children)

Aw fuck, somebody will fork ruff and uv right?

+
+

[–]zangler 0 points1 point  (0 children)

Uhhhh...

+
+

[–]TemporaryAble8826 0 points1 point  (0 children)

I get it, I really do. But these companies buying up all these massive open source tools and the teams behind them is so concerning.

+
+

[–]gautiexe 0 points1 point  (0 children)

God fing dammned

+
+

[–]fiery_prometheus 0 points1 point  (0 children)

Welp, there goes my tooling, time to find another package manager and linter. Zuban looks nice for a linter, and maybe poetry could be modernized as a package manager.

+
+

[–]gcavalcante8808 -1 points0 points  (0 children)

nooooooo

+
+

[–]ebits21 -1 points0 points  (0 children)

Ughhhhhhh sorry Python maybe it’s time for me to move on…..

+
+

[–]slcpnk -1 points0 points  (0 children)

no god please no

+
+

[–]levelstar01 -1 points0 points  (0 children)

Hahahahaha. I KNEW I was right to never trust this

+
+

π Rendered by PID 102307 on reddit-service-r2-loggedout-544bd98c-plfm8 at 2026-03-19 18:56:33.695335+00:00 running 90f1150 country code: US.

\ No newline at end of file diff --git a/examples/assets/sizing_test.html b/examples/assets/sizing_test.html new file mode 100644 index 000000000..7f2b1aeb2 --- /dev/null +++ b/examples/assets/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/examples/assets/text_layout_test.html b/examples/assets/text_layout_test.html new file mode 100644 index 000000000..abb489bd9 --- /dev/null +++ b/examples/assets/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/examples/assets/valign_lineheight_test.html b/examples/assets/valign_lineheight_test.html new file mode 100644 index 000000000..c2746fcc3 --- /dev/null +++ b/examples/assets/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/packages/blitz-dom/src/layout/inline.rs b/packages/blitz-dom/src/layout/inline.rs index e1e68ed9c..c83be92b9 100644 --- a/packages/blitz-dom/src/layout/inline.rs +++ b/packages/blitz-dom/src/layout/inline.rs @@ -10,7 +10,7 @@ use taffy::{ }; #[cfg(feature = "floats")] -use taffy::{Clear, Float, prelude::TaffyMaxContent}; +use taffy::{Float, prelude::TaffyMaxContent}; use super::construct::resolve_line_height; use super::resolve_calc_value; @@ -298,8 +298,8 @@ impl BaseDocument { 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(); + 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; From fe7f31b2a9cbd6b74cd91dae032d65979dbf1f3c Mon Sep 17 00:00:00 2001 From: Jonathan Kelley Date: Thu, 19 Mar 2026 15:44:46 -0700 Subject: [PATCH 05/14] more complete kitchen sink --- examples/assets/kitchen_sink_test.html | 838 +++++++++++++++++++++---- 1 file changed, 732 insertions(+), 106 deletions(-) diff --git a/examples/assets/kitchen_sink_test.html b/examples/assets/kitchen_sink_test.html index 5496550c2..cd7a15f63 100644 --- a/examples/assets/kitchen_sink_test.html +++ b/examples/assets/kitchen_sink_test.html @@ -3,21 +3,136 @@ -

CSS Layout Kitchen Sink

-

- Comprehensive test combining block, inline, flex, grid, positioning, sizing, text, and overflow. -

+ + + + + + + + +
+
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

@@ -34,20 +149,32 @@

Margin collapse + box-sizing

-

Auto margins + negative margins

+

Auto margins + negative margins + opacity

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

Percentage width/padding + min/max

+

calc() widths + percentage padding + min/max

-
- w:75% of 400=300, min:200, max:350 → 300px. pad:5% of 400=20px. +
+ width: calc(100% - 80px) = 320px
-
- w:30% of 400=120 → clamped to min:200px +
+ 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)
@@ -81,13 +208,33 @@

Multi-line inline-block baseline

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

Inline span decoration (background + border + padding)

+

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 +
@@ -128,7 +275,15 @@

Flex wrap + gap + order

4 (wraps)
-

Nested flex

+

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
@@ -197,13 +352,25 @@

Absolute stretched (all 4 insets)

-

Sticky header (scroll to test)

-
-
Sticky header
-
- Scroll down to test sticky behavior ↓ +

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. +
@@ -222,9 +389,18 @@

min-content / max-content / fit-content

aspect-ratio

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

calc() in various contexts

+
+
+
calc(33% - 6px)
+
calc(33% - 6px)
+
calc(33% - 6px)
+
@@ -241,28 +417,57 @@

text-align + white-space

- Ellipsis: this very long text truncates with dots… + Ellipsis: this very long text truncates with dots at 200px
-
pre: preserves spaces - and newlines
+
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 lines that demonstrate compact spacing on wrap.
-
LH:1.5 — normal lines that are readable and have normal spacing.
-
LH:2.5 — spacious lines with generous vertical gap.
+
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)
@@ -275,64 +480,257 @@

8. Overflow & Clipping

visible

- Overflows visibly outside box boundaries. + Overflows visibly outside box.

hidden

- Clipped at overflow hidden boundary. + Clipped at overflow boundary.

scroll

- Scrollable content. Extra text extra text extra text. + 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

+

9. Floats & Clearfix

-
-
+

Float left + right with text wrap

+
+
-

Text wraps around both floats. The left coral box and right blue box are floated, and this text fills the remaining space between them. When the text is long enough, it wraps below the shorter float first.

-
+

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. Display Table

+

10. Tables (real elements)

+

HTML table with thead/tbody, borders, alignment

-
-
-
Header 1
-
Header 2
-
Header 3
-
-
-
Data A
-
Data B with more text
-
C
-
+ + + + + + + + + + + + + + + + + + + + + + + + + +
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 +
-

11. Borders & Backgrounds

+

13. Shadows & Visual Effects

-

Border styles + radius

+

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
@@ -340,94 +738,322 @@

Border styles + radius

double
-
8px radius
+
8px radius
circle
-
asymmetric
+
top accent
+
left accent (alert)
+
+ +

Gradients

+
+
+
+
+
-

Background color + gradient

+

opacity + stacking contexts

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

transform

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

14. Lists

+ + +
-
Linear gradient
-
Radial gradient
+
+

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
+
-

12. Combined Stress Test

+

15. Details/Summary & Semantic HTML

-

Simulated page header with nav, hero, cards, and footer — exercises all layout modes together.

+

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.

+
+
- -
-
Logo
-
-
- HomeAboutProductsContact -
+

Semantic elements (should render as blocks)

+
+
<header>
+ +
+
<article>
+
<section>
+
+ +
<footer>
+
- -
-
Kitchen Sink Hero
-
Testing block centering, text align, gradients, and font sizing together.
+ +

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
-
Description text that might overflow the card area.
+
Grid auto-fill card with gradient header and shadow.
-
-
-
+
+
+
Card 2
-
Another card with different content length.
+
Tests border-radius + overflow:hidden clipping.
-
-
-
+
+
+
Card 3
-
Short.
+
Minmax responsive columns.
-
-
-
+
+
+
Card 4
-
Grid auto-fill handles the responsive layout here.
+
Should fill available columns.
- -
-
-

- This paragraph wraps around the floated circle. It tests float interaction with inline text, - bold spans, - super and - sub text, - decorated inlines, - and links. - All on the same line with proper baseline alignment. -

+ +
+
+
+
+
Card 5
+
Flex-wrap fallback for comparison.
+
+
+
+
+
+
Card 6
+
flex: 1 1 140px + max-width.
+
+
+
+
+
+
Card 7
+
Compare with auto-fill above.
+
+
- -
- © 2026 Kitchen Sink - - PrivacyTermsHelp - + +
+ +
+
+
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.

+
+
+ + +
+
+
+
+
+ + + + + + From 4c68bc73e40cd0672cd9436f833fda895f0b338c Mon Sep 17 00:00:00 2001 From: Jonathan Kelley Date: Thu, 19 Mar 2026 15:59:14 -0700 Subject: [PATCH 06/14] add even more samples --- examples/assets/calc_sizing_test.html | 209 ++++++++++++ .../assets/inline_block_heights_test.html | 151 ++++++++ examples/assets/table_test.html | 323 ++++++++++++++++++ 3 files changed, 683 insertions(+) create mode 100644 examples/assets/calc_sizing_test.html create mode 100644 examples/assets/inline_block_heights_test.html create mode 100644 examples/assets/table_test.html diff --git a/examples/assets/calc_sizing_test.html b/examples/assets/calc_sizing_test.html new file mode 100644 index 000000000..35cd2e7bc --- /dev/null +++ b/examples/assets/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/examples/assets/inline_block_heights_test.html b/examples/assets/inline_block_heights_test.html new file mode 100644 index 000000000..1b51953b5 --- /dev/null +++ b/examples/assets/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/examples/assets/table_test.html b/examples/assets/table_test.html new file mode 100644 index 000000000..a1d3d0ee0 --- /dev/null +++ b/examples/assets/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
+
+
+
+ + + From d73c7d0207d1718f6943e8aa2c463ff46ef16f4c Mon Sep 17 00:00:00 2001 From: Jonathan Kelley Date: Thu, 19 Mar 2026 17:03:15 -0700 Subject: [PATCH 07/14] more test cases --- examples/assets/overflow_baseline_test.html | 119 ++++++++++++++++++ examples/assets/overflow_case6_debug.html | 48 +++++++ .../overflow_hidden_baseline_debug.html | 23 ++++ 3 files changed, 190 insertions(+) create mode 100644 examples/assets/overflow_baseline_test.html create mode 100644 examples/assets/overflow_case6_debug.html create mode 100644 examples/assets/overflow_hidden_baseline_debug.html diff --git a/examples/assets/overflow_baseline_test.html b/examples/assets/overflow_baseline_test.html new file mode 100644 index 000000000..e39f39a54 --- /dev/null +++ b/examples/assets/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/examples/assets/overflow_case6_debug.html b/examples/assets/overflow_case6_debug.html new file mode 100644 index 000000000..59b4962ed --- /dev/null +++ b/examples/assets/overflow_case6_debug.html @@ -0,0 +1,48 @@ + + + + + + + +
+
+
overflow: visible
+
+ X + + A
B
C +
+
+
+
+
overflow: hidden
+
+ X + + A
B
C +
+
+
+
+ +

+
+
+
+
+
diff --git a/examples/assets/overflow_hidden_baseline_debug.html b/examples/assets/overflow_hidden_baseline_debug.html
new file mode 100644
index 000000000..9cab032a1
--- /dev/null
+++ b/examples/assets/overflow_hidden_baseline_debug.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+Align + + Visible text + + + Hidden text + +After +
+ + + From fe8aa255d80df4bb13e843831095f8265699a558 Mon Sep 17 00:00:00 2001 From: Jonathan Kelley Date: Thu, 19 Mar 2026 17:34:55 -0700 Subject: [PATCH 08/14] use os/2 line height --- examples/assets/line_height_debug.html | 78 +++++++++++++++++++++++ packages/blitz-dom/src/layout/inline.rs | 43 ++++++++++--- packages/blitz-dom/src/stylo_to_parley.rs | 2 +- 3 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 examples/assets/line_height_debug.html diff --git a/examples/assets/line_height_debug.html b/examples/assets/line_height_debug.html new file mode 100644 index 000000000..6dc4a84c6 --- /dev/null +++ b/examples/assets/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/packages/blitz-dom/src/layout/inline.rs b/packages/blitz-dom/src/layout/inline.rs
index c83be92b9..8c63499c4 100644
--- a/packages/blitz-dom/src/layout/inline.rs
+++ b/packages/blitz-dom/src/layout/inline.rs
@@ -12,7 +12,6 @@ use taffy::{
 #[cfg(feature = "floats")]
 use taffy::{Float, prelude::TaffyMaxContent};
 
-use super::construct::resolve_line_height;
 use super::resolve_calc_value;
 use crate::BaseDocument;
 use crate::stylo_to_parley;
@@ -323,6 +322,14 @@ impl BaseDocument {
                 } 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
@@ -724,15 +731,8 @@ impl BaseDocument {
         let styles = self.nodes[node_id].primary_styles()?;
         let font_styles = styles.get_font();
 
-        // Resolve font size and line height in CSS pixels
+        // Resolve font size in CSS pixels
         let font_size_px = font_styles.font_size.used_size.0.px();
-        let line_height = match font_styles.line_height {
-            stylo_to_parley::stylo::LineHeight::Normal => parley::LineHeight::FontSizeRelative(1.2),
-            stylo_to_parley::stylo::LineHeight::Number(n) => {
-                parley::LineHeight::FontSizeRelative(n.0)
-            }
-            stylo_to_parley::stylo::LineHeight::Length(v) => parley::LineHeight::Absolute(v.0.px()),
-        };
 
         // Query fontique for matching font
         let mut font_ctx = self.font_ctx.lock().unwrap();
@@ -778,7 +778,30 @@ impl BaseDocument {
         // Scale to physical pixels
         let strut_ascent = metrics.ascent * scale;
         let strut_descent = -metrics.descent * scale; // skrifa descent is negative
-        let strut_line_height = resolve_line_height(line_height, font_size_px) * scale;
+        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;
 
diff --git a/packages/blitz-dom/src/stylo_to_parley.rs b/packages/blitz-dom/src/stylo_to_parley.rs
index 224cbf555..eefe0631f 100644
--- a/packages/blitz-dom/src/stylo_to_parley.rs
+++ b/packages/blitz-dom/src/stylo_to_parley.rs
@@ -166,7 +166,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()),
     };

From 084f6d1a5f7ffefa5e59cfe8f2a5ac8afc38ecc5 Mon Sep 17 00:00:00 2001
From: Jonathan Kelley 
Date: Thu, 19 Mar 2026 20:29:36 -0700
Subject: [PATCH 09/14] update to stylo 0.14

---
 Cargo.lock                                 |   58 +-
 Cargo.toml                                 |   26 +-
 examples/assets/dioxus_footer.html         | 2490 +++++++++++++++++++
 examples/assets/dioxus_topnav.html         |   97 +
 examples/assets/dioxuslabs.html            | 2500 ++++++++++++++++++++
 examples/assets/hfull_debug.html           |  198 ++
 packages/blitz-dom/Cargo.toml              |    2 +-
 packages/blitz-dom/src/document.rs         |   16 +-
 packages/blitz-dom/src/font_metrics.rs     |    2 +-
 packages/blitz-dom/src/layout/construct.rs |   14 +-
 packages/blitz-dom/src/layout/damage.rs    |    3 +-
 packages/blitz-dom/src/layout/list.rs      |   77 +-
 packages/blitz-dom/src/layout/table.rs     |    2 -
 packages/blitz-dom/src/mutator.rs          |   27 +-
 packages/blitz-dom/src/node/node.rs        |   52 +-
 packages/blitz-dom/src/stylo.rs            |   42 +-
 packages/blitz-dom/src/stylo_to_parley.rs  |    9 +
 packages/blitz-paint/src/render.rs         |    5 +-
 18 files changed, 5442 insertions(+), 178 deletions(-)
 create mode 100644 examples/assets/dioxus_footer.html
 create mode 100644 examples/assets/dioxus_topnav.html
 create mode 100644 examples/assets/dioxuslabs.html
 create mode 100644 examples/assets/hfull_debug.html

diff --git a/Cargo.lock b/Cargo.lock
index d39549f75..95acb31d8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -852,8 +852,8 @@ dependencies = [
  "slab",
  "smallvec",
  "stylo",
- "stylo_config",
  "stylo_dom",
+ "stylo_static_prefs",
  "stylo_taffy",
  "stylo_traits",
  "taffy",
@@ -6123,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",
@@ -6265,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",
@@ -6634,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",
@@ -6652,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",
@@ -6692,7 +6686,6 @@ dependencies = [
  "strum",
  "strum_macros",
  "stylo_atoms",
- "stylo_config",
  "stylo_derive",
  "stylo_dom",
  "stylo_malloc_size_of",
@@ -6710,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",
@@ -6739,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",
@@ -6749,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",
@@ -6767,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"
@@ -6785,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",
@@ -7158,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",
@@ -7172,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",
diff --git a/Cargo.toml b/Cargo.toml
index b202f7b5b..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,14 +255,6 @@ 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" }
@@ -274,3 +266,9 @@ 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/examples/assets/dioxus_footer.html b/examples/assets/dioxus_footer.html
new file mode 100644
index 000000000..ab70e44c3
--- /dev/null
+++ b/examples/assets/dioxus_footer.html
@@ -0,0 +1,2490 @@
+
+
+
+
+
+Dioxus Footer Test
+
+
+
+
+
+
diff --git a/examples/assets/dioxus_topnav.html b/examples/assets/dioxus_topnav.html
new file mode 100644
index 000000000..a5141f806
--- /dev/null
+++ b/examples/assets/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/examples/assets/dioxuslabs.html b/examples/assets/dioxuslabs.html new file mode 100644 index 000000000..93cb15343 --- /dev/null +++ b/examples/assets/dioxuslabs.html @@ -0,0 +1,2500 @@ + + Dioxus | Fullstack crossplatform app framework for Rust + + + + + + + + + + + \ No newline at end of file diff --git a/examples/assets/hfull_debug.html b/examples/assets/hfull_debug.html new file mode 100644 index 000000000..875a1cc6b --- /dev/null +++ b/examples/assets/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/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/document.rs b/packages/blitz-dom/src/document.rs index 9e2758f39..5cab58438 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -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}, @@ -340,12 +341,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()); @@ -448,7 +447,8 @@ impl BaseDocument { }, ..Default::default() }; - *doc.root_node().stylo_element_data.borrow_mut() = Some(stylo_element_data); + // Safety: we have exclusive access during document construction + *unsafe { &mut *doc.root_node().stylo_element_data.get() } = Some(stylo_element_data); doc } 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 3c1ee1419..b522b8ac0 100644 --- a/packages/blitz-dom/src/layout/construct.rs +++ b/packages/blitz-dom/src/layout/construct.rs @@ -396,7 +396,8 @@ 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(); + // Safety: we have exclusive access during layout construction + let style_data = unsafe { &*node.stylo_element_data.get() }; let before_style = style_data .as_ref() .and_then(|d| d.styles.pseudos.as_array()[1].clone()); @@ -453,7 +454,8 @@ fn flush_pseudo_elements(doc: &mut BaseDocument, node_id: usize) { 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); + // Safety: we have exclusive access during layout construction + *unsafe { &mut *doc.nodes[new_node_id].stylo_element_data.get() } = Some(element_data); let node = &mut doc.nodes[node_id]; node.set_pe_by_index(idx, Some(new_node_id)); @@ -464,7 +466,8 @@ 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(); + // Safety: we have exclusive access during layout construction + let node_styles = unsafe { &mut *doc.nodes[pe_node_id].stylo_element_data.get() }; let node_styles = &mut node_styles.as_mut().unwrap(); node_styles.damage.insert(ALL_DAMAGE); let primary_styles = &mut node_styles.styles.primary; @@ -553,11 +556,10 @@ fn collect_complex_layout_children( 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); + // Safety: we have exclusive access during layout construction + *unsafe { &mut *doc.nodes[node_id].stylo_element_data.get() } = Some(stylo_element_data); if doc.nodes[container_node_id] .flags .contains(NodeFlags::IS_IN_DOCUMENT) diff --git a/packages/blitz-dom/src/layout/damage.rs b/packages/blitz-dom/src/layout/damage.rs index 28f91ddd1..e64c74519 100644 --- a/packages/blitz-dom/src/layout/damage.rs +++ b/packages/blitz-dom/src/layout/damage.rs @@ -383,7 +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(); + // Safety: we have exclusive access during layout + let stylo_element_data = unsafe { &*node.stylo_element_data.get() }; let primary_styles = stylo_element_data .as_ref() .and_then(|data| data.styles.get_primary()); 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..ab7c931e6 100644 --- a/packages/blitz-dom/src/mutator.rs +++ b/packages/blitz-dom/src/mutator.rs @@ -135,7 +135,8 @@ impl DocumentMutator<'_> { let node = self.doc.get_node(id).unwrap(); // Initialise style data - *node.stylo_element_data.borrow_mut() = Some(style::data::ElementData { + // Safety: node was just created, we have exclusive access + *unsafe { &mut *node.stylo_element_data.get() } = Some(style::data::ElementData { damage: ALL_DAMAGE, ..Default::default() }); @@ -215,7 +216,8 @@ 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() { + // Safety: we have exclusive access via &mut self + if let Some(data) = unsafe { &mut *node.stylo_element_data.get() }.as_mut() { data.hint |= RestyleHint::restyle_subtree(); data.damage.insert(ALL_DAMAGE); } @@ -224,7 +226,8 @@ 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() { + // Safety: we have exclusive access via &mut self + if let Some(data) = unsafe { &mut *parent.stylo_element_data.get() }.as_mut() { data.hint |= RestyleHint::restyle_subtree(); } } @@ -294,12 +297,12 @@ 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 { + // Safety: we have exclusive access via &mut self + let stylo_element_data = unsafe { &mut *node.stylo_element_data.get() }; + if let Some(data) = stylo_element_data.as_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 +397,8 @@ 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() { + // Safety: we have exclusive access via &mut self + if let Some(data) = unsafe { &mut *parent.stylo_element_data.get() }.as_mut() { data.hint |= RestyleHint::restyle_subtree(); } // Mark ancestors dirty so the style traversal visits this subtree. @@ -414,7 +418,8 @@ 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() { + // Safety: we have exclusive access via &mut self + if let Some(data) = unsafe { &mut *parent.stylo_element_data.get() }.as_mut() { data.hint |= RestyleHint::restyle_subtree(); } // Mark ancestors dirty so the style traversal visits this subtree. @@ -467,7 +472,8 @@ 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() { + // Safety: we have exclusive access via &mut self + if let Some(data) = unsafe { &mut *new_parent.stylo_element_data.get() }.as_mut() { data.hint |= RestyleHint::restyle_subtree(); } // Mark ancestors dirty so the style traversal visits this subtree. @@ -491,7 +497,8 @@ 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() { + // Safety: we have exclusive access via &mut self + if let Some(data) = unsafe { &mut *old_parent.stylo_element_data.get() }.as_mut() { data.hint |= RestyleHint::restyle_subtree(); } // Mark ancestors dirty so the style traversal visits this subtree. diff --git a/packages/blitz-dom/src/node/node.rs b/packages/blitz-dom/src/node/node.rs index cbaf3b804..f5a3bb2c0 100644 --- a/packages/blitz-dom/src/node/node.rs +++ b/packages/blitz-dom/src/node/node.rs @@ -1,4 +1,4 @@ -use atomic_refcell::{AtomicRef, AtomicRefCell, AtomicRefMut}; +use std::cell::UnsafeCell; use bitflags::bitflags; use blitz_traits::events::{ BlitzPointerEvent, BlitzPointerId, DomEventData, HitResult, PointerCoords, @@ -101,9 +101,10 @@ 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. Uses UnsafeCell because stylo's trait methods need interior + // mutability and must return ElementDataMut/ElementDataRef types (plain references). + // Safety is ensured by stylo's traversal which guarantees proper synchronization. + pub stylo_element_data: UnsafeCell>, pub selector_flags: Cell, pub guard: SharedRwLock, pub element_state: ElementState, @@ -255,7 +256,8 @@ impl Node { } pub fn set_restyle_hint(&self, hint: RestyleHint) { - if let Some(element_data) = self.stylo_element_data.borrow_mut().as_mut() { + // Safety: caller must ensure exclusive access to stylo_element_data + if let Some(element_data) = unsafe { &mut *self.stylo_element_data.get() }.as_mut() { element_data.hint.insert(hint); } // Mark all ancestors as having dirty descendants so the style traversal @@ -302,16 +304,10 @@ 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<&mut RestyleDamage> { + // Safety: caller must ensure exclusive access to stylo_element_data + let data = unsafe { &mut *self.stylo_element_data.get() }; + data.as_mut().map(|d| &mut d.damage) } pub fn damage(&mut self) -> Option { @@ -322,7 +318,8 @@ impl Node { } pub fn set_damage(&self, damage: RestyleDamage) { - if let Some(data) = self.stylo_element_data.borrow_mut().as_mut() { + // Safety: caller must ensure exclusive access to stylo_element_data + if let Some(data) = unsafe { &mut *self.stylo_element_data.get() }.as_mut() { data.damage = damage; } } @@ -334,7 +331,8 @@ impl Node { } pub fn remove_damage(&self, damage: RestyleDamage) { - if let Some(data) = self.stylo_element_data.borrow_mut().as_mut() { + // Safety: caller must ensure exclusive access to stylo_element_data + if let Some(data) = unsafe { &mut *self.stylo_element_data.get() }.as_mut() { data.damage.remove(damage); } } @@ -803,22 +801,10 @@ 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<&ComputedValues> { + // Safety: caller must ensure no mutable aliases to stylo_element_data exist + let data = unsafe { &*self.stylo_element_data.get() }; + data.as_ref()?.styles.get_primary().map(|arc| &**arc) } pub fn text_content(&self) -> String { diff --git a/packages/blitz-dom/src/stylo.rs b/packages/blitz-dom/src/stylo.rs index 971e8391b..9fde9cbd1 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,40 +696,41 @@ 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 { + unsafe fn ensure_data(&self) -> style::data::ElementDataMut<'_> { + // Safety: ensure_data is an unsafe fn — caller guarantees exclusive access. + let opt = unsafe { &mut *self.stylo_element_data.get() }; + if opt.is_none() { + *opt = Some(style::data::ElementData { damage: ALL_DAMAGE, ..Default::default() }); } - AtomicRefMut::map(stylo_data, |sd| sd.as_mut().unwrap()) + style::data::ElementDataMut::new_unchecked(opt.as_mut().unwrap()) } unsafe fn clear_data(&self) { - *self.stylo_element_data.borrow_mut() = None; + // Safety: clear_data is an unsafe fn — caller guarantees exclusive access. + *unsafe { &mut *self.stylo_element_data.get() } = None; } fn has_data(&self) -> bool { - self.stylo_element_data.borrow().is_some() + // Safety: stylo's traversal ensures proper synchronization. + unsafe { &*self.stylo_element_data.get() }.is_some() } - 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> { + // Safety: stylo's traversal ensures proper synchronization. + unsafe { + let opt = &*self.stylo_element_data.get(); + opt.as_ref().map(|data| style::data::ElementDataRef::new_unchecked(data)) } } - 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> { + // Safety: stylo's traversal ensures proper synchronization. + unsafe { + let opt = &mut *self.stylo_element_data.get(); + opt.as_mut().map(|data| style::data::ElementDataMut::new_unchecked(data)) } } diff --git a/packages/blitz-dom/src/stylo_to_parley.rs b/packages/blitz-dom/src/stylo_to_parley.rs index eefe0631f..6594eabf7 100644 --- a/packages/blitz-dom/src/stylo_to_parley.rs +++ b/packages/blitz-dom/src/stylo_to_parley.rs @@ -112,6 +112,15 @@ pub(crate) fn alignment_baseline(style: &stylo::ComputedValues) -> parley::Align 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 + } } } diff --git a/packages/blitz-paint/src/render.rs b/packages/blitz-paint/src/render.rs index 8dbac8a73..c67829186 100644 --- a/packages/blitz-paint/src/render.rs +++ b/packages/blitz-paint/src/render.rs @@ -358,9 +358,8 @@ impl<'dom> BlitzDomPainter<'dom> { layout: Layout, box_position: Point, ) -> ElementCx<'w> { - let style = node - .stylo_element_data - .borrow() + // Safety: render happens single-threaded after layout is complete. + let style = unsafe { &*node.stylo_element_data.get() } .as_ref() .map(|element_data| element_data.styles.primary().clone()) .unwrap_or( From e4b058ef1f90de3998d8ea3814ecb34305bb4ee1 Mon Sep 17 00:00:00 2001 From: Jonathan Kelley Date: Thu, 19 Mar 2026 20:41:39 -0700 Subject: [PATCH 10/14] rollback changes to stylo --- packages/blitz-dom/src/document.rs | 15 +++---- packages/blitz-dom/src/layout/construct.rs | 37 +++++++++-------- packages/blitz-dom/src/layout/damage.rs | 3 +- packages/blitz-dom/src/mutator.rs | 34 +++++++-------- packages/blitz-dom/src/node/node.rs | 48 ++++++++++------------ packages/blitz-dom/src/resolve.rs | 4 +- packages/blitz-dom/src/stylo.rs | 33 +++++++-------- packages/blitz-paint/src/render.rs | 2 +- 8 files changed, 87 insertions(+), 89 deletions(-) diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index 5cab58438..896b47259 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; @@ -437,18 +437,19 @@ 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() - }; + }; + } // Safety: we have exclusive access during document construction - *unsafe { &mut *doc.root_node().stylo_element_data.get() } = Some(stylo_element_data); + *unsafe { &mut *doc.root_node().stylo_element_data.get() } = Some(wrapper); doc } diff --git a/packages/blitz-dom/src/layout/construct.rs b/packages/blitz-dom/src/layout/construct.rs index b522b8ac0..c2452f98f 100644 --- a/packages/blitz-dom/src/layout/construct.rs +++ b/packages/blitz-dom/src/layout/construct.rs @@ -9,7 +9,6 @@ use parley::{ use slab::Slab; use style::{ computed_values::position::T as PositionProperty, - data::ElementData as StyloElementData, shared_lock::StylesheetGuards, values::{ computed::{Content, ContentItem, Display, Float}, @@ -400,10 +399,10 @@ fn flush_pseudo_elements(doc: &mut BaseDocument, node_id: usize) { let style_data = unsafe { &*node.stylo_element_data.get() }; let before_style = style_data .as_ref() - .and_then(|d| d.styles.pseudos.as_array()[1].clone()); + .and_then(|d| d.borrow().styles.pseudos.as_array()[1].clone()); let after_style = style_data .as_ref() - .and_then(|d| d.styles.pseudos.as_array()[0].clone()); + .and_then(|d| d.borrow().styles.pseudos.as_array()[0].clone()); (before_style, after_style, before_node_id, after_node_id) }; @@ -450,12 +449,15 @@ 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; + 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; + } // Safety: we have exclusive access during layout construction - *unsafe { &mut *doc.nodes[new_node_id].stylo_element_data.get() } = Some(element_data); + *unsafe { &mut *doc.nodes[new_node_id].stylo_element_data.get() } = Some(wrapper); let node = &mut doc.nodes[node_id]; node.set_pe_by_index(idx, Some(new_node_id)); @@ -467,8 +469,8 @@ fn flush_pseudo_elements(doc: &mut BaseDocument, node_id: usize) { // TODO: Update content // Safety: we have exclusive access during layout construction - let node_styles = unsafe { &mut *doc.nodes[pe_node_id].stylo_element_data.get() }; - let node_styles = &mut node_styles.as_mut().unwrap(); + let opt = unsafe { &*doc.nodes[pe_node_id].stylo_element_data.get() }; + let mut node_styles = opt.as_ref().unwrap().borrow_mut(); node_styles.damage.insert(ALL_DAMAGE); let primary_styles = &mut node_styles.styles.primary; @@ -552,14 +554,15 @@ fn collect_complex_layout_children( &PseudoElement::ServoAnonymousBox, &parent_style, ); - let mut stylo_element_data = StyloElementData { - damage: ALL_DAMAGE, - ..Default::default() - }; - stylo_element_data.styles.primary = Some(style); - stylo_element_data.set_restyled(); + 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(); + } // Safety: we have exclusive access during layout construction - *unsafe { &mut *doc.nodes[node_id].stylo_element_data.get() } = Some(stylo_element_data); + *unsafe { &mut *doc.nodes[node_id].stylo_element_data.get() } = Some(wrapper); if doc.nodes[container_node_id] .flags .contains(NodeFlags::IS_IN_DOCUMENT) diff --git a/packages/blitz-dom/src/layout/damage.rs b/packages/blitz-dom/src/layout/damage.rs index e64c74519..1b945c96b 100644 --- a/packages/blitz-dom/src/layout/damage.rs +++ b/packages/blitz-dom/src/layout/damage.rs @@ -385,7 +385,8 @@ impl BaseDocument { let _damage = node.damage().unwrap_or(ALL_DAMAGE); // Safety: we have exclusive access during layout let stylo_element_data = unsafe { &*node.stylo_element_data.get() }; - let primary_styles = stylo_element_data + let stylo_data_ref = stylo_element_data.as_ref().map(|w| w.borrow()); + let primary_styles = stylo_data_ref .as_ref() .and_then(|data| data.styles.get_primary()); diff --git a/packages/blitz-dom/src/mutator.rs b/packages/blitz-dom/src/mutator.rs index ab7c931e6..8437f4aab 100644 --- a/packages/blitz-dom/src/mutator.rs +++ b/packages/blitz-dom/src/mutator.rs @@ -136,10 +136,9 @@ impl DocumentMutator<'_> { // Initialise style data // Safety: node was just created, we have exclusive access - *unsafe { &mut *node.stylo_element_data.get() } = Some(style::data::ElementData { - damage: ALL_DAMAGE, - ..Default::default() - }); + let wrapper = style::data::ElementDataWrapper::default(); + wrapper.borrow_mut().damage = ALL_DAMAGE; + *unsafe { &mut *node.stylo_element_data.get() } = Some(wrapper); id } @@ -217,7 +216,8 @@ impl DocumentMutator<'_> { let node = &mut self.doc.nodes[node_id]; // Safety: we have exclusive access via &mut self - if let Some(data) = unsafe { &mut *node.stylo_element_data.get() }.as_mut() { + if let Some(wrapper) = unsafe { &*node.stylo_element_data.get() }.as_ref() { + let mut data = wrapper.borrow_mut(); data.hint |= RestyleHint::restyle_subtree(); data.damage.insert(ALL_DAMAGE); } @@ -227,8 +227,8 @@ impl DocumentMutator<'_> { if let Some(parent_id) = parent { let parent = &mut self.doc.nodes[parent_id]; // Safety: we have exclusive access via &mut self - if let Some(data) = unsafe { &mut *parent.stylo_element_data.get() }.as_mut() { - data.hint |= RestyleHint::restyle_subtree(); + if let Some(wrapper) = unsafe { &*parent.stylo_element_data.get() }.as_ref() { + wrapper.borrow_mut().hint |= RestyleHint::restyle_subtree(); } } @@ -298,8 +298,8 @@ impl DocumentMutator<'_> { let node = &mut self.doc.nodes[node_id]; // Safety: we have exclusive access via &mut self - let stylo_element_data = unsafe { &mut *node.stylo_element_data.get() }; - if let Some(data) = stylo_element_data.as_mut() { + if let Some(wrapper) = unsafe { &*node.stylo_element_data.get() }.as_ref() { + let mut data = wrapper.borrow_mut(); data.hint |= RestyleHint::restyle_subtree(); data.damage.insert(ALL_DAMAGE); } @@ -398,8 +398,8 @@ impl DocumentMutator<'_> { // TODO: make this fine grained / conditional based on ElementSelectorFlags if parent_is_in_doc { // Safety: we have exclusive access via &mut self - if let Some(data) = unsafe { &mut *parent.stylo_element_data.get() }.as_mut() { - data.hint |= RestyleHint::restyle_subtree(); + if let Some(wrapper) = unsafe { &*parent.stylo_element_data.get() }.as_ref() { + wrapper.borrow_mut().hint |= RestyleHint::restyle_subtree(); } // Mark ancestors dirty so the style traversal visits this subtree. parent.mark_ancestors_dirty(); @@ -419,8 +419,8 @@ impl DocumentMutator<'_> { // TODO: make this fine grained / conditional based on ElementSelectorFlags if parent_is_in_doc { // Safety: we have exclusive access via &mut self - if let Some(data) = unsafe { &mut *parent.stylo_element_data.get() }.as_mut() { - data.hint |= RestyleHint::restyle_subtree(); + if let Some(wrapper) = unsafe { &*parent.stylo_element_data.get() }.as_ref() { + wrapper.borrow_mut().hint |= RestyleHint::restyle_subtree(); } // Mark ancestors dirty so the style traversal visits this subtree. parent.mark_ancestors_dirty(); @@ -473,8 +473,8 @@ impl DocumentMutator<'_> { // TODO: make this fine grained / conditional based on ElementSelectorFlags if new_parent_is_in_doc { // Safety: we have exclusive access via &mut self - if let Some(data) = unsafe { &mut *new_parent.stylo_element_data.get() }.as_mut() { - data.hint |= RestyleHint::restyle_subtree(); + if let Some(wrapper) = unsafe { &*new_parent.stylo_element_data.get() }.as_ref() { + wrapper.borrow_mut().hint |= RestyleHint::restyle_subtree(); } // Mark ancestors dirty so the style traversal visits this subtree. new_parent.mark_ancestors_dirty(); @@ -498,8 +498,8 @@ impl DocumentMutator<'_> { // TODO: make this fine grained / conditional based on ElementSelectorFlags if child_was_in_doc { // Safety: we have exclusive access via &mut self - if let Some(data) = unsafe { &mut *old_parent.stylo_element_data.get() }.as_mut() { - data.hint |= RestyleHint::restyle_subtree(); + if let Some(wrapper) = unsafe { &*old_parent.stylo_element_data.get() }.as_ref() { + wrapper.borrow_mut().hint |= RestyleHint::restyle_subtree(); } // Mark ancestors dirty so the style traversal visits this subtree. old_parent.mark_ancestors_dirty(); diff --git a/packages/blitz-dom/src/node/node.rs b/packages/blitz-dom/src/node/node.rs index f5a3bb2c0..35f87724b 100644 --- a/packages/blitz-dom/src/node/node.rs +++ b/packages/blitz-dom/src/node/node.rs @@ -22,7 +22,8 @@ use style::selector_parser::{PseudoElement, RestyleDamage}; 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::data::ElementDataWrapper; +use style::shared_lock::SharedRwLock; use style_dom::ElementState; use style_traits::values::ToCss; use taffy::{ @@ -101,10 +102,11 @@ pub struct Node { /// Node type (Element, TextNode, etc) specific data pub data: NodeData, - // Style data from stylo. Uses UnsafeCell because stylo's trait methods need interior - // mutability and must return ElementDataMut/ElementDataRef types (plain references). - // Safety is ensured by stylo's traversal which guarantees proper synchronization. - pub stylo_element_data: UnsafeCell>, + // Style data from stylo. Uses UnsafeCell> because stylo's + // TElement trait methods return ElementDataMut/ElementDataRef which come from + // ElementDataWrapper::borrow()/borrow_mut(). The UnsafeCell wraps the Option to allow + // interior mutability for ensure_data/clear_data. Safety is ensured by stylo's traversal. + pub stylo_element_data: UnsafeCell>, pub selector_flags: Cell, pub guard: SharedRwLock, pub element_state: ElementState, @@ -257,8 +259,8 @@ impl Node { pub fn set_restyle_hint(&self, hint: RestyleHint) { // Safety: caller must ensure exclusive access to stylo_element_data - if let Some(element_data) = unsafe { &mut *self.stylo_element_data.get() }.as_mut() { - element_data.hint.insert(hint); + if let Some(wrapper) = unsafe { &*self.stylo_element_data.get() }.as_ref() { + wrapper.borrow_mut().hint.insert(hint); } // Mark all ancestors as having dirty descendants so the style traversal // will visit this node's subtree @@ -282,8 +284,8 @@ 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() { - data.hint |= RestyleHint::RESTYLE_STYLE_ATTRIBUTE; + if let Some(wrapper) = self.stylo_element_data.get_mut().as_ref() { + wrapper.borrow_mut().hint |= RestyleHint::RESTYLE_STYLE_ATTRIBUTE; self.set_dirty_descendants(); } } @@ -304,42 +306,36 @@ impl Node { } } - pub fn damage_mut(&self) -> Option<&mut RestyleDamage> { - // Safety: caller must ensure exclusive access to stylo_element_data - let data = unsafe { &mut *self.stylo_element_data.get() }; - data.as_mut().map(|d| &mut d.damage) - } - pub fn damage(&mut self) -> Option { self.stylo_element_data .get_mut() .as_ref() - .map(|data| data.damage) + .map(|wrapper| wrapper.borrow().damage) } pub fn set_damage(&self, damage: RestyleDamage) { // Safety: caller must ensure exclusive access to stylo_element_data - if let Some(data) = unsafe { &mut *self.stylo_element_data.get() }.as_mut() { - data.damage = damage; + if let Some(wrapper) = unsafe { &*self.stylo_element_data.get() }.as_ref() { + wrapper.borrow_mut().damage = damage; } } pub fn insert_damage(&mut self, damage: RestyleDamage) { - if let Some(data) = self.stylo_element_data.get_mut().as_mut() { - data.damage |= damage; + if let Some(wrapper) = self.stylo_element_data.get_mut().as_ref() { + wrapper.borrow_mut().damage |= damage; } } pub fn remove_damage(&self, damage: RestyleDamage) { // Safety: caller must ensure exclusive access to stylo_element_data - if let Some(data) = unsafe { &mut *self.stylo_element_data.get() }.as_mut() { - data.damage.remove(damage); + if let Some(wrapper) = unsafe { &*self.stylo_element_data.get() }.as_ref() { + wrapper.borrow_mut().damage.remove(damage); } } pub fn clear_damage_mut(&mut self) { - if let Some(data) = self.stylo_element_data.get_mut() { - data.damage = RestyleDamage::empty(); + if let Some(wrapper) = self.stylo_element_data.get_mut().as_ref() { + wrapper.borrow_mut().damage = RestyleDamage::empty(); } } @@ -801,10 +797,10 @@ impl Node { Some(&attr.value) } - pub fn primary_styles(&self) -> Option<&ComputedValues> { + pub fn primary_styles(&self) -> Option> { // Safety: caller must ensure no mutable aliases to stylo_element_data exist let data = unsafe { &*self.stylo_element_data.get() }; - data.as_ref()?.styles.get_primary().map(|arc| &**arc) + data.as_ref()?.borrow().styles.get_primary().cloned() } pub fn text_content(&self) -> String { diff --git a/packages/blitz-dom/src/resolve.rs b/packages/blitz-dom/src/resolve.rs index 6bc6a2a9b..401eb2f90 100644 --- a/packages/blitz-dom/src/resolve.rs +++ b/packages/blitz-dom/src/resolve.rs @@ -171,8 +171,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 + if let Some(wrapper) = doc.nodes[child_id].stylo_element_data.get_mut().as_ref() { + wrapper.borrow_mut().damage .remove(CONSTRUCT_DESCENDENT | CONSTRUCT_FC | CONSTRUCT_BOX); } } diff --git a/packages/blitz-dom/src/stylo.rs b/packages/blitz-dom/src/stylo.rs index 9fde9cbd1..1ea5c8676 100644 --- a/packages/blitz-dom/src/stylo.rs +++ b/packages/blitz-dom/src/stylo.rs @@ -697,41 +697,38 @@ impl<'a> TElement for BlitzNode<'a> { } unsafe fn ensure_data(&self) -> style::data::ElementDataMut<'_> { - // Safety: ensure_data is an unsafe fn — caller guarantees exclusive access. + // Safety: ensure_data is unsafe — caller guarantees exclusive access. + // UnsafeCell needed here because we mutate the Option (None → Some). let opt = unsafe { &mut *self.stylo_element_data.get() }; if opt.is_none() { - *opt = Some(style::data::ElementData { - damage: ALL_DAMAGE, - ..Default::default() - }); + let data = style::data::ElementDataWrapper::default(); + data.borrow_mut().damage = ALL_DAMAGE; + *opt = Some(data); } - style::data::ElementDataMut::new_unchecked(opt.as_mut().unwrap()) + opt.as_ref().unwrap().borrow_mut() } unsafe fn clear_data(&self) { - // Safety: clear_data is an unsafe fn — caller guarantees exclusive access. + // Safety: clear_data is unsafe — caller guarantees exclusive access. *unsafe { &mut *self.stylo_element_data.get() } = None; } fn has_data(&self) -> bool { - // Safety: stylo's traversal ensures proper synchronization. + // Safety: only reads the Option discriminant, no data aliasing concerns. unsafe { &*self.stylo_element_data.get() }.is_some() } fn borrow_data(&self) -> Option> { - // Safety: stylo's traversal ensures proper synchronization. - unsafe { - let opt = &*self.stylo_element_data.get(); - opt.as_ref().map(|data| style::data::ElementDataRef::new_unchecked(data)) - } + // Safety: only reads the Option discriminant to get &ElementDataWrapper. + let opt = unsafe { &*self.stylo_element_data.get() }; + opt.as_ref().map(|wrapper| wrapper.borrow()) } fn mutate_data(&self) -> Option> { - // Safety: stylo's traversal ensures proper synchronization. - unsafe { - let opt = &mut *self.stylo_element_data.get(); - opt.as_mut().map(|data| style::data::ElementDataMut::new_unchecked(data)) - } + // Safety: only reads the Option discriminant to get &ElementDataWrapper. + // Interior mutability is provided by ElementDataWrapper's UnsafeCell. + let opt = unsafe { &*self.stylo_element_data.get() }; + opt.as_ref().map(|wrapper| wrapper.borrow_mut()) } fn skip_item_display_fixup(&self) -> bool { diff --git a/packages/blitz-paint/src/render.rs b/packages/blitz-paint/src/render.rs index c67829186..5f111a0f6 100644 --- a/packages/blitz-paint/src/render.rs +++ b/packages/blitz-paint/src/render.rs @@ -361,7 +361,7 @@ impl<'dom> BlitzDomPainter<'dom> { // Safety: render happens single-threaded after layout is complete. let style = unsafe { &*node.stylo_element_data.get() } .as_ref() - .map(|element_data| element_data.styles.primary().clone()) + .map(|wrapper| wrapper.borrow().styles.primary().clone()) .unwrap_or( ComputedValues::initial_values_with_font_override(Font::initial_values()).to_arc(), ); From ed9ba94392adb9e6f16a99e7ab5e4af6435e3188 Mon Sep 17 00:00:00 2001 From: Jonathan Kelley Date: Fri, 20 Mar 2026 10:47:07 -0700 Subject: [PATCH 11/14] encapsulate stylo wrapped type --- packages/blitz-dom/src/document.rs | 3 +- packages/blitz-dom/src/layout/construct.rs | 24 ++++------ packages/blitz-dom/src/layout/damage.rs | 4 +- packages/blitz-dom/src/mutator.rs | 36 +++++--------- packages/blitz-dom/src/node/mod.rs | 2 + packages/blitz-dom/src/node/node.rs | 49 ++++++++----------- packages/blitz-dom/src/node/stylo_data.rs | 55 ++++++++++++++++++++++ packages/blitz-dom/src/resolve.rs | 5 +- packages/blitz-dom/src/stylo.rs | 24 +++------- packages/blitz-paint/src/render.rs | 7 ++- 10 files changed, 112 insertions(+), 97 deletions(-) create mode 100644 packages/blitz-dom/src/node/stylo_data.rs diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index 896b47259..f78a576dc 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -448,8 +448,7 @@ impl BaseDocument { ..Default::default() }; } - // Safety: we have exclusive access during document construction - *unsafe { &mut *doc.root_node().stylo_element_data.get() } = Some(wrapper); + doc.root_node().stylo_element_data.set(wrapper); doc } diff --git a/packages/blitz-dom/src/layout/construct.rs b/packages/blitz-dom/src/layout/construct.rs index c2452f98f..475a3bda0 100644 --- a/packages/blitz-dom/src/layout/construct.rs +++ b/packages/blitz-dom/src/layout/construct.rs @@ -395,14 +395,12 @@ fn flush_pseudo_elements(doc: &mut BaseDocument, node_id: usize) { let after_node_id = node.after; // Note: yes these are kinda backwards - // Safety: we have exclusive access during layout construction - let style_data = unsafe { &*node.stylo_element_data.get() }; - let before_style = style_data - .as_ref() - .and_then(|d| d.borrow().styles.pseudos.as_array()[1].clone()); - let after_style = style_data - .as_ref() - .and_then(|d| d.borrow().styles.pseudos.as_array()[0].clone()); + let before_style = node.stylo_element_data + .borrow() + .and_then(|d| d.styles.pseudos.as_array()[1].clone()); + 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) }; @@ -456,8 +454,7 @@ fn flush_pseudo_elements(doc: &mut BaseDocument, node_id: usize) { element_data.set_restyled(); element_data.damage = ALL_DAMAGE; } - // Safety: we have exclusive access during layout construction - *unsafe { &mut *doc.nodes[new_node_id].stylo_element_data.get() } = Some(wrapper); + 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)); @@ -468,9 +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 - // Safety: we have exclusive access during layout construction - let opt = unsafe { &*doc.nodes[pe_node_id].stylo_element_data.get() }; - let mut node_styles = opt.as_ref().unwrap().borrow_mut(); + 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; @@ -561,8 +556,7 @@ fn collect_complex_layout_children( stylo_element_data.styles.primary = Some(style); stylo_element_data.set_restyled(); } - // Safety: we have exclusive access during layout construction - *unsafe { &mut *doc.nodes[node_id].stylo_element_data.get() } = Some(wrapper); + doc.nodes[node_id].stylo_element_data.set(wrapper); if doc.nodes[container_node_id] .flags .contains(NodeFlags::IS_IN_DOCUMENT) diff --git a/packages/blitz-dom/src/layout/damage.rs b/packages/blitz-dom/src/layout/damage.rs index 1b945c96b..d89010b42 100644 --- a/packages/blitz-dom/src/layout/damage.rs +++ b/packages/blitz-dom/src/layout/damage.rs @@ -383,9 +383,7 @@ impl BaseDocument { let display = { let node = self.nodes.get_mut(node_id).unwrap(); let _damage = node.damage().unwrap_or(ALL_DAMAGE); - // Safety: we have exclusive access during layout - let stylo_element_data = unsafe { &*node.stylo_element_data.get() }; - let stylo_data_ref = stylo_element_data.as_ref().map(|w| w.borrow()); + let stylo_data_ref = node.stylo_element_data.borrow(); let primary_styles = stylo_data_ref .as_ref() .and_then(|data| data.styles.get_primary()); diff --git a/packages/blitz-dom/src/mutator.rs b/packages/blitz-dom/src/mutator.rs index 8437f4aab..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 - // Safety: node was just created, we have exclusive access let wrapper = style::data::ElementDataWrapper::default(); wrapper.borrow_mut().damage = ALL_DAMAGE; - *unsafe { &mut *node.stylo_element_data.get() } = Some(wrapper); + node.stylo_element_data.set(wrapper); id } @@ -215,9 +214,7 @@ impl DocumentMutator<'_> { self.doc.snapshot_node(node_id); let node = &mut self.doc.nodes[node_id]; - // Safety: we have exclusive access via &mut self - if let Some(wrapper) = unsafe { &*node.stylo_element_data.get() }.as_ref() { - let mut data = wrapper.borrow_mut(); + if let Some(mut data) = node.stylo_element_data.borrow_mut() { data.hint |= RestyleHint::restyle_subtree(); data.damage.insert(ALL_DAMAGE); } @@ -226,9 +223,8 @@ impl DocumentMutator<'_> { let parent = node.parent; if let Some(parent_id) = parent { let parent = &mut self.doc.nodes[parent_id]; - // Safety: we have exclusive access via &mut self - if let Some(wrapper) = unsafe { &*parent.stylo_element_data.get() }.as_ref() { - wrapper.borrow_mut().hint |= RestyleHint::restyle_subtree(); + if let Some(mut data) = parent.stylo_element_data.borrow_mut() { + data.hint |= RestyleHint::restyle_subtree(); } } @@ -297,9 +293,7 @@ impl DocumentMutator<'_> { let node = &mut self.doc.nodes[node_id]; - // Safety: we have exclusive access via &mut self - if let Some(wrapper) = unsafe { &*node.stylo_element_data.get() }.as_ref() { - let mut data = wrapper.borrow_mut(); + if let Some(mut data) = node.stylo_element_data.borrow_mut() { data.hint |= RestyleHint::restyle_subtree(); data.damage.insert(ALL_DAMAGE); } @@ -397,9 +391,8 @@ impl DocumentMutator<'_> { // TODO: make this fine grained / conditional based on ElementSelectorFlags if parent_is_in_doc { - // Safety: we have exclusive access via &mut self - if let Some(wrapper) = unsafe { &*parent.stylo_element_data.get() }.as_ref() { - wrapper.borrow_mut().hint |= RestyleHint::restyle_subtree(); + 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. parent.mark_ancestors_dirty(); @@ -418,9 +411,8 @@ impl DocumentMutator<'_> { // TODO: make this fine grained / conditional based on ElementSelectorFlags if parent_is_in_doc { - // Safety: we have exclusive access via &mut self - if let Some(wrapper) = unsafe { &*parent.stylo_element_data.get() }.as_ref() { - wrapper.borrow_mut().hint |= RestyleHint::restyle_subtree(); + 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. parent.mark_ancestors_dirty(); @@ -472,9 +464,8 @@ impl DocumentMutator<'_> { // TODO: make this fine grained / conditional based on ElementSelectorFlags if new_parent_is_in_doc { - // Safety: we have exclusive access via &mut self - if let Some(wrapper) = unsafe { &*new_parent.stylo_element_data.get() }.as_ref() { - wrapper.borrow_mut().hint |= RestyleHint::restyle_subtree(); + 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. new_parent.mark_ancestors_dirty(); @@ -497,9 +488,8 @@ impl DocumentMutator<'_> { // TODO: make this fine grained / conditional based on ElementSelectorFlags if child_was_in_doc { - // Safety: we have exclusive access via &mut self - if let Some(wrapper) = unsafe { &*old_parent.stylo_element_data.get() }.as_ref() { - wrapper.borrow_mut().hint |= RestyleHint::restyle_subtree(); + 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. old_parent.mark_ancestors_dirty(); 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 35f87724b..51b373317 100644 --- a/packages/blitz-dom/src/node/node.rs +++ b/packages/blitz-dom/src/node/node.rs @@ -1,4 +1,3 @@ -use std::cell::UnsafeCell; use bitflags::bitflags; use blitz_traits::events::{ BlitzPointerEvent, BlitzPointerId, DomEventData, HitResult, PointerCoords, @@ -15,15 +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::ElementDataWrapper; -use style::shared_lock::SharedRwLock; use style_dom::ElementState; use style_traits::values::ToCss; use taffy::{ @@ -102,11 +101,9 @@ pub struct Node { /// Node type (Element, TextNode, etc) specific data pub data: NodeData, - // Style data from stylo. Uses UnsafeCell> because stylo's - // TElement trait methods return ElementDataMut/ElementDataRef which come from - // ElementDataWrapper::borrow()/borrow_mut(). The UnsafeCell wraps the Option to allow - // interior mutability for ensure_data/clear_data. Safety is ensured by stylo's traversal. - pub stylo_element_data: UnsafeCell>, + /// 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, @@ -258,9 +255,8 @@ impl Node { } pub fn set_restyle_hint(&self, hint: RestyleHint) { - // Safety: caller must ensure exclusive access to stylo_element_data - if let Some(wrapper) = unsafe { &*self.stylo_element_data.get() }.as_ref() { - wrapper.borrow_mut().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 @@ -284,8 +280,8 @@ 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(wrapper) = self.stylo_element_data.get_mut().as_ref() { - wrapper.borrow_mut().hint |= RestyleHint::RESTYLE_STYLE_ATTRIBUTE; + if let Some(mut data) = self.stylo_element_data.borrow_mut() { + data.hint |= RestyleHint::RESTYLE_STYLE_ATTRIBUTE; self.set_dirty_descendants(); } } @@ -307,35 +303,30 @@ impl Node { } pub fn damage(&mut self) -> Option { - self.stylo_element_data - .get_mut() - .as_ref() - .map(|wrapper| wrapper.borrow().damage) + self.stylo_element_data.borrow().map(|data| data.damage) } pub fn set_damage(&self, damage: RestyleDamage) { - // Safety: caller must ensure exclusive access to stylo_element_data - if let Some(wrapper) = unsafe { &*self.stylo_element_data.get() }.as_ref() { - wrapper.borrow_mut().damage = damage; + 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(wrapper) = self.stylo_element_data.get_mut().as_ref() { - wrapper.borrow_mut().damage |= damage; + if let Some(mut data) = self.stylo_element_data.borrow_mut() { + data.damage |= damage; } } pub fn remove_damage(&self, damage: RestyleDamage) { - // Safety: caller must ensure exclusive access to stylo_element_data - if let Some(wrapper) = unsafe { &*self.stylo_element_data.get() }.as_ref() { - wrapper.borrow_mut().damage.remove(damage); + 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(wrapper) = self.stylo_element_data.get_mut().as_ref() { - wrapper.borrow_mut().damage = RestyleDamage::empty(); + if let Some(mut data) = self.stylo_element_data.borrow_mut() { + data.damage = RestyleDamage::empty(); } } @@ -798,9 +789,7 @@ impl Node { } pub fn primary_styles(&self) -> Option> { - // Safety: caller must ensure no mutable aliases to stylo_element_data exist - let data = unsafe { &*self.stylo_element_data.get() }; - data.as_ref()?.borrow().styles.get_primary().cloned() + self.stylo_element_data.borrow()?.styles.get_primary().cloned() } pub fn text_content(&self) -> String { 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 401eb2f90..638cafca6 100644 --- a/packages/blitz-dom/src/resolve.rs +++ b/packages/blitz-dom/src/resolve.rs @@ -171,9 +171,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(wrapper) = doc.nodes[child_id].stylo_element_data.get_mut().as_ref() { - wrapper.borrow_mut().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); } } diff --git a/packages/blitz-dom/src/stylo.rs b/packages/blitz-dom/src/stylo.rs index 1ea5c8676..c43ebcc41 100644 --- a/packages/blitz-dom/src/stylo.rs +++ b/packages/blitz-dom/src/stylo.rs @@ -697,38 +697,28 @@ impl<'a> TElement for BlitzNode<'a> { } unsafe fn ensure_data(&self) -> style::data::ElementDataMut<'_> { - // Safety: ensure_data is unsafe — caller guarantees exclusive access. - // UnsafeCell needed here because we mutate the Option (None → Some). - let opt = unsafe { &mut *self.stylo_element_data.get() }; - if opt.is_none() { + if !self.stylo_element_data.has_data() { let data = style::data::ElementDataWrapper::default(); data.borrow_mut().damage = ALL_DAMAGE; - *opt = Some(data); + self.stylo_element_data.set(data); } - opt.as_ref().unwrap().borrow_mut() + self.stylo_element_data.borrow_mut().unwrap() } unsafe fn clear_data(&self) { - // Safety: clear_data is unsafe — caller guarantees exclusive access. - *unsafe { &mut *self.stylo_element_data.get() } = None; + self.stylo_element_data.clear(); } fn has_data(&self) -> bool { - // Safety: only reads the Option discriminant, no data aliasing concerns. - unsafe { &*self.stylo_element_data.get() }.is_some() + self.stylo_element_data.has_data() } fn borrow_data(&self) -> Option> { - // Safety: only reads the Option discriminant to get &ElementDataWrapper. - let opt = unsafe { &*self.stylo_element_data.get() }; - opt.as_ref().map(|wrapper| wrapper.borrow()) + self.stylo_element_data.borrow() } fn mutate_data(&self) -> Option> { - // Safety: only reads the Option discriminant to get &ElementDataWrapper. - // Interior mutability is provided by ElementDataWrapper's UnsafeCell. - let opt = unsafe { &*self.stylo_element_data.get() }; - opt.as_ref().map(|wrapper| wrapper.borrow_mut()) + self.stylo_element_data.borrow_mut() } fn skip_item_display_fixup(&self) -> bool { diff --git a/packages/blitz-paint/src/render.rs b/packages/blitz-paint/src/render.rs index 5f111a0f6..6b8b6f808 100644 --- a/packages/blitz-paint/src/render.rs +++ b/packages/blitz-paint/src/render.rs @@ -358,10 +358,9 @@ impl<'dom> BlitzDomPainter<'dom> { layout: Layout, box_position: Point, ) -> ElementCx<'w> { - // Safety: render happens single-threaded after layout is complete. - let style = unsafe { &*node.stylo_element_data.get() } - .as_ref() - .map(|wrapper| wrapper.borrow().styles.primary().clone()) + let style = node.stylo_element_data + .borrow() + .map(|data| data.styles.primary().clone()) .unwrap_or( ComputedValues::initial_values_with_font_override(Font::initial_values()).to_arc(), ); From 1998c6bf3096161291bbef2f8d2ace2b21cbb5b9 Mon Sep 17 00:00:00 2001 From: Jonathan Kelley Date: Fri, 20 Mar 2026 12:08:21 -0700 Subject: [PATCH 12/14] implement position: absolute, static --- packages/blitz-dom/src/document.rs | 60 +++- packages/blitz-dom/src/layout/damage.rs | 10 +- packages/blitz-dom/src/layout/inline.rs | 6 + packages/blitz-dom/src/node/node.rs | 16 +- packages/blitz-dom/src/resolve.rs | 111 ++++++ packages/blitz-paint/src/render.rs | 15 +- packages/stylo_taffy/src/convert.rs | 20 +- position_plan.md | 454 ++++++++++++++++++++++++ 8 files changed, 680 insertions(+), 12 deletions(-) create mode 100644 position_plan.md diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index f78a576dc..620797415 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -1093,7 +1093,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 +1104,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())?; diff --git a/packages/blitz-dom/src/layout/damage.rs b/packages/blitz-dom/src/layout/damage.rs index d89010b42..90b6aae55 100644 --- a/packages/blitz-dom/src/layout/damage.rs +++ b/packages/blitz-dom/src/layout/damage.rs @@ -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, diff --git a/packages/blitz-dom/src/layout/inline.rs b/packages/blitz-dom/src/layout/inline.rs index 8c63499c4..c21f19120 100644 --- a/packages/blitz-dom/src/layout/inline.rs +++ b/packages/blitz-dom/src/layout/inline.rs @@ -597,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; diff --git a/packages/blitz-dom/src/node/node.rs b/packages/blitz-dom/src/node/node.rs index 51b373317..57fd01741 100644 --- a/packages/blitz-dom/src/node/node.rs +++ b/packages/blitz-dom/src/node/node.rs @@ -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. @@ -175,6 +177,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), @@ -856,12 +859,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 diff --git a/packages/blitz-dom/src/resolve.rs b/packages/blitz-dom/src/resolve.rs index 638cafca6..6474e10fb 100644 --- a/packages/blitz-dom/src/resolve.rs +++ b/packages/blitz-dom/src/resolve.rs @@ -70,6 +70,11 @@ 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"); @@ -202,6 +207,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); diff --git a/packages/blitz-paint/src/render.rs b/packages/blitz-paint/src/render.rs index 6b8b6f808..6281a8d3c 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,19 @@ 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; + } + } let taffy::Layout { size, border, 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 From 500d1d5e030458872382b3a92d136766c3498c50 Mon Sep 17 00:00:00 2001 From: Jonathan Kelley Date: Fri, 20 Mar 2026 13:19:18 -0700 Subject: [PATCH 13/14] move test files, slightly broken sticky --- examples/assets/reddit-astral.html | 665 ------------------ packages/blitz-dom/src/document.rs | 13 +- packages/blitz-dom/src/layout/damage.rs | 7 +- packages/blitz-dom/src/node/node.rs | 16 +- packages/blitz-dom/src/resolve.rs | 194 +++++ packages/blitz-paint/src/render.rs | 7 + .../align_content_block_test.html | 0 .../assets => tests}/box_model_test.html | 0 .../assets => tests}/calc_sizing_test.html | 0 {examples/assets => tests}/dioxus_footer.html | 0 {examples/assets => tests}/dioxus_topnav.html | 0 {examples/assets => tests}/display_test.html | 0 {examples/assets => tests}/dots_test.html | 0 {examples/assets => tests}/flexbox_test.html | 0 {examples/assets => tests}/float_test.html | 0 {examples/assets => tests}/grid_test.html | 0 .../inline_baseline_test.html | 0 .../inline_block_heights_test.html | 0 .../assets => tests}/inline_span_test.html | 0 .../assets => tests}/kitchen_sink_test.html | 0 .../replaced_baseline_test.html | 0 {examples/assets => tests}/sizing_test.html | 0 {examples/assets => tests}/strut_test.html | 0 tests/stylo_usage.rs | 160 ----- tests/test_sticky.html | 363 ++++++++++ .../assets => tests}/text_layout_test.html | 0 .../assets => tests}/valign_100_test.html | 0 .../valign_lineheight_test.html | 0 .../assets => tests}/valign_percent_test.html | 0 {examples/assets => tests}/valign_test.html | 0 30 files changed, 592 insertions(+), 833 deletions(-) delete mode 100644 examples/assets/reddit-astral.html rename {examples/assets => tests}/align_content_block_test.html (100%) rename {examples/assets => tests}/box_model_test.html (100%) rename {examples/assets => tests}/calc_sizing_test.html (100%) rename {examples/assets => tests}/dioxus_footer.html (100%) rename {examples/assets => tests}/dioxus_topnav.html (100%) rename {examples/assets => tests}/display_test.html (100%) rename {examples/assets => tests}/dots_test.html (100%) rename {examples/assets => tests}/flexbox_test.html (100%) rename {examples/assets => tests}/float_test.html (100%) rename {examples/assets => tests}/grid_test.html (100%) rename {examples/assets => tests}/inline_baseline_test.html (100%) rename {examples/assets => tests}/inline_block_heights_test.html (100%) rename {examples/assets => tests}/inline_span_test.html (100%) rename {examples/assets => tests}/kitchen_sink_test.html (100%) rename {examples/assets => tests}/replaced_baseline_test.html (100%) rename {examples/assets => tests}/sizing_test.html (100%) rename {examples/assets => tests}/strut_test.html (100%) delete mode 100644 tests/stylo_usage.rs create mode 100644 tests/test_sticky.html rename {examples/assets => tests}/text_layout_test.html (100%) rename {examples/assets => tests}/valign_100_test.html (100%) rename {examples/assets => tests}/valign_lineheight_test.html (100%) rename {examples/assets => tests}/valign_percent_test.html (100%) rename {examples/assets => tests}/valign_test.html (100%) diff --git a/examples/assets/reddit-astral.html b/examples/assets/reddit-astral.html deleted file mode 100644 index 6407981ed..000000000 --- a/examples/assets/reddit-astral.html +++ /dev/null @@ -1,665 +0,0 @@ -OpenAI to acquire Astral : Python
this post was submitted on
488 points (95% upvoted)

Python

The Python Discord

- -

News about the dynamic, interpreted, interactive, object-oriented, extensible programming language Python

- -

Upcoming Events

- -

Full Events Calendar

- -

Please read the rules

- -

You can find the rules here.

- -

If you are about to ask a "how do I do this in python" question, please try r/learnpython, the Python discord, or the #python IRC channel on Libera.chat.

- -

Please don't use URL shorteners. Reddit filters them out, so your post or comment will be lost.

- -

Posts require flair. Please use the flair selector to choose your topic.

- -

Posting code to this subreddit:

- -

Add 4 extra spaces before each line of code

- -
def fibonacci():
-    a, b = 0, 1
-    while True:
-        yield a
-        a, b = b, a + b
-
- -

Online Resources

- - - -

Online exercices

- - - -

programming challenges

- - - -

Asking Questions

- - - -

Try Python in your browser

- - - -

Docs

- - - -

Libraries

- - - -

Related subreddits

- - - -

Python jobs

- - - -

Newsletters

- - - -

Screencasts

- - -
-
a community for
×
top 200 commentsshow all 248

[–]gingimli 323 points324 points  (9 children)

Anthropic bought Bun and now OpenAI buys Astral. Who knew building a package manager would be so lucrative in 2025-26.

-
-

[–]deadwisdomgreenlet revolution 63 points64 points  (2 children)

Yeah, I wonder if this is the start of buying up open source tooling to control everything. Everyone start a tooling library! See if we can get 3rd tier companies to pay too much on a bunch of shitty scripts.

-
-

[–]gingimli 28 points29 points  (1 child)

I agree, they want to own the whole supply chain starting from “uv init” all the way to production. I have to wonder if one of them is eyeing GitLab, because that’s a relatively cheap way to own a large chunk of the supply chain.

-
-

[–]noshowthrow 6 points7 points  (0 children)

Yep. Once they buy all the open source stuff they'll start making it expensive beyond belief.

-
-

[–]critterheist 21 points22 points  (2 children)

Uh oh Pixi shit the bed

-
-

[–]pwang99 6 points7 points  (1 child)

? Pixi is fine

-
-

[–]SSX_Elise [score hidden]  (0 children)

pixi depends on uv but I do know they had their own alternative prior to shelving it in favor of uv

-
-

[–]cats_catz_kats_katz 2 points3 points  (0 children)

I’m so annoyed by all of this

-
-

[–]sebovzeoueb 1 point2 points  (0 children)

I mean, Bun is a bit more than a package manager, it's an all in one that replaces Node.js and a bunch of JS tooling.

-
-

[–]CSI_Tech_Dept [score hidden]  (0 children)

Their goal is to force people into their product.

- -

I'm pretty sure they mostly care about uv. It will have ChatGPT integration and be modified that you can disable it, but without the integration it won't be as useful.

-
-

[–]menge101 418 points419 points  (46 children)

Keep in mind, ruff and ty are MIT licensed.

- -

UV is apache2 and MIT licensed.

- -

We can fork these things if needed to stop from being trapped into anything by OpenAI.

-
-

[–]MoreRespectForQA 126 points127 points  (15 children)

This looks more like an acquihire a bit like when zoom bought keybase.

- -

As in, I doubt openai will try to monetize ruff, uv, etc. but new development will probably slow to a crawl or cease entirely as they move the devs on to other projects.

- -

If we're lucky the purchase conditions will carve out a bit of time for them to work on it, as was the case with keybase but it'll be a dribble.

-
-

[–]zupzupper 22 points23 points  (2 children)

Which was a damn shame because keybase was awesome

-
-

[–]MoreRespectForQA 9 points10 points  (1 child)

it still is awesome.

- -

it's a shame they stopped improving it but it's still running.

-
-

[–]zupzupper 6 points7 points  (0 children)

Thats true, though all my contacts bailed on it. Just a few lonely stragglers these days.

-
-

[–]wRAR_ 31 points32 points  (8 children)

-

new development will probably slow to a crawl or cease entirely as they move the devs on to other projects.

-
- -

I feel relatively fine about this because:

- -
    -
  • ruff is in a good shape and is immensely useful in the current state for any kinds of projects, and also hopefully the community can work on it successfully
  • -
  • ty isn't finished and widely adopted anyway
  • -
  • uv is widely adopted but I haven't used it that much still (mostly because it's still not packaged in Debian), OTOH as it's immensely popular probably the community would also be able to work on it?
  • -
-
-

[–]ROFLLOLSTER 42 points43 points  (6 children)

uv is definitely worth switching to, and I say that as someone who was initially quite hesitant (came from poetry).

-
-

[–]axonxorzpip'ing aint easy, especially on windows 6 points7 points  (5 children)

Here I am still using pip. What's the benefit for projects like mine with fairly uncomplicated dependencies?

-
-

[–]Stromcor 5 points6 points  (1 child)

For me it’s not about dependencies, it’s about uv being self sufficient, as in uv does not need Python to run and it manages Python versions for each projects. So no bootstrapping issue, no conflict, even venv do not need activation (most of the time), everything is neatly isolated and taken care of, including Python, without needing Python. And yes, it’s freaking fast.

-
-

[–]axonxorzpip'ing aint easy, especially on windows 2 points3 points  (0 children)

-

it’s about uv being self sufficient

-
- -

That makes perfect sense. I never understood the "fast" arguments, how much time is everyone spending managing dependencies?

-
-

[–]jesusrambo 9 points10 points  (0 children)

It’s fast as hell

- -

If you don’t need it, don’t use it

-
-

[–]JJJSchmidt_etAl 3 points4 points  (1 child)

The benefit is that you can just drop in uv without changing anything and it should still work, just a whole lot faster and with fewer commands.

-
-

[–]gerardwx 0 points1 point  (0 children)

Not quite. Doesn’t support private repos in same way as pip.

-
-

[–]catcint0s 0 points1 point  (0 children)

There is also pyx, I wonder if it will be finished.

-
-

[–]thisdude415 0 points1 point  (1 child)

I actually disagree here -- I think they will especially focus on ruff/ty to provide better error messages in Python so that they can train more effective AI agents.

-
-

[–]MoreRespectForQA 1 point2 points  (0 children)

A pull request could achieve that.

-
-

[–]CSI_Tech_Dept [score hidden]  (0 children)

If the acquisition goes through, the uv will have ChatGPT integration, and will be modified to not be very useful if you chose to not use the AI.

-
-

[–]PaintItPurple 51 points52 points  (1 child)

"Don't worry, you can just become the primary maintainer of a massive open-source project" is not that comforting to me as somebody using these projects. Realistically, I am not going to do that. My employer is not going to pay me to do that.

-
-

[–]Vresa 4 points5 points  (0 children)

I mean, the tools from astral as great because they’re well designed and fast. They aren’t nearly as large of a scope as many bedrock projects.

-
-

[–]Oct8-Danger 2 points3 points  (0 children)

Hopefully these projects join an OSS foundation like Linux foundation or other reputable one.

- -

This happened recently to sqlmesh after fivetran bought the company. I think that’s the best outcome for the community and for open ai and astral.

- -

Good PR, keeps community alive and trusting it. Trying to monetize and or close sourcing it or change in licensing never seems to pan out well. For example Redis and MinIO come to mind

-
-

[–]Eric_12345678 4 points5 points  (8 children)

Doesn't uv need a lot of remote infrastructure to work, for all the precompiled packages?

- -

Edit: not really. Thanks for the info!

-
-

[–]latkdeTuple unpacking gone wrong 17 points18 points  (1 child)

Not really. There are no “precompiled packages” other than the Wheels that package authors (≠ Astral) upload to PyPI, and the pre-built Python binaries that are built via GitHub Actions infrastructure and distributed via the Cloudflare CDN. None of this is uv-specific, and there is little Astral-controlled infrastructure.

-
-

[–]bjorneylol 10 points11 points  (0 children)

99% of the remote infrastructure needs is just PyPi for packages, the rest is just downloading build artifacts from the github repo

-
-

[–]wRAR_ 2 points3 points  (4 children)

Do you mean interpreters or does it also keep some binary wheels separately from PyPI?

-
-

[–]Eric_12345678 1 point2 points  (3 children)

Binary wheels I think? Similar to anaconda.

-
-

[–]Smallpaul 5 points6 points  (0 children)

No. uv uses pypi for that just as poetry and pip do.

-
-

[–]wRAR_ 0 points1 point  (1 child)

Do you have a link?

-
-

[–]Eric_12345678 1 point2 points  (0 children)

No, I apparently was wrong.

- -

Sorry.

-
-

[–]iaurp 318 points319 points  (18 children)

fuck

-
-

[–]Darwinmate 56 points57 points  (14 children)

fuck

-
-

[–]xAragon_ 37 points38 points  (13 children)

fuck

-
-

[–]really_not_unreal 8 points9 points  (1 child)

fuck

-
-

[–]LackingAGoodNamePythoneer 5 points6 points  (0 children)

fuck

-
-

[–]bigsassy 24 points25 points  (2 children)

aw fuck

-
-

[–]ricckyo 11 points12 points  (1 child)

fuckity fuck

-
-

[–]daddy_stool 2 points3 points  (0 children)

Frak

-
-

[–]latkdeTuple unpacking gone wrong 142 points143 points  (8 children)

oh no :'(

- -

Too be fair though, Astral's business model always seemed unclear, and an acquihire is a relatively unsurprising outcome. We've all built on Astral tooling knowing that it was unsustainable. But having the fate of these tools chained to what may be the biggest bubble in tech economy history doesn't exactly soothe my worries.

-
-

[–]wRAR_ 47 points48 points  (0 children)

-

Astral's business model always seemed unclear,

-
- -

Yeah, my second thought was "oh that's how they will monetize"

-
-

[–]MoreRespectForQA 35 points36 points  (2 children)

To be equally fair uv, ruff, etc. being abandoned is probably a better outcome than whatever plan to trap and extract money from devs they might come up with if they went on the IPO path.

-
-

[–]Smallpaul 9 points10 points  (1 child)

I don’t think IPO was ever in the cards but they could have been acquired by Red Hat or GitHub or a security vendor and their product plan might be more compatible than OpenAI.

-
-

[–]turbothyIt works on my machine 3 points4 points  (0 children)

GitHub and OpenAI are effectively the same thing in 2026.

-
-

[–]redditusername58 10 points11 points  (3 children)

Why would OpenAI need to hire developers when they have Codex?

-
-

[–]Vresa 15 points16 points  (2 children)

The folks at Astral have clearly demonstrated that they are extremely capable developers who can execute long term plans and design good tooling.

- -

Codex unseats juniors, sloppy developers, and people getting paid 6 figures to make CRUD.

- -

Extremely talented developers who can lead projects like this will always be in demand

-
-

[–]Black_Magic100 4 points5 points  (1 child)

I think you missed the sarcasm 😁

-
-

[–]Quant32 [score hidden]  (0 children)

It’s important to be said even if it is sarcasm lol fling people these days are losing any sense of nuance. Someone’s going to read the og comment and think “AI SLOP!!!”

-
-

[–]Consistent-Quiet6701 132 points133 points  (0 children)

Noooooooo

-
-

[–]masteroflich 50 points51 points  (10 children)

With what money

-
-

[–]axonxorzpip'ing aint easy, especially on windows 94 points95 points  (2 children)

Your future bailout.

-
-

[–]wunderspud7575 30 points31 points  (0 children)

Also, your 401k value reduction when they IPO and their stock plummets.

-
-

[–]PipePistoleer 5 points6 points  (0 children)

the bailout funded by the $39 trillion negative dollars in the US bank account

-
-

[–]Consistent-Quiet6701 9 points10 points  (5 children)

Nvidia or Oracle or one of the other market manipulation schemes

-
-

[–]CyclopsRock 13 points14 points  (4 children)

Nvidia isn't really like the others, though. They're not mining for gold, they're selling the shovels.

-
-

[–]VEMODMASKINEN 7 points8 points  (3 children)

How many shovel sellers were there after the gold rush had ended?

-
-

[–]CyclopsRock 3 points4 points  (1 child)

I'm not sure - people bought shovels before the rush and people still buy shovels today.

- -

My argument is not that Nvidia will always and forever have insanely high revenue driven by insanely high demand for their products. My argument is that a business whose value and cash goes up when they sell lots of stuff is not an example of market manipulation.

-
-

[–]axonxorzpip'ing aint easy, especially on windows 0 points1 point  (0 children)

-

My argument is that a business whose value and cash goes up when they sell lots of stuff is not an example of market manipulation.

-
- -

When people talk about manpulation in the AI space, I think they mean the nebulous and circular funding deals that have been made. We know NVIDIA's stock wouldn't be this high if they were "simply" selling the same price-adjusted volume in consumer GPUs and server interconnect hardware. A lot of these deals are contingent on infrastructure build-out that is completely separate from the product they're selling, but that's nobody's problem until the bag-holding party starts.

-
-

[–]mDodd 0 points1 point  (0 children)

From the Department of War, no?

-
-

[–]UltraPoci 34 points35 points  (0 children)

Time to fork it I guess

-
-

[–]farkinga 39 points40 points  (3 children)

upvoted for visibility; not because I think this is good news...

- -

I've even gotten to the point where Microsoft can purchase something like Github and I can tolerate it. But this is just next-level in terms of the dystopian role OpenAI play in our present context. What a crap development...

-
-

[–]fivetoedslothbear 4 points5 points  (2 children)

To be fair, the reaction to buying GitHub was like someone announced the Apocalypse, but we lean heavily on GitHub at work, and it's not been that bad.

-
-

[–]turbothyIt works on my machine 2 points3 points  (0 children)

Organisation-wise, GitHub has been folded in under MS AI as of August 2025. Make of that what you will.

-
-

[–]farkinga 0 points1 point  (0 children)

It totally did feel like the apocalypse - and yet somehow, this seems worse. I know, uv isn't anything like github, but now openai has a particular "ick" that just lands poorly.

- -

And btw, github probably was a bit apocalyptic insofar as they used all our code to train language models to be better coders than humans. So there's that too.

- -

This timeline, yo...

-
-

[–]EmberQuill 30 points31 points  (0 children)

Well, we had a good run.

-
-

[–]xAmorphous 23 points24 points  (4 children)

The Python foundation has the opportunity to do the funniest thing

-
-

[–]jiminiminimini 3 points4 points  (1 child)

I say fork them! uv, ty, and ruff, I mean.

-
-

[–]tehfrod [score hidden]  (0 children)

Go ahead.

-
-

[–]PipePistoleer 1 point2 points  (0 children)

diabolical

-
-

[–]tehfrod [score hidden]  (0 children)

With what dev resources?

-
-

[–]All_I_Can 32 points33 points  (0 children)

Sad news. In an ideal world, I think uv should be part of Python itself, just as Cargo is for Rust.

-
-

[–]KwpolskaNikola co-maintainer 6 points7 points  (1 child)

Congrats to everyone who adopted VC-funded Python tools not written in Python for their projects!

-
-

[–]sudomatrix 3 points4 points  (0 children)

*shrug* I adopted the best tools for the job. uv and ruff are worlds better than what came before.

-
-

[–]danted002 19 points20 points  (6 children)

I’ve read the article and there is no mention of what happens to the tools themselves. They only mention that the people working on the tools will work on Codex… so who will work on the tools?

-
-

[–]wRAR_ 21 points22 points  (2 children)

"OpenAI plans to support Astral’s open source products", "we’ll continue to support these open source projects while exploring ways they can work more seamlessly with Codex"

-
-

[–]nemec 9 points10 points  (1 child)

aka in a few months we'll reduce new investment into the tools to near zero

-
-

[–]wRAR_ 2 points3 points  (0 children)

Yup.

-
-

[–]lucas1853 7 points8 points  (1 child)

-

They only mention that the people working on the tools will work on Codex… so who will work on the tools?

-
- -

Codex.

-
-

[–]senatorium 0 points1 point  (0 children)

Judging from an email I received it looks like they might be axing their pyx product. "We'll continue supporting you as normal until the deal closes, and partner on next steps from there as we determine the long-term plan for the product." Doesn't say they're killing it but it certainly sounds wobbly.

-
-

[–]Civilanimal 20 points21 points  (1 child)

Fuuuuuuuuuuuck!

- -

It's the Microslop strategy from the 90s all over again. https://en.wikipedia.org/wiki/Embrace,_extend,_and_extinguish

-
-

[–]PipePistoleer 1 point2 points  (0 children)

this is the thing I was trying to recall but me old brain is shite at remembering

-
-

[–]wRAR_ 10 points11 points  (0 children)

That's... certainly an interesting development.

-
-

[–]ideamotor 26 points27 points  (2 children)

This was inevitable. These companies absolutely want to pull the ladder up. They don’t even want you to be able to code. They want people to have to use their products. There’s barely anything on this announcement about continuing to support open source development. Just a little hand waving note, nothing about governance or foundation involvement. Letting such primary and significant python contributing entities be VC funded or otherwise private companies that have very poor plans for funding is really gonna backfire.

-
-

[–]harttrav 0 points1 point  (0 children)

This acquisition makes me uncomfortable too but they aren’t necessarily going to pull the ladder up. The more likely outcome is that they just enshittify uv, like adding tool fields in pyproject for codex specific configuration options that ship with uv. TBD whether switching back to miniconda is worth it for me personally, though my cynical side puts a 70% probability on an intolerable level of enshittification within 5 years.

-
-

[–]myke_ 4 points5 points  (0 children)

It feels like uv has stalled a bit recently, even some basic important issues like https://github.com/astral-sh/uv/issues/8253 have seen no progress despite being upvoted.

-
-

[–]HexamonNexus 14 points15 points  (0 children)

And another reason added to the list of why I'm taking early retirement. They won't be happy until everything is ruined.

-
-

[–]chub79 6 points7 points  (0 children)

Good for them but not great for the Python ecosystem.

-
-

[–]AC1colossus 3 points4 points  (0 children)

Well shit

-
-

[–]tristan957 17 points18 points  (5 children)

I hope that the additional resources from OpenAI allow Astral to develop these tools even faster. They are the best tools in the Python ecosystem.

-
-

[–]trisul-108 18 points19 points  (0 children)

OpenAI hopes the opposite ... that Astral will allow them to develop their proprietary tools even faster.

-
-

[–]strange_norrell 12 points13 points  (1 child)

Per statement, "Astral team will join the Codex team at OpenAI" (not continue to operate separately) and "we’ll continue to support these open source projects while exploring ways they can work more seamlessly with Codex". "Continue to support" phrasing does not give me any excitement here. More like "whatever our next AI bullshit product needs, we will add first".

-
-

[–]nemec 3 points4 points  (0 children)

100%. They'll do minimal investment, probably just security fixes and some minor stuff here and there (likely driven by OpenAI's needs rather than users'), but I have zero hope of significant long term support.

-
-

[–]gerardwx 1 point2 points  (0 children)

The interpreter is the best tool in the ecosystem

-
-

[–]Smallpaul -1 points0 points  (0 children)

What makes you think that these projects will get additional resources? What would be the motivation for giving them additional resources?

-
-

[–]downerison 12 points13 points  (1 child)

Rip

-
-

[–]_redmist 25 points26 points  (0 children)

*pip

-
-

[–]dusktreader 9 points10 points  (0 children)

Fuck. No.

-
-

[–]edcculus 9 points10 points  (0 children)

Well it was fun UV and Ruff. I hope the people smarter than me can fork these tools and make other versions we can use that aren’t tied to Open AI.

-
-

[–]No_Lingonberry1201pip needs updating 6 points7 points  (0 children)

First they took mah' RAM, then they took mah' GPU, then they came for mah' SSD, but I'll be dammed if they take my uv!

-
-

[–]Competitive_Lie2628 2 points3 points  (0 children)

Guess is as good time as any to consider other languages.

- -

rip, you made starting new projects so much easier and I refuse to go back.

-
-

[–]hcmar [score hidden]  (0 children)

NOOOOO!

-
-

[–]12candycanes [score hidden]  (0 children)

Well gross. I hope that the licenses keep these going strong in the public interest.

-
-

[–]TheVincibleIronMan 8 points9 points  (0 children)

Fuck... 

-
-

[–]MarcelLecture 7 points8 points  (0 children)

Fckkkk noooo

-
-

[–]SpareIntroduction721 5 points6 points  (0 children)

There goes the good thing… wait for this shit to get locked with subscriptions now… they have to make money somehow….

- -

Can’t wait for the next “uv” alternative

-
-

[–]Reasonable_Tie_5543 5 points6 points  (0 children)

OH GOD NO

-
-

[–]pioniere 4 points5 points  (0 children)

Booo. Fuck OpenAI.

-
-

[–]Giddius 4 points5 points  (0 children)

Hahahahahhahahhahah

- -

It was so fucking inevitable.

- -

Please we need an actual law like murphys law, that says „if there is python packaging system that has large scale adoption by the community, it will shoot itself in the knee and make the packaging situation actually worse“

-
-

[–]Aggressive-Prior4459 1 point2 points  (0 children)

I have really liked astral's work on uv and ruff. This OpenAI acquisition feels a bit off to me. I hope it doesn't change what made their tools good!

-
-

[–]firefrommoonlight 1 point2 points  (3 children)

Would there be any interest in me fixing the bugs in Pyflow and getting it updated to install newer python versions? It's almost identical to uv in concept, but I haven't touched it in 6 years.

- -

Astral has demonstrated that there is desire for this sort of "just works" thing, which I struggled with, and led me to abandoning it. (I.e.: "pip/venv/conda/poetry are fine, why do I want this?", despite my personal experience with those as high-friction)

-
-

[–]max123246 1 point2 points  (1 child)

It might be easier to fork uv and help maintain it instead. We need our efforts to be concentrated, not split across a bunch of different tooling

-
-

[–]firefrommoonlight 0 points1 point  (0 children)

-

not split

-
- -

This is the core problem / tragedy of the commons scenario. You could also ask why Astral made UV instead of forking and patching PyFlow.

-
-

[–]holy_macanoli 0 points1 point  (0 children)

Yes please.

-
-

[–]sudomatrix 1 point2 points  (1 child)

-$ uv init -I noticed you're setting up a new Python project. If you describe it in a paragraph I can write it for you to get you started. -

-
-

[–]l_dang [score hidden]  (0 children)

my eyesss

-
-

[–]xeow [score hidden]  (0 children)

Man, I just started using uv and ty a couple months ago and really like them both. I don't plan to stop using them unless/until something better comes along. Sucks that OpenAI is pulling the Astral devs off these projects, but we don't know yet what's going to happen. Maybe the core Astral people will quit in disgust and fork the tools. (I mean, I doubt it, but it's possible.) I guess the tools' future depends on how much $$$ OpenAI is throwing at the core devs and whether they allow them to work on the Astral tools as much as they'd like to, without being forced to work on Codex stuff too much. In any case, I'm just glad and grateful that uv and the other big Astral tools are open-source and that the community can pick up the pieces if things start falling apart. uv is a total game-changer for the Python ecosystem and is too important to let it languish.

- -

Question: Does uv have a plugin system like git does? Is it possible to extend its functionality without forking it?

-
-

[–]NGTTwo 4 points5 points  (0 children)

God-fucking-dammit.

- -

I so can't wait for all this generative AI idiocy to wind up in the dumpster of stupid tech ideas alongside NFTs and SOAP.

-
-

[–]martin7274 1 point2 points  (0 children)

Oops, we ran out of money, just like Bun

- -

- Astral Founders

-
-

[–]thuiop1 2 points3 points  (0 children)

Well, shit. This is so fucking annoying. AI companies really are there to fuck up everything good in this world.

-
-

[–]_redmist 7 points8 points  (19 children)

Kinda glad i stuck with venv/pip now ngl.

-
-

[–]cinicDiver 5 points6 points  (4 children)

Hahaha, funny thing is I was just writing some Python tutorials for my company and said:

- -

"we can work just fine with venv, theres uv but no need to overcomplicate things".

-
-

[–]max123246 1 point2 points  (0 children)

I was literally promoting uv at my company because the UX is far better

-
-

[–]Veggies-are-okay 3 points4 points  (2 children)

It’s funny because imo using base venv does overcomplicate things. I can propagate my testing, limiting, formatting, and type checking into my CI with a simple “COPY puproject.toml” and “uv sync —dev”. I can manage subsets of packages via “uv add <package> —group <xyz>. I can specify all my configurations for each of these, and dependency tracking is a thing of the past. No need to find the needle in the haystack of that one slightly out of date dependency or the chain that’s slightly conflicting as uv fixes all of it.

- -

Like the learning curve is so straightforward that it took maybe 30min to get the basics down and another 30 to switch out poetry.

- -

I honestly would rather have uv be acquired by OpenAI than just abandoned because of lack of funding. In the former at least we don’t have to go back to poetry or shudders pip…

-
-

[–]KwpolskaNikola co-maintainer 0 points1 point  (1 child)

uv might be easier to use than plain venv, but at the same time, it adds complexity by insisting on managing Pythons on its own.

-
-

[–]diegoasecas 1 point2 points  (0 children)

are you kidding? that's its best feature

-
-

[–]diegoasecas -1 points0 points  (12 children)

how does this affect you in any way

-
-

[–]_redmist 5 points6 points  (11 children)

It affects the ecosystem; not me directly.

- -

The greatest lesson out of tech the past few years is that you must never hop onto the next cool thing because the finance bros will turn it to sh*t right away. -This makes me somewhat sad. Maybe that is how i am affected. 

- -

Thank you for asking.

-
-

[–]AlpacaDC 0 points1 point  (0 children)

Kinda glad I can lock my uv version ngl.

-
-

[–]sweetbeems 3 points4 points  (0 children)

So they’re going to add codex to my freakin’ linter?? Sounds GREAT 🫠

-
-

[–]VEMODMASKINEN 4 points5 points  (4 children)

Lol, Astral's tools made Python tolerable. I'll just invest 100% of my time in Go instead.

-
-

[–]AtlAWSConsultant -3 points-2 points  (3 children)

I wonder if this might cause more people to move to Go.

-
-

[–]ebits21 0 points1 point  (0 children)

I don’t think I can go back to pre uv to be honest.

- -

I won’t switch fully but would definitely consider another language where I can more often.

-
-

[–]updated_at 3 points4 points  (7 children)

yeah, going back to poetry and black

-
-

[–]AlpacaDC 4 points5 points  (6 children)

You can just lock uv’s, ruff’s and ty’s version you know.

-
-

[–]gingimli 7 points8 points  (5 children)

Until the security team comes calling you’re using tooling with CVEs that will never get fixed unless you upgrade or switch to something else.

-
-

[–]AlpacaDC 2 points3 points  (2 children)

I’m sure someone will fork it and keep it up to date if it comes to that.

-
-

[–]gingimli 5 points6 points  (1 child)

Hopefully! That plan worked out well for opentofu vs terraform

-
-

[–]syklemil 1 point2 points  (0 children)

Also opensearch vs elasticsearch, valkey vs redis. There's a history of companies trying to do stupid things with open source software, but also a history of people just creating a fork which grows until the company reconsiders.

-
-

[–]ThiefMaster 1 point2 points  (1 child)

If your security team pesters you about "vulnerabilities" in your dev tooling, then there's a good chance that your security team sucks. There are only few areas in dev tooling where bugs are actually vulnerabilities, when used on trusted code and not caring about ReDoS and the likes.

- -

One example that comes to my mind would be a package manager writing outside the package's installation folder. But besides that...not much danger in this type of tool.

-
-

[–]Deux87 4 points5 points  (8 children)

So so, good that I didn't switch completely to uv

-
-

[–]FitBoog 17 points18 points  (7 children)

uv is here to stay, if they choose to be evil about uv people will fork it. People will not tolerate go back to pip + 8 other tools.

-
-

[–]PaintItPurple 0 points1 point  (0 children)

Very few times in history has this "if if goes bad, fork it" approach actually worked. LibreOffice is a very clear example of that working, but most software just dies a slow death until people just stopped using it in favor or something else that was actively developed.

-
-

[–]chub79 [score hidden]  (0 children)

I could easily replace uv with pdm personally. ruff is a more difficult one because I'm really used to its speed. (uv's speed never was a major benefit to me because I don't run uv as often).

-
-

[–]_OMGTheyKilledKenny_ 2 points3 points  (0 children)

It was good while it lasted but this was a predictable outcome.

-
-

[–]GreatBigBagOfNope 2 points3 points  (0 children)

Ah shit

-
-

[–]AlpacaDC 2 points3 points  (0 children)

Happy for the Astral team, sad for us

-
-

[–]KimPeek 2 points3 points  (0 children)

Laughs in pip

-
-

[–]-LeopardShark- 2 points3 points  (0 children)

There were always questions about the funding model, but I trusted them nonetheless.

- -

What a betrayal, especially given how acutely awfully OpenAI has behaved recently.

-
-

[–]cellularcone 3 points4 points  (1 child)

I thought there was nothing to worry about and everyone should use UV because rust makes the internet faster or something.

-
-

[–]mmmboppe -1 points0 points  (0 children)

safer, not faster!

- -

the irony is that an useful tool written in a safe language just became socially unsafe to be used

-
-

[–]WowSoHuTao 1 point2 points  (0 children)

Here is our AI powered super fast intelligent pkg manager!!!1!1

-
-

[–]HugeCannoli 1 point2 points  (0 children)

and here is finally the core of their business model unfolded.
-Get acquired, then fuck off with the money.

-
-

[–]Ok-Selection-2227 1 point2 points  (1 child)

I've never been a big fan. There are other tools that work fine for me. Now I have another reason for not using ruff and uv.

-
-

[–]max123246 1 point2 points  (0 children)

Any suggestions?

-
-

[–]rcap107 2 points3 points  (0 children)

Well that sucks.

-
-

[–]aspublic 0 points1 point  (0 children)

Acquisition might focus more on acquiring talent to strengthen applied machine learning and research teams rather than software.

-
-

[–]gordinmitya 0 points1 point  (0 children)

codex can’t work with uv

-
-

[–]nekokattt [score hidden]  (0 children)

Silly question but what is stopping a community fork similar to OpenTofu?

- -

UV currently has both MIT and Apache licenses attached to it.

-
-

[–]vexatious-big [score hidden]  (1 child)

For alternatives:

- -
    -
  • Poetry is a very solid package manager, very fast.
  • -
  • Pyright still yields better results than Ty for me. I.e. Ty can't properly figure out types inside a lambda function.
  • -
  • The Black formatter is still developed and relevant.
  • -
- -

We'll be fine.

-
-

[–]chub79 [score hidden]  (0 children)

Same but with pdm instead of poetry.

-
-

[–]Worldly_Dish_48 [score hidden]  (0 children)

Implications?

-
-

[–]epiecs 1 point2 points  (0 children)

oh ffs

-
-

[–]xrabbit 0 points1 point  (0 children)

RIP

-
-

[–]Looploop420 0 points1 point  (0 children)

FUCK

-
-

[–]roastedfunction 0 points1 point  (0 children)

This was entirely predictable. Would love to see all these projects forked as soon as the rug pull comes or development is abandoned. Maybe PyPA can take these projects on or steward them?

-
-

[–]Chemical-Fault-7331 0 points1 point  (3 children)

I swear, every good thing that gets developed, they always sell out. My god. Can there not be a single company that doesn’t sell out?

-
-

[–]wRAR_ 4 points5 points  (0 children)

They needed money, where would have they got them?

-
-

[–]max123246 1 point2 points  (1 child)

To be fair, open source tooling isn't a way to make money. This was always going to happen. I just wish it wasn't OpenAI and could've been a company that has a stake in improving Python's ecosystem

-
-

[–]Chemical-Fault-7331 [score hidden]  (0 children)

How does Python make money?

-
-

[–]gromain 0 points1 point  (0 children)

Ah fuck. Here goes a good thing.

-
-

[–]skool_101git push -f 0 points1 point  (0 children)

guess we are cooked now

-
-

[–]me_myself_ai 0 points1 point  (0 children)

WTAF

-
-

[–]iengmind 0 points1 point  (0 children)

Aw fuck, somebody will fork ruff and uv right?

-
-

[–]zangler 0 points1 point  (0 children)

Uhhhh...

-
-

[–]TemporaryAble8826 0 points1 point  (0 children)

I get it, I really do. But these companies buying up all these massive open source tools and the teams behind them is so concerning.

-
-

[–]gautiexe 0 points1 point  (0 children)

God fing dammned

-
-

[–]fiery_prometheus 0 points1 point  (0 children)

Welp, there goes my tooling, time to find another package manager and linter. Zuban looks nice for a linter, and maybe poetry could be modernized as a package manager.

-
-

[–]gcavalcante8808 -1 points0 points  (0 children)

nooooooo

-
-

[–]ebits21 -1 points0 points  (0 children)

Ughhhhhhh sorry Python maybe it’s time for me to move on…..

-
-

[–]slcpnk -1 points0 points  (0 children)

no god please no

-
-

[–]levelstar01 -1 points0 points  (0 children)

Hahahahaha. I KNEW I was right to never trust this

-
-

π Rendered by PID 102307 on reddit-service-r2-loggedout-544bd98c-plfm8 at 2026-03-19 18:56:33.695335+00:00 running 90f1150 country code: US.

\ No newline at end of file diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index 620797415..2b167b8a8 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -265,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. @@ -407,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(), @@ -1532,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 @@ -1562,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/layout/damage.rs b/packages/blitz-dom/src/layout/damage.rs index 90b6aae55..6c7918af5 100644 --- a/packages/blitz-dom/src/layout/damage.rs +++ b/packages/blitz-dom/src/layout/damage.rs @@ -562,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/node/node.rs b/packages/blitz-dom/src/node/node.rs index 57fd01741..c0c3abfcd 100644 --- a/packages/blitz-dom/src/node/node.rs +++ b/packages/blitz-dom/src/node/node.rs @@ -126,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 {} @@ -186,6 +189,7 @@ impl Node { unrounded_layout: Layout::new(), final_layout: Layout::new(), scroll_offset: crate::Point::ZERO, + sticky_offset: crate::Point::ZERO, } } @@ -900,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 @@ -1063,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/resolve.rs b/packages/blitz-dom/src/resolve.rs index 6474e10fb..a7068be35 100644 --- a/packages/blitz-dom/src/resolve.rs +++ b/packages/blitz-dom/src/resolve.rs @@ -80,10 +80,12 @@ impl BaseDocument { // 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 @@ -396,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(); @@ -415,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-paint/src/render.rs b/packages/blitz-paint/src/render.rs index 6281a8d3c..35b217a49 100644 --- a/packages/blitz-paint/src/render.rs +++ b/packages/blitz-paint/src/render.rs @@ -252,6 +252,13 @@ impl<'dom> BlitzDomPainter<'dom> { 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, diff --git a/examples/assets/align_content_block_test.html b/tests/align_content_block_test.html similarity index 100% rename from examples/assets/align_content_block_test.html rename to tests/align_content_block_test.html diff --git a/examples/assets/box_model_test.html b/tests/box_model_test.html similarity index 100% rename from examples/assets/box_model_test.html rename to tests/box_model_test.html diff --git a/examples/assets/calc_sizing_test.html b/tests/calc_sizing_test.html similarity index 100% rename from examples/assets/calc_sizing_test.html rename to tests/calc_sizing_test.html diff --git a/examples/assets/dioxus_footer.html b/tests/dioxus_footer.html similarity index 100% rename from examples/assets/dioxus_footer.html rename to tests/dioxus_footer.html diff --git a/examples/assets/dioxus_topnav.html b/tests/dioxus_topnav.html similarity index 100% rename from examples/assets/dioxus_topnav.html rename to tests/dioxus_topnav.html diff --git a/examples/assets/display_test.html b/tests/display_test.html similarity index 100% rename from examples/assets/display_test.html rename to tests/display_test.html diff --git a/examples/assets/dots_test.html b/tests/dots_test.html similarity index 100% rename from examples/assets/dots_test.html rename to tests/dots_test.html diff --git a/examples/assets/flexbox_test.html b/tests/flexbox_test.html similarity index 100% rename from examples/assets/flexbox_test.html rename to tests/flexbox_test.html diff --git a/examples/assets/float_test.html b/tests/float_test.html similarity index 100% rename from examples/assets/float_test.html rename to tests/float_test.html diff --git a/examples/assets/grid_test.html b/tests/grid_test.html similarity index 100% rename from examples/assets/grid_test.html rename to tests/grid_test.html diff --git a/examples/assets/inline_baseline_test.html b/tests/inline_baseline_test.html similarity index 100% rename from examples/assets/inline_baseline_test.html rename to tests/inline_baseline_test.html diff --git a/examples/assets/inline_block_heights_test.html b/tests/inline_block_heights_test.html similarity index 100% rename from examples/assets/inline_block_heights_test.html rename to tests/inline_block_heights_test.html diff --git a/examples/assets/inline_span_test.html b/tests/inline_span_test.html similarity index 100% rename from examples/assets/inline_span_test.html rename to tests/inline_span_test.html diff --git a/examples/assets/kitchen_sink_test.html b/tests/kitchen_sink_test.html similarity index 100% rename from examples/assets/kitchen_sink_test.html rename to tests/kitchen_sink_test.html diff --git a/examples/assets/replaced_baseline_test.html b/tests/replaced_baseline_test.html similarity index 100% rename from examples/assets/replaced_baseline_test.html rename to tests/replaced_baseline_test.html diff --git a/examples/assets/sizing_test.html b/tests/sizing_test.html similarity index 100% rename from examples/assets/sizing_test.html rename to tests/sizing_test.html diff --git a/examples/assets/strut_test.html b/tests/strut_test.html similarity index 100% rename from examples/assets/strut_test.html rename to tests/strut_test.html 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/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/examples/assets/text_layout_test.html b/tests/text_layout_test.html similarity index 100% rename from examples/assets/text_layout_test.html rename to tests/text_layout_test.html diff --git a/examples/assets/valign_100_test.html b/tests/valign_100_test.html similarity index 100% rename from examples/assets/valign_100_test.html rename to tests/valign_100_test.html diff --git a/examples/assets/valign_lineheight_test.html b/tests/valign_lineheight_test.html similarity index 100% rename from examples/assets/valign_lineheight_test.html rename to tests/valign_lineheight_test.html diff --git a/examples/assets/valign_percent_test.html b/tests/valign_percent_test.html similarity index 100% rename from examples/assets/valign_percent_test.html rename to tests/valign_percent_test.html diff --git a/examples/assets/valign_test.html b/tests/valign_test.html similarity index 100% rename from examples/assets/valign_test.html rename to tests/valign_test.html From ac080269953deaf09851fe290b35a992b1b3b48a Mon Sep 17 00:00:00 2001 From: Jonathan Kelley Date: Fri, 20 Mar 2026 13:20:51 -0700 Subject: [PATCH 14/14] move more tests --- {examples/assets => tests}/bbc_dots_test.html | 0 {examples/assets => tests}/bbc_nav.html | 0 {examples/assets => tests}/dioxuslabs.html | 0 {examples/assets => tests}/hfull_debug.html | 0 {examples/assets => tests}/line_box_expansion_test.html | 0 {examples/assets => tests}/line_height_debug.html | 0 {examples/assets => tests}/multiline_baseline_test.html | 0 {examples/assets => tests}/nav_test.html | 0 {examples/assets => tests}/nested_inline_block_test.html | 0 {examples/assets => tests}/overflow_baseline_test.html | 0 {examples/assets => tests}/overflow_case6_debug.html | 0 {examples/assets => tests}/overflow_hidden_baseline_debug.html | 0 {examples/assets => tests}/overflow_test.html | 0 {examples/assets => tests}/positioning_test.html | 0 {examples/assets => tests}/table_test.html | 0 15 files changed, 0 insertions(+), 0 deletions(-) rename {examples/assets => tests}/bbc_dots_test.html (100%) rename {examples/assets => tests}/bbc_nav.html (100%) rename {examples/assets => tests}/dioxuslabs.html (100%) rename {examples/assets => tests}/hfull_debug.html (100%) rename {examples/assets => tests}/line_box_expansion_test.html (100%) rename {examples/assets => tests}/line_height_debug.html (100%) rename {examples/assets => tests}/multiline_baseline_test.html (100%) rename {examples/assets => tests}/nav_test.html (100%) rename {examples/assets => tests}/nested_inline_block_test.html (100%) rename {examples/assets => tests}/overflow_baseline_test.html (100%) rename {examples/assets => tests}/overflow_case6_debug.html (100%) rename {examples/assets => tests}/overflow_hidden_baseline_debug.html (100%) rename {examples/assets => tests}/overflow_test.html (100%) rename {examples/assets => tests}/positioning_test.html (100%) rename {examples/assets => tests}/table_test.html (100%) diff --git a/examples/assets/bbc_dots_test.html b/tests/bbc_dots_test.html similarity index 100% rename from examples/assets/bbc_dots_test.html rename to tests/bbc_dots_test.html diff --git a/examples/assets/bbc_nav.html b/tests/bbc_nav.html similarity index 100% rename from examples/assets/bbc_nav.html rename to tests/bbc_nav.html diff --git a/examples/assets/dioxuslabs.html b/tests/dioxuslabs.html similarity index 100% rename from examples/assets/dioxuslabs.html rename to tests/dioxuslabs.html diff --git a/examples/assets/hfull_debug.html b/tests/hfull_debug.html similarity index 100% rename from examples/assets/hfull_debug.html rename to tests/hfull_debug.html diff --git a/examples/assets/line_box_expansion_test.html b/tests/line_box_expansion_test.html similarity index 100% rename from examples/assets/line_box_expansion_test.html rename to tests/line_box_expansion_test.html diff --git a/examples/assets/line_height_debug.html b/tests/line_height_debug.html similarity index 100% rename from examples/assets/line_height_debug.html rename to tests/line_height_debug.html diff --git a/examples/assets/multiline_baseline_test.html b/tests/multiline_baseline_test.html similarity index 100% rename from examples/assets/multiline_baseline_test.html rename to tests/multiline_baseline_test.html diff --git a/examples/assets/nav_test.html b/tests/nav_test.html similarity index 100% rename from examples/assets/nav_test.html rename to tests/nav_test.html diff --git a/examples/assets/nested_inline_block_test.html b/tests/nested_inline_block_test.html similarity index 100% rename from examples/assets/nested_inline_block_test.html rename to tests/nested_inline_block_test.html diff --git a/examples/assets/overflow_baseline_test.html b/tests/overflow_baseline_test.html similarity index 100% rename from examples/assets/overflow_baseline_test.html rename to tests/overflow_baseline_test.html diff --git a/examples/assets/overflow_case6_debug.html b/tests/overflow_case6_debug.html similarity index 100% rename from examples/assets/overflow_case6_debug.html rename to tests/overflow_case6_debug.html diff --git a/examples/assets/overflow_hidden_baseline_debug.html b/tests/overflow_hidden_baseline_debug.html similarity index 100% rename from examples/assets/overflow_hidden_baseline_debug.html rename to tests/overflow_hidden_baseline_debug.html diff --git a/examples/assets/overflow_test.html b/tests/overflow_test.html similarity index 100% rename from examples/assets/overflow_test.html rename to tests/overflow_test.html diff --git a/examples/assets/positioning_test.html b/tests/positioning_test.html similarity index 100% rename from examples/assets/positioning_test.html rename to tests/positioning_test.html diff --git a/examples/assets/table_test.html b/tests/table_test.html similarity index 100% rename from examples/assets/table_test.html rename to tests/table_test.html