From 94081fdedf91f6a049bb890eef6b28a0ec330b96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sun, 27 Sep 2026 22:53:45 +0300 Subject: [PATCH 1/4] engine: root styles, var() length cache, pointer-events inheritance - Tree::mount queues class styles for a root it mounts for the first time (noteMountedRootStyle). Nothing in building the root recomputed its styles, so `:root { --x: ... }` never applied and every var(--x) below resolved to nothing: colours and paddings driven by custom properties silently fell back to their defaults. - A compiled length with var() inputs is no longer stored in the dynamic length-expression cache. A node styled before it is parented resolved the var to nothing, and that entry then answered every later resolution, layout's included. - pointer-events inherits, as in CSS: applyInheritedStyleDefaults copies it, the parent snapshot and diff carry it so incremental recompute reaches descendants, and it counts as a descendant-affecting property. A `pointer-events: none` overlay now lets hits fall through its images. Co-Authored-By: Claude Fable 5.1 --- packages/engine/ui/document.h | 7 +++++ packages/engine/ui/style.cpp | 41 +++++++++++++++++++++++++++--- packages/engine/ui/tree_render.cpp | 5 ++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/engine/ui/document.h b/packages/engine/ui/document.h index e92f69a..d238473 100644 --- a/packages/engine/ui/document.h +++ b/packages/engine/ui/document.h @@ -97,5 +97,12 @@ void endStyleMountBatch(); // presents to the panel would show unstyled content; the render path uses this to // suppress that flush until styles land. Defined in style.cpp. bool styleMountBatchActive(); +// Queues the class styles of a newly mounted root. The mounted root is what +// `:root` matches and where a stylesheet's custom properties live, but nothing +// in building it (createView + an id attribute) queues its own recompute, so +// `:root { --x: ... }` never landed and every var(--x) below resolved to +// nothing. Deferred and coalesced under an open batch, where it becomes the +// one top-most root; immediate otherwise. Defined in style.cpp. +void noteMountedRootStyle(int root); } // namespace gea::embedded::ui diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index f5f2fa6..bd10869 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -5877,10 +5877,16 @@ ResolvedCssLength resolveCompiledLengthForNodeDetailed(const CssLengthSpec &leng if (tryResolveStaticLengthExpressionCached(handle, expression, nodeId, axis, depth, cached)) return cached; // The ordinary dynamic cache keys containing-block dimensions. Font metrics // are another dependency, so never reuse that cache for font-relative input. + // A var() is a third: its value is an ancestor's custom property, and a + // node styled before it is parented (templates build children first) + // resolves to nothing under the same handle, basis and axis -- an entry + // that would then answer every later resolution, layout's included, and + // `.screen { padding: var(--safe-x) }` stayed at 0 that way. const bool fontRelative = g_fontSizeBasisNode >= 0 || lengthDependsOnFont(length, nodeId); - if (!fontRelative && tryResolveDynamicLengthExpressionCached(handle, nodeId, axis, cached)) return cached; + const bool cacheable = !fontRelative && !compiledLengthSpecHasCustomRuntimeInputs(length, depth); + if (cacheable && tryResolveDynamicLengthExpressionCached(handle, nodeId, axis, cached)) return cached; ResolvedCssLength resolved = resolveCompiledLengthExpressionForNode(expression, nodeId, axis, depth); - if (!fontRelative) storeDynamicLengthExpressionCached(handle, nodeId, axis, resolved); + if (cacheable) storeDynamicLengthExpressionCached(handle, nodeId, axis, resolved); return resolved; } @@ -9132,7 +9138,8 @@ bool propertyAffectsDescendantStyle(Property property) property == Property::LineHeight || property == Property::LineHeightExpression || property == Property::LineHeightMultiplier || property == Property::TextAlign || property == Property::TextTransform || - property == Property::WhiteSpace || property == Property::Visibility || property == Property::BorderWidth || + property == Property::WhiteSpace || property == Property::Visibility || property == Property::PointerEvents || + property == Property::BorderWidth || (property >= Property::BorderTopWidth && property <= Property::BorderLeftWidth); } @@ -9173,6 +9180,12 @@ void applyInheritedStyleDefaults(int node) #if GEA_CSS_VISIBILITY style.visibility = parentStyle.visibility; #endif +#if GEA_CSS_POINTER_EVENTS + // pointer-events inherits in CSS: `.overlay { pointer-events: none }` lets + // hits fall through its images too, which is how a decorative layer stays + // out of the way of what sits under it. + style.pointer_events = parentStyle.pointer_events; +#endif } bool applyNumberDeclarationWithSource(NodeHandle node, @@ -15850,6 +15863,11 @@ struct ParentStyleSnapshot { #else static constexpr std::uint8_t visibility = 0; #endif +#if GEA_CSS_POINTER_EVENTS + std::int8_t pointer_events; +#else + static constexpr std::int8_t pointer_events = 0; +#endif #if GEA_CSS_SIDE_BORDERS std::array border_widths; #else @@ -15887,6 +15905,9 @@ ParentStyleSnapshot snapshotParentStyle(const ComputedStyle &s) #if GEA_CSS_VISIBILITY out.visibility = s.visibility; #endif +#if GEA_CSS_POINTER_EVENTS + out.pointer_events = s.pointer_events; +#endif #if GEA_CSS_SIDE_BORDERS out.border_widths = {computedBorderWidth(s, 0), computedBorderWidth(s, 1), computedBorderWidth(s, 2), computedBorderWidth(s, 3)}; @@ -15901,7 +15922,8 @@ bool parentStylesDiffer(const ParentStyleSnapshot &a, const ParentStyleSnapshot return a.text_color != b.text_color || a.text_alpha != b.text_alpha || a.font_id != b.font_id || a.font_size != b.font_size || a.font_weight != b.font_weight || a.line_height != b.line_height || a.line_height_multiplier != b.line_height_multiplier || a.text_align != b.text_align || a.text_transform != b.text_transform || - a.white_space != b.white_space || a.visibility != b.visibility || a.border_widths != b.border_widths; + a.white_space != b.white_space || a.visibility != b.visibility || a.pointer_events != b.pointer_events || + a.border_widths != b.border_widths; } inline void listInsertUnique(CssAtomSmallList &v, CssAtomId s) @@ -17236,6 +17258,17 @@ bool styleMountBatchActive() return g_styleMountBatchActive; } +void noteMountedRootStyle(int root) +{ + auto &state = treeState(); + if (root < 0 || root >= state.nodeCount) return; + // Under the mount batch this only records the root as pending; being the + // ancestor of everything else queued, it subsumes them and the batch's + // incremental walk styles the root first, so its custom properties are in + // place before any descendant's var() is resolved. + recomputeSubtreeClassStyles(root); +} + void endStyleMountBatch() { if (!g_styleMountBatchActive) return; diff --git a/packages/engine/ui/tree_render.cpp b/packages/engine/ui/tree_render.cpp index 3b9d8cb..fce0591 100644 --- a/packages/engine/ui/tree_render.cpp +++ b/packages/engine/ui/tree_render.cpp @@ -1790,9 +1790,14 @@ namespace gea::embedded::ui return; state.refreshSerial++; + const int previousRoot = state.mountedRoot; state.mountedRoot = root; state.mountedWidth = width; state.mountedHeight = height; + // A root mounted for the first time has never had its class styles + // computed: `:root` rules (custom properties above all) apply to it and + // nowhere else. The styled re-mount at boot passes the same root again. + if (previousRoot != root) noteMountedRootStyle(root); // While the initial style batch is open, per-node class styles haven't been // applied yet, so laying out now produces UNSTYLED, content-sized boxes. That From 179f8b8833800b851a5866993ca7636acef40332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Tue, 22 Sep 2026 16:19:43 +0300 Subject: [PATCH 2/4] Style the document root on the first mount, so :root is applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Application::init publishes the viewport before the tree exists, clears the document, and only then runs the app's top level — so the style pass that actually matters is endStyleMountBatch, once the tree is built. That pass recomputes the nodes the mount marked pending, reduced to their top-most ancestors. The document root is never among them. Document creates it, not the app, so nothing marks it pending — and it is exactly what :root matches, which is where custom properties are declared. Descendants were therefore styled against a root that had no properties yet: every var() lookup missed, and a miss resolves to a hard 0, so `padding: var(--safe-top) var(--safe-x) var(--safe-bottom)` silently computed to 0 on all four sides. weather's content sat flush against both edges on every target. It looked like an iOS bug only because macOS and iOS call setViewportMetrics from their frame loops, which ends in recomputeAllClassStyles — and that walks from every parentless node, so it picks the root up and heals the miss. Android never calls it, and showed the bug plainly. Nothing was ever right on the first pass; two shells were just papering over it on the second. Pull the root in when it has never been styled, which is the first mount and nothing else: later incremental recomputes keep their narrow roots, so a theme switch does not become a whole-tree pass. Verified on iOS with the shells' second pass disabled, so the padding resolves on the mount pass alone: pad=(6,24,0,24) at dpr 1.44, i.e. the authored 4px and 17px. Co-Authored-By: Claude Opus 5 (1M context) --- packages/engine/ui/style.cpp | 38 +++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index bd10869..888ca5f 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -17269,6 +17269,21 @@ void noteMountedRootStyle(int root) recomputeSubtreeClassStyles(root); } +// Whether `node` has had its class styles computed at least once, read from +// whichever storage this build keeps the bit in (recomputeNodeClassStyles sets +// it): the custom-property reference record, the shared-style node flag, or +// the plain tracked array. The caller checks the range. +static bool classStyleTracked(int node) +{ +#if GEA_CSS_CUSTOM_PROPERTIES + return g_nodeRefs[node].tracked; +#elif GEA_EMBEDDED_SHARED_STYLES + return treeState().nodes[node].class_style_tracked; +#else + return g_nodeStyleTracked[node]; +#endif +} + void endStyleMountBatch() { if (!g_styleMountBatchActive) return; @@ -17314,7 +17329,28 @@ void endStyleMountBatch() break; } } - if (!covered) topRoots.push_back(node); + if (covered) continue; + // The document root is created by Document, not by the app, so a mount + // never marks it pending — yet it is what :root matches, and :root is + // where custom properties are declared. Recomputing the app's subtree + // without it means every descendant var() resolves against a root that + // has no properties yet: the lookup misses, and a miss latches 0 into the + // computed style (a `padding: var(--safe-x)` silently became 0). Pull the + // root in, but only while it has never been styled — that is the first + // mount. Later incremental updates keep their narrow roots, so a theme + // switch does not turn into a whole-tree recompute. + int ultimate = node; + while (state.nodes[ultimate].parent >= 0) ultimate = state.nodes[ultimate].parent; + const bool rootNeedsFirstStyle = + ultimate != node && ultimate >= 0 && ultimate < kMaxNodes && !classStyleTracked(ultimate); + if (rootNeedsFirstStyle) { + if (!g_pendingRecomputeMark.contains(ultimate)) { + g_pendingRecomputeMark.insert(ultimate); + topRoots.push_back(ultimate); + } + continue; + } + topRoots.push_back(node); } // Incremental recompute: a near-root class change (e.g. a theme switch) only From 0c9c3e95cf8002afb5c6ec30088e49ee8cfc1764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Tue, 22 Sep 2026 18:26:48 +0300 Subject: [PATCH 3/4] Stop the text-align line box from poisoning shrink-to-fit A text run alone on its line box was widened to the whole content box so text-align had room to align inside it. `start` alignment does not use that room -- it draws at the left edge either way -- but the widened box becomes the run's layout.width, and that is what the parent shrink-wraps to. Any auto-width box holding one text run therefore claimed its container's full width instead of hugging its label. In the weather example that made each `