Pandora’s box model

An experiment in containing stylesheet complexity.

This post is part of my personal notes about the benefits currently specified in Shadow DOM, but contentious and held up in committee. We’ll work it out in standards, I’m sure — but given the number of things Shadow DOM was addressing, it may still be several years until we have solutions widely implemented and deployed that solve all of them. This has me doing a lot of thought exercises about what can be done in the meantime. The following reflects one such exercise: specifically, what would it mean to solve just the styling end of this on its own. Warning: it may be mildly crazy.

boxes

The Pandora Box

CSS works really well if you can follow good patterns and have nice rich markup. It lets you define broad rules and inherit and override selectively, and if used well it cleanly decouples a separation of concerns — it’s pretty elegant actually.

On the other hand, in the real world, things are often not that simple: until pretty recently, HTML wasn’t especially expressive natively, so we worked around it — many times on our own by adding classes like “article.”  But, there wasn’t a standard. Likewise, our ideas about the patterns we should follow or best practices continues to change as we gain new powers or spend more time with the technology. Of course, there are a vast number of cases where you’ll just go and upgrade your philosophy and be the better for it, but there are a number of times when this just isn’t an option. This is sometimes referred to in standards circles, less colorfully, as “the composition problem.”

When it comes to these sorts of cases, quality and philosophy are inevitably mixed and not entirely in the control of any one team. In these sorts of cases, CSS selectors are kind of like live hand grenades, it’s just way too easy to do damage you didn’t intend to do because it requires a level of coordination of approach which is, for business reasons, highly impractical. And so you try really hard to analyze the problem, get the right select/cascade/descend/inherit/specificity, pull the pin, lob it into the pages and… hope. Frequently, all hell breaks loose. The more you mix it up, the harder it gets to reason about and the more our stylesheets explode in complexity. You’ve opened the proverbial Pandora’s Box.

You can see this in play in some simple examples. If the page begins with assumptions and a philosophy of its own and then injects markup created by another team with a differing philosophy without adopting their CSS as well, the results are frequently bad. Here’s one such (overly simplified) example in which you’ll notice that the taxonomy of classes overlap and the result is that the inner context has bits that not only themselves become unreadable but obscure the page’s (already pretty bad) content.

See the Pen OPvpZy by Brian Kardell (@bkardell) on CodePen.


If the page does include the other team’s CSS it can easily get worse, as seen in this example in which you’ll notice both ends have harmed each other to the point of complete uselessness.

See the Pen vERmdy by Brian Kardell (@bkardell) on CodePen.

At a minimum, one proposal that seems to keep coming up in various forms (most recently Shadow DOM) is to provide a way, in cases like these, for authors to isolate the styling of various pieces so that the default thing is to do no harm in either direction, thereby just simplifying the problem. For now, the platform doesn’t provide you an easy way to do that, but it does provide a way to fake it, and that might be useful. At the very least, it can help us to figure out exactly what it is we need: without data, standardization is hard and often a bad idea. So much discussion is about what we think developers will find intuitive or confusing. A better way is to know what developers understand or don’t.

Thinking about how CSS works

initial-values: All elements have all of the CSS properties from the get-go. All CSS properties have a default or initial value specified in the specification.

A screenshot of the CSS2.1 spec showing the initial value for the display property was

The initial value for each property is provided in the spec.

For simplicity, you can sort of imagine that without anything more, all elements begin their life with properties that would look to most of us like a <span>. I’m guessing this is news to a lot of long-time users of CSS because we pretty much never experience this world because browsers (“user-agents” in standards speak) come with stylesheets of their own.

user-agent stylesheets: all browsers come with a default or “user-agent” stylesheet. This is the thing that tells them how to display an element in the first place, and it does so with the same kinds of CSS rules that you and I write every day. Without it, you’d be able to see the contents of those <style> and <script> tags, your <h1> would look just like any other piece of text, and so on. You can see one such example in Firefox’s stylesheet. So, initial values plus user-agent sheet yields what you’d get if you started editing an HTML document before you started adding your own CSS (“author CSS” is what we generally call that stuff).

specificity: the CSS runtime is a rules engine — all of the rules are loaded in order and maintain a stable sort based on a concept called “specificity.” That is, each rule gets a weighted “score” according to the selector it is based on. A “*” in the selector is worth 0 “points.” A tag is worth one. A class is worth an order of magnitude more (it’s not specifically 10-based, but for illustration you can assume 10 points). An id is worth 100. So, the reason author CSS trumps the user-agent stylesheets is simply because the user-agent stylesheet has very low specificity and it came first — authors will always be either more specific or have come later.

And this is where things get sticky…

In situations like the ones described above, if you’ve got independently developed components working on a differing model or timeline, no side can really know what they’re likely to inherit, what to override, or how to do it (because specificity). Reasoning about this is really hard — many big companies or products have stylesheets that are many thousands of rules, and frequently not just one that must work with a vast array of content of varying age, quality, and philosophy.

Keep a lid on it

At some level, we can imagine that we’d like to identify a container as a special thing, let’s call it a “pandora-box” — the kind of integration point we’re talking about — and have it do just what the browser does with a fresh page by default for that container. In this model, we’d like to say “Give me a clean slate (reset the defaults and provide a user-agent-like sheet for this sort of container). Let my rules automatically trump the page within this container, by default, just like you trump the user agent. Likewise, keep my rules inside this Pandora’s Box by default.” Essentially, we’d like a new specificity context.

If you're thinking this seems a little like a Rube Goldberg machine, it's not quite so out there — but, welcome to the Web and why we need the sorts of primitives explained in the Extensible Web Manifesto

If you’re thinking this seems a little like a Rube Goldberg machine, it’s not quite so out there — but, welcome to the Web and why we need the sorts of primitives explained in the Extensible Web Manifesto.

Well, to some extent, we can kind of do this with a not-too-hard specificity strategy that removes most of the hard-core coordination concerns: we can use CSS’s own specificity rules to create an incredibly specific set of default rules, and use that as our box’s equivalent of a “default stylesheet” — basically resetting things. And that should work pretty much everywhere today.

The :not() pseudo-class counts the simple selector inside for its specificity. So, if we created an attribute called, for example, pandora-box and then picked an id that would be incredibly unlikely to exist in the wild (say #-_- because it’s not just unique but short and shows my “no comment” face), then, it would — using CSS’s own theory — provide a clean slate within the box. And, importantly, this would work everywhere today. We would wind up with a single “pandora-box stylesheet” with rules like [pandora-box] h1:not(#-_-):not(#-_-):not(#-_-) { ... }, which has a specificity of three ids, one attribute, and one tag (311 in base-10 — again CSS isn’t base-10 but it’s helpful for visualization to assign a meaningful number). This is enough to trump virtually any sane stylesheet’s most ambitious selector.

Given this, all you need is a way to shove the component’s rules into the box and shut the lid and you have a pretty easy coordination strategy. You’ve basically created a new “context.”

Essentially, this is the same pattern and model as the page has with user-agent sheets, at least mentally (initial values are still initial values and they could have been modified, so we’ll have to reset those in our pandora sheet as well).

In simple terms, a pandora box has two sets of rules — the default and the author provided that parallel the user-agent (default) and author. The automatic ordering of specificity is naturally such that the automatic pattern for resolution is: user-agent default stylesheet, then normal author stylesheets, then pandora-box default stylesheet, then pandora-box author stylesheets.

If you can follow this model, you should be able to work cooperatively without the risks and figure out how well that approach works. Of course, no one wants to write a ton of :nots, but we have preprocessors that can help. And maybe we can write something helpful for basic use that doesn’t even require that.

So, here’s a quick test pandora-box.js that does just that. Including it will inject a “pandora-box stylesheet” (just once) and give you a method CSS._specifyContainer(containerElement), which will shove any <style> tags within it to a more specific ruleset (adding the pandora/specificity boosts). The file has a couple of other methods too, but they all are just alternate forms of this. I’m trying out a few ways of slicing up the problem — one that allows me to hoist a string of arbitrary cssText, and another that I’m playing with in a larger custom element pattern. You can see this in use in this example. It is identical to the previous one in terms of HTML and CSS.

See the Pen XJEMwP by Brian Kardell (@bkardell) on CodePen.

Interestingly, CSS has an all property for unwinding the cascaded values simply. Unfortunately, it doesn’t have an equivalent to “default” things back to the fresh page state (the one that includes the user-agent sheet), only to nuke all styles back to their initial values, which, in my mind, leaves you in a pretty bad spot. However, if it did, our pandora sheet would be essentially a single declaration: [pandora-box]:not(-_-):not(-_-):not(-_-) * { all: user-agent; }. Luckily, that’s under discussion by the CSS Working Group as you read this.

What should that pandora sheet actually contain? How much is really necessary and should it include common reset rules? I’ve taken what I think is a pretty sane stab at it, but all of this is easily tweaked for experimentation, so feel free to fork or make suggestions. The real goal of this exercise is to allow for experimentation and feedback from real authors, expand the conversation, and highlight just how badly we need to solve some of these problems.

What about performance?

The injection of rules happens just once. I don’t think that is likely a big performance problem. Beside the pandora-box stylesheet, you have exactly the same number of rules (your components rules are replaced, not duplicated) and because it’s easier to reason about you should be able to have generally less crazy sheets. At least, that’s what I hope. Only time and use will really tell. If you’re worried about the complexity of the selectors slowing things down dramatically, I doubt that’s much of a problem either — browsers use a bloom filter and such cleverness that evaluating the pandora sheet should not have much effect. The triple :nots are placed on the subject element of the selector, so there shouldn’t be much computational difference between something no one would have trouble writing, like [pandora-box] .foo, and the rule it rewrites to [pandora-box] .foo:not(-_-):not(-_-):not(-_-). It never walks anywhere.

What about theming?

This seems to be one of the great debates — clearly there are certain kinds of advice you want an outer page to provide to the stuff in these various pandora boxes — things like “the background of this calendar component should be blue.”

In case it’s not obvious, the basic principle at play here is that just like selectors are managed with orders of magnitude, this technique employs those to create order of magnitude “contexts.” So, if you created a new context with specificity, you can simply trump it, just like anything else. That is to say that if you really really wanted to pierce through from the outside, you’d just have to make those rules really specific (or just place them after with the same specificity). The key is that it has the right default behaviors — the calendar defines what it is to be a calendar and the page has to specifically say “except in these ways.” If you look back at pandora-box.js, you can use the same API to allow the page to add rules, or there is a simple pattern that was also demonstrated in the last codepen example. If the thing you contain has an ID, it will see if there is a matching style tag in your page with a special type <style type="text/theme-{id}">, where {id} is the id of the container. If it finds one, all of those rules will be placed after the component’s rules and will therefore theme in the same way an author customizes native elements.

So, be part of the conversation. Let’s figure out what works and doesn’t in solving the friendly fire problem in CSS. Would the ability to put a lid back on Pandora’s Box be helpful?


Editor’s note: if you’re interested in CSS, the DOM, and W3C standards, you’ll want to check out the HTML and CSS presentations at our upcoming Fluent conference.

This post is part of our ongoing exploration into the explosion of web tools and approaches.

Public domain images via Pixabay and Wikimedia commons.

tags: , , , , ,

Get the O’Reilly Web Platform Newsletter

Stay informed. Receive weekly insight from industry insiders—plus exclusive content and offers.

  • I like it but it properly hurts my head to think about it. I will make a few experiments and let you know how it goes

    • вкαя∂εℓℓ

      Great! Let me know how it goes!

  • James Edwards

    I really liked the encapsulation potential of Shadow DOM, but I’m not convinced that it solves the whole problem, and it seems somewhat over-engineered for what it does solve:

    1. not solving the whole problem: you can build a widget inside a shadow DOM, but since you can’t make semantic associations between the shadow DOM and the main DOM, you can’t associate shadow DOM content with static content for accessibility.

    I’m thinking of things like “aria-labelledby” or “aria-describedby” — if that attribute wants to refer to content in the main DOM … well it can’t, so you have to have an outer element in the main DOM that binds that association, with the inner workings in the Shadow DOM (the same way that an INPUT element is the main DOM while it’s inner components are in the shadow DOM). Therefore, the widget is not entirely encapsulated, because its outer element has to be in the main DOM.

    2. over-engineered: what if you just want a simple piece of static content to be encapsulated? Why do we have to go through all this complex JS with unique traversal methods, just to control how CSS behaves?

    Couldn’t we just have ? It would be much easier to use, and eventually (once widely supported) it could be used declaratively.

  • James Edwards

    I prefer “encapsulated” because it’s a techy term, where “pandoras box” requires cultural knowledge to understand. But yeah, that wouldn’t have made such a good title :-)

    My interest in Shadow DOM stemmed from when I needed to find a solution for encapsulating the controls in a video player, and all I could think of in the end was to add !important to everything. It’s a horrible and ugly and hard-to-maintain approach, but it does work really well for that.

    Your :not() solution is a lot more elegant; but it wouldn’t work in IE7, which I needed to support at the time.

    I will follow on to the discussions you mentioned; it would be great to see these concepts evolve, because they have to potential to solve some of the biggest pains we have to deal with.