Skip to content

css: apply @layer block rules to the cascade#2719

Open
navidemad wants to merge 1 commit into
lightpanda-io:mainfrom
navidemad:css-layer-cascade
Open

css: apply @layer block rules to the cascade#2719
navidemad wants to merge 1 commit into
lightpanda-io:mainfrom
navidemad:css-layer-cascade

Conversation

@navidemad

@navidemad navidemad commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

What

Style rules wrapped in CSS Cascade Layers blocks (@layer name { … }, anonymous @layer { … }, dotted sub-layer names like @layer theme.dark { … }) never participated in the cascade. A display: none inside @layer left the element reporting checkVisibility() === true and computed display === "block", where Chrome reports it hidden. This PR flattens @layer block contents into the cascade the same way a matching @media body is applied today, on both stylesheet paths (<style> text and cssRules via insertRule/replaceSync), recursing into nested @media/@layer in both directions. The statement form (@layer a, b;) declares ordering only and stays inert.

Closes #2718.

Root cause

StyleManager.parseSheet handled .style rules and recursed into @media only. @layer fell into the silent else => {} arm on the _css_rules path, and was skipped by the keyword check on the <style> text path. On the CSSOM side, atRuleTypeFor had no layer mapping, so cssRules-inserted layer rules degraded to .unknown and were unrecognizable to the cascade pass even if it had wanted to recurse.

flowchart LR
    A[stylesheet parsed] --> B[style rules added]
    A --> C[media blocks recursed]
    A --> D[layer blocks hit else arm]
    D --> E[layered rules vanish from cascade]
    style D fill:#fdd,color:#000
    style E fill:#fdd,color:#000
Loading

Fix

  • src/browser/StyleManager.zig: in parseSheet, dispatch @layer on both paths. A new applyLayerAtRule flattens the block body (the statement form has no block, so it's a no-op). Two helpers are now shared with the existing @media path: applyInnerRules (the nested-rule loop) and atRuleBlock (the comment-aware block-boundary scan), so @media-in-@layer and @layer-in-@media both work under the shared depth cap (MAX_MEDIA_NESTING generalized to MAX_AT_RULE_NESTING). The mutually-recursive group carries an explicit Allocator.Error error set, since inferred sets can't resolve the cycle.
  • src/browser/webapi/css/CSSRule.zig: new .layer variant in Type; getType() returns 0 for it (CSSOM §6.4.1: @layer postdates the legacy numeric constants, matching Chrome's CSSLayerBlockRule). The variant sits after the last legacy-numbered type, so no existing rule.type value shifts.
  • src/browser/webapi/css/CSSStyleSheet.zig: atRuleTypeFor maps layer to .layer.
flowchart LR
    A[stylesheet parsed] --> B[style rules added]
    A --> C[media blocks recursed]
    A --> D[layer blocks flattened]
    D --> E[layered rules join cascade]
    style D fill:#dfd,color:#000
    style E fill:#dfd,color:#000
Loading

Deliberate scope

Layers are flattened. Full layer-priority ordering (css-cascade-5 §6.4, where earlier-declared layers lose to later ones regardless of specificity) is not implemented: ties keep breaking on specificity + document order, and the layer-name prelude is not retained. The gap this leaves is narrow. StyleManager's cascade only tracks the visibility filter (display, visibility, opacity, pointer-events), so a missing inter-layer priority can only matter when two of those declarations collide across layers with inverted specificity, and it never touches getComputedStyle for any other property. Flattening already fixes the common failure mode, layered rules vanishing entirely (Tailwind v4, for instance, emits its whole compiled output inside @layer). Proper inter-layer ordering can follow separately if a real page needs it.

Test

  • Unit test: src/browser/webapi/css/CSSStyleSheet.zigtest "WebApi: layer @-rule cascade" runs the new fixture src/browser/tests/css/layer_at_rule_cascade.html (named/anonymous/dotted blocks, statement form, @media-in-@layer matching and non-matching, @layer-in-@media, @layer-in-@layer, !important, visibility: hidden, insertRule/replaceSync paths, rule.type === 0, 50-deep nesting cap). Every assertion targets a rule that only reaches the cascade once @layer is flattened, so the fixture is meaningless without the fix.
  • Suite: zig build test on this branch passes 978/979, including the new test. The single failure (WebApi: Frames, an iframe-over-HTTP test) is pre-existing on main — clean main is 977/978 with the same failure — and isn't touched by this PR, which only changes CSS files.
  • Reproducer: issue css: @layer block rules are dropped from the cascade (checkVisibility / getComputedStyle report Tailwind v4 hidden elements as visible) #2718 carries a standalone CDP repro (one HTML fixture plus a small Node driver, no Ruby) that shows the same gap at the protocol level: checkVisibility() returns true for a display:none rule inside @layer utilities, where the identical unlayered rule returns false.

Notes

src/browser/tests/css/media_at_rule_cascade.html has a one-line comment touch-up (MAX_MEDIA_NESTINGMAX_AT_RULE_NESTING) tracking the constant rename.

Rules wrapped in @layer blocks (named, dotted sub-layer names, or
anonymous) were dropped from the cascade: StyleManager.parseSheet applied
top-level style rules and recursed into @media only, so @layer fell into
the silent else arm on both the _css_rules path and the <style> text path.
@layer blocks now flatten into the cascade like a matching @media body,
recursing into nested @media/@layer in both directions under the shared
MAX_AT_RULE_NESTING depth cap. The statement form (@layer a, b;) declares
ordering only and stays inert. Layer-priority ordering (css-cascade-5
section 6.4) is deliberately out of scope for this change: flattening with
the existing specificity + source-order tie-breaking fixes the common
failure mode of layered rules vanishing entirely (e.g. Tailwind v4 emits
its whole compiled output inside @layer, so .hidden { display: none }
never applied and checkVisibility()/getComputedStyle() reported hidden
elements as visible).

cssRules-inserted layer rules (insertRule/replaceSync) get their own
CSSRule.Type variant so the cascade pass can recognize them; their
JS-visible type stays 0 per CSSOM section 6.4.1 (no legacy code assigned),
matching Chrome's CSSLayerBlockRule.

Closes lightpanda-io#2718
@navidemad navidemad force-pushed the css-layer-cascade branch from 7d47a8e to 2ed49ec Compare June 22, 2026 22:33
@navidemad

Copy link
Copy Markdown
Contributor Author

Bumping this since it's been quiet for a few weeks. The impact keeps getting more common: Tailwind v4 emits most of its utilities inside @layer, so on any Tailwind-v4 page an element hidden via a layered display: none still reports checkVisibility() === true, and automation clients end up acting on elements the page actually hides.

The branch is still conflict-free against main. Happy to rebase onto the recent StyleManager/computed-style work (#2836, #2864) and re-verify, or rework the approach if you'd prefer a different shape.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

css: @layer block rules are dropped from the cascade (checkVisibility / getComputedStyle report Tailwind v4 hidden elements as visible)

1 participant