Skip to content

Theming Proposal

Jimmy Sanford edited this page Jun 24, 2021 · 2 revisions

Theming for PWA Studio

What problem are we trying to solve?

Right now, it's difficult for agencies to customize the look and feel of the stores they're building with PWA Studio. Regardless of whether developers are using components from venia-ui or authoring their own, our current architecture expects each component to define its own presentation—entirely. While this arrangement gives developers maximum flexibility, it also gives them too little assistance; to fully implement a design, they may need to override every component in the app.

We can do better for our developers. What they expect, and what we should deliver, are themes.

What is a theme?

A theme is an independent package that serves as the basis of an app's presentation. A theme should have the following qualities:

  • centralized: a theme should establish common presentational language and abstractions, such that an app's presentation can be consistent across components and screens
  • configurable: a theme should consume a configuration file, such than an app can modify the values the theme uses to generate styles
  • hierarchical: a theme should include base values and one or more levels of derived values, such that an app can make changes broadly or narrowly as necessary
  • inheritable: a theme should allow other themes to declare it as a dependency, such that a developer can elect to package and distribute customizations as a child theme

Why doesn't PWA Studio already support themes?

Generally, themes are either too bloated to meet the nonfunctional requirements of a PWA or too limited to support the diversity of design we see in Magento stores.

Traditionally, themes have been used to generate and serve all of the rulesets that an app could potentially need. This happens because theming systems typically aren't sophisticated enough to statically analyze an app's content—let alone scripts—to determine which rulesets are needed and which ones aren't. As a result, all rulesets must be served to the browser.

While serving the whole theme ensures that a shopper sees the correct presentation, it's also the exact kind of overserving that a PWA aims to avoid. Styles are render-blocking (must be served before the page renders), so unused rulesets end up delaying the initial render, which frustrates the shopper and leads to penalties in all the most important performance metrics for PWAs.

Some theming systems attempt to shrink their footprint by minimizing complexity (reducing the number of concepts and variants), but such an approach constrains design, so it's only viable if design authority rests with the theme. In our case, agencies give each Magento store a unique design and retain authority over its complexity, so we can't afford to constrain them arbitrarily.

We need a solution that remains optimized even as an agency's design language grows.

What would the ideal solution look like?

In addition to supporting packaged themes featuring the authoring qualities listed earlier, a theming solution should also generate output optimized for fast rendering and low bandwidth. Ideally, output would have the following characteristics:

  • minimal: the bundler should statically analyze rulesets, deduplicating them and pruning unused rulesets from the bundle
  • modular: the bundler should split stylesheets into chunks, serving and updating them over the course of the shopper's journey rather than all up front

Essentially, we need a theming system that allows us to bundle styles the same way we bundle scripts. If we could design an ideal solution, it would optimize our CSS transparently, replacing all of our duplicate declarations with shared ones in roughly three steps:

  1. Collect all declarations in the codebase
  2. Generate a utility classname & ruleset for each unique declaration
  3. Deduplicate declarations automatically

Unfortunately, automatic collection and deduplication are difficult. Components often apply classnames dynamically based on state, and generated rulesets don't always work with advanced selectors, so potential solutions become complex fairly quickly. Linkedin's CSS Blocks seems to be the most sophisticated attempt, but it uses new syntax, a runtime, and several framework integrations, and there are still a number of edges it doesn't cover; it also doesn't solve for central configuration or theme distribution.

In any case, CSS Blocks doesn't have enough activity or traction to justify refactoring PWA Studio. For this scope of change, we need something more familiar, more idiomatic, and more widely accepted—even if that means deduplication ends up being a bit more manual.

Introducing Tailwind

Tailwind is a popular CSS framework and theming system centered around a utility-first philosophy. At a high level, workflows with Tailwind involve three steps:

  1. Theme authors specify design values in a central configuration file
  2. Build plugins read the configuration file to generate utility rulesets
  3. Component authors add utility classnames to elements for presentation

Tailwind config

module.exports = {
    // all theme values are present in one config file
    theme: {
        // entries can be appended or merged, not just modified
        extend: {
            // margin, padding, gap all derive from one entry
            spacing: {
                // keys are used to generate classnames
                foo: "1rem",
                bar: "2rem"
            }
        }
    }
}

React component

const Panel = props => (
    <article>
        <div className="flex" />
        <div className="gap-foo grid" />
    </article>
)

On the one hand, this workflow allows Tailwind to reach something close to the ideal solution described earlier. When authors reuse generated classnames, in effect, they're manually deduplicating whole rulesets. For highly consistent designs that benefit from such reuse, the resulting CSS bundles can be very small.

On the other hand, Tailwind's philosophy rejects the core principle behind CSS itself: separation of content and presentation. Content is inherently meaningful and presentation is inherently arbitrary, so CSS exists to separate the two into static and dynamic layers, respectively. But Tailwind asserts that, for developers trying to maintain a consistent presentation across a variety of content created by a variety of authors, content should be the dynamic layer instead—and in a world of PWAs, perhaps it already is.

Tailwind's position is compelling, but there's an important caveat: it only works if the teams applying presentation to components are also maintaining those components. Such an assumption is true for many applications, but it's not true for PWA Studio; in our case, Adobe maintains the library of Venia components, but agencies maintain only a minimal set of changes and additions. So we still prefer an arrangement where agencies can overhaul presentation from central configuration alone, without touching components.

But what if we could restrict Tailwind to the presentation layer?

Revisiting CSS Modules

Today, each venia-ui component has its own default presentation defined in its own .css file. These files use a css-loader feature called CSS Modules that hashes and namespaces selectors; when a component imports a CSS file, the imported object contains the raw classnames as keys and the translated classnames as values, and Webpack discards any unused rulesets.

React component

/*
Rendering this component will yield HTML similar to the following:

<article>
    <div class="panel-header-1aB"></div>
    <div class="panel-body-2cD"></div>
<article>

Note that the `<article>` element has no `class` attribute. This is because
`classes.root` is undefined, as no matching ruleset exists.
*/

import classes from "./panel.css"

const Panel = props => (
    <article className={classes.root}>
        <div className={classes.header} />
        <div className={classes.body} />
    </article>
)

CSS module

/*
Even though this file defines a ruleset for `.footer`, there are no active
references to this ruleset, so the bundler will mark it as dead code and
exclude it.
*/

.header {
    display: flex;
}

.body {
    display: grid;
    gap: 1rem;
}

.footer {
    display: flex;
}

Thanks to this classname translation, agencies using PWA Studio don't need to worry about classname collisions when writing components. In fact, a developer can even install third-party extensions without checking for classname conflicts, since extensions may also allow css-loader to translate their classnames. These benefits simply aren't available with global classnames.

Fortunately, CSS Modules provides another relevant feature: composition. Each ruleset may contain one or more instance of a special composes property, which accepts a selector as a value; when css-loader parses this property, it imports the rulesets for that selector and merges them into the parent, just like a preprocessor mixin. Venia components already take advantage of this feature to avoid concatenating classnames.

React component

/*
Rendering this component will yield HTML similar to the following:

<article>
    <div class="panel-header-1aB panel-animated-3eF"></div>
    <div class="panel-body-2cD panel-animated-3eF"></div>
<article>

Note that the value of `classes.header` is a string containing more than one
classname. Concatenation happens via `composes`, not in the component; the
component doesn't even know that an `.animated` ruleset exists.
*/

import classes from "./panel.css"

const Panel = props => (
    <article className={classes.root}>
        <div className={classes.header} />
        <div className={classes.body} />
    </article>
)

CSS module

/*
This file defines a ruleset for `.animated`. No component reads this property,
so ordinarily the bundler would mark it as dead code and exclude it from the
bundle. But since other rulesets access it via `composes`, the bundler knows
to retain it.
*/

.animated {
    transition: all 256ms linear;
}

.header {
    composes: animated;
    display: flex;
}

.body {
    composes: animated;
    display: grid;
    gap: 1rem;
}

This arrangement preserves the separation of concerns that we cherish, in that components know nothing about presentational abstractions. But there's actually another hidden benefit here: by assigning a single, semantic, locally unique classname to each significant element (and since presentational changes don't require these classnames to change) we're establishing a stable API for each component. Local classnames act as keys or identifiers, helping developers write selectors that remain accurate over time—and in the future they may even help us expose elements as Targetables for extension. It's a pattern worth keeping, and it imposes no burden on our developers.

Rather, the pattern that imposes a maintenance burden is how we use raw values. In most cases, when we write a declaration, we set a raw or arbitrary value rather than referencing a token, so we end up with lots of duplicate or inconsistent values. These values are hard for us to maintain, hard for agencies to change, and superfluous for shoppers. Colors are an exception here (we use global tokens), but as discussed earlier, our current token system would scale poorly, so expanding it to all types of values isn't an option.

What we'd like is a way for Tailwind to provide abstractions that refer to centralized values, and for our existing files (with CSS Modules) to use those abstractions. Composition would work, but Tailwind only outputs global rulesets. Perhaps, if composes were able to concatenate global classnames, we could get these two systems to work together.

Combining Tailwind and CSS Modules

Fortunately, composes can target global classnames, not just local ones. This means Venia's rulesets can compose from Tailwind-generated classnames without any changes on the component side.

Tailwind config

module.exports = {
    theme: {
        extend: {
            spacing: {
                foo: "1rem",
                bar: "2rem"
            }
        }
    },
    plugins: [
        plugin(({ addComponents, theme }) => {
            addComponents({
                ".card-header": {
                    display: "flex"
                },
                ".card-body": {
                    display: "grid",
                    gap: theme("spacing.foo")
                },
            })
        })
    ]
}

React component

/*
Rendering this component will yield HTML similar to the following:

<article>
    <div class="panel-header-1aB card-header"></div>
    <div class="panel-body-2cD card-body"></div>
<article>

Note that the value of `classes.header` is a string containing more than one
classname, but one of them is not hashed. The `card-header` classname, created
by the plugin we gave to Tailwind, is global; since this file uses it, the
bundler knows to include it.
*/

import classes from "./panel.css"

const Panel = props => (
    <article className={classes.root}>
        <div className={classes.header} />
        <div className={classes.body} />
    </article>
)

CSS module

.header {
    composes: panel-header from global;
}

.body {
    composes: panel-body from global;
}

This is a good start: Tailwind generates rulesets, and components compose their presentation from those rulesets. But Tailwind generates lots of rulesets—thousands of them, totaling several megabytes—so we need to help the bundler identify which ones are in use so that it can exclude the rest during the build. To accomplish that, we just need to tell Tailwind where to look for its classnames.

Tailwind config

module.exports = {
    // use Tailwind's brand-new optimizer
    mode: "jit",
    // tell Tailwind which files are consuming its output
    // normally, these would be HTML or JSX files
    purge: {
        // but in our case, CSS files consume Tailwind output via `composes`
        content: ["./src/**/*.css"],
        extractors: [{
            // extract all classnames from each `composes` declaration
            extractor: (content) => {
                const test = /(?<=composes:.*)(\b\S+\b)(?=.*from global;)/g

                return content.match(test) || []
            }
            extensions: ["css"]
        }]
    }
}

Perfect. Now the build will contain only the generated rulesets that our components actually use.

Comparing solutions

Quality Bootstrap Spectrum Tailwind
Active

Does an organization actively maintain and distribute the library?
✅ Yes

The Bootstrap team maintains the bootstrap package on NPM, and has published in the last 30 days.
✅ Yes

Adobe maintains packages under the @spectrum-css scope on NPM, and has published in the last 30 days.
✅ Yes

Tailwind Labs maintains the tailwindcss package on NPM, and has published in the last 30 days.
Compatible

Can Venia's existing rulesets consume theme variables and classnames?
❌ No

Venia would need to adopt Sass (convert source, add loader) in order to use variables.
✅ Yes

Venia would be able to use custom properties and compose classnames with no change.
✅ Yes

Venia would be able to use custom properties and compose classnames with no change.
Configurable

Can developers add and modify theme values used to generate rulesets?
✅ Yes

User-defined Sass variables replace the defaults before the bundler generates rulesets.
❌ No

The bundler only generates rulesets from default variables, so users need to serve additional rulesets.
✅ Yes

User-defined config values replace the defaults before the bundler generates rulesets.
Hierarchical

Do specific theme values derive from more general ones?
✅ Yes

Components depend on several core Sass files.
✅ Yes

Components depend on several core CSS files.
✅ Yes

Config sections can depend on other config sections, and styles exist in discrete layers.
Incremental

Does the bundler chunk and serve stylesheets over time, rather than all at once?
🟡 Not usually

Users can import rulesets dynamically, but it's safer to serve them up front.
🟡 Not usually

Users can import rulesets dynamically, but it's safer to serve them up front.
🟡 Not usually

The bundler generates only one stylesheet, but we could eventually split it up.
Inheritable

Can a theme depend on another theme and reconfigure it?
🟡 Somewhat

A theme can depend on another, but will likely duplicate some imports.
🟡 Somewhat

A theme can depend on another, but will likely duplicate some imports.
✅ Yes

A theme can designate other themes as presets and reconfigure all of them.
Modular

Can developers compose a theme from a variety of packages and plugins?
✅ Yes

Bootstrap defines most rulesets in optional Sass files, and developers can follow the same pattern.
✅ Yes

Spectrum distributes each component individually, and developers can follow the same pattern.
✅ Yes

Themes can include plugins, which can use theme values to generate rulesets.
Optimized

Does the bundler automatically prune unused rulesets?
❌ No

The bundler includes every ruleset that the content imports.
❌ No

The bundler includes every ruleset that the content imports.
✅ Yes

The bundler excludes any rulesets that the content doesn't reference.

Implementation details

Initial setup

We need to create a theme package and configure scaffolded apps to use it by default. Scaffolded apps should only need a tailwind.config.js file and a dependency on the theme package.

  • Add postcss-loader and tailwindcss to our Webpack configuration
  • Add tailwind.config.js to @magento/venia-concept
  • Have the scaffolding tool create a copy of tailwind.config.js
  • Create a @magento/venia-theme package ("theme") inside the monorepo
  • Export a complete Tailwind configuration file from the theme
  • Import the theme and apply it as a "preset" in tailwind.config.js
const venia = require("@magento/venia-theme")

const config = {
    presets: [venia]
}

module.exports = config

Plugin architecture

The Tailwind preset for Venia should include styles for all of our components. In order to let developers replace or exclude styles for individual components, though, we should create a Tailwind plugin for each component. Furthermore, for simplicity, we should allow developers to enable or disable plugins via configuration rather than requiring them to import plugins and apply them explicitly.

  • Create an entrypoint plugin that imports every component plugin
  • Export a name (unique identifier) from each component plugin
  • Reserve a venia property on the Tailwind configuration's theme entry
  • Reserve a property for each component name on the venia.plugins entry
  • Have the entrypoint plugin check venia.plugins before running each plugin
const createPlugin = require("tailwindcss/plugin")

// require all component plugins
const plugins = [
    require("./button"),
    require("./card")
]

// define an entrypoint plugin
const includePlugins = (pluginApi) => {
    const { theme } = pluginApi
    const config = theme("venia.plugins")

    for (const [id, plugin] of plugins) {
        try {
            if (
                // config is null or undefined, so include all plugins
                config == null ||
                // config is an array, so treat it as a safelist
                (Array.isArray(config) && config.includes(id)) ||
                // config is an object, so treat it as a blocklist
                config[id] !== false
            ) {
                plugin(pluginApi)
            }
        } catch (error) {
            console.error("`theme.venia.plugins` must be an array or object")
        }
    }
}

module.exports = createPlugin(includePlugins)

Variable hierarchy

Specifying all of Venia's core theme values in Tailwind configuration is a good start, but we should offer developers more than a binary choice between changing one instance and changing every instance of a value. Rather, we should take this opportunity to deconstruct Venia's design by identifying common ways that core values are used and create semantically named, higher-order values that tie these use cases together. Developers always have the option to change components individually, but they should also be able to change all components that share an abstraction.

const addRulesets = ({ addComponents, theme }) => {
    addComponents({
        ".card": {
            borderColor: theme("borderColor.DEFAULT"),
            borderWidth: theme("borderWidth.DEFAULT"),
            display: "grid",
            gap: theme("gap.interior"),
            gridTemplateRows: theme("gridTemplateRows.common")
        }
    })
}

const ID = "card"
module.exports = [ID, addRulesets]

Example scenario

Current workflow

Currently, to change a Venia component's presentation, developers need to replace the classnames applied to that component. To do this without taking over component source code, they can use our Targetables APIs to replace classnames at build time. The following simplified example demonstrates how this process works.

PWA Studio's code

/* textInput.js */
import defaultClasses from "./textInput.css"

const TextInput = props => {
    const classes = useStyle(defaultClasses, props.classes)

    return (
        <input className={classes.root} type="text">
    )
}
/* textInput.css */
.root {
    border-radius: 4px;
    height: 40px;
}
/* button.js */
import defaultClasses from "./button.css"

const Button = props => {
    const classes = useStyle(defaultClasses, props.classes)

    return (
        <input className={classes.root} type="text">
    )
}
/* button.css */
.root {
    border-radius: 4px;
    height: 40px;
}

Developer's code

/* local-intercept.js */
const { Targetables } = require("@magento/pwa-buildpack")

module.exports = targets => {
    const targetables = Targetables.using(targets)

    // override TextInput classes
    const TextInput = targetables.reactComponent(
        "@magento/venia-ui/lib/components/TextInput/textInput.js"
    )
    const inputClasses = TextInput.addImport(
        "inputClasses from 'src/custom/textInput.css'"
    )
    TextInput.setJSXProps(
        "<input>",
        `${inputClasses}.root`
    )

    // override Button classes
    const Button = targetables.reactComponent(
        "@magento/venia-ui/lib/components/Button/button.js"
    )
    const buttonClasses = Button.addImport(
        "buttonClasses from 'src/custom/button.css'"
    )
    Button.setJSXProps(
        "<input>",
        `${buttonClasses}.root`
    )
}
/* src/custom/textInput.css */
.root {
    border-radius: 0px;
    height: 48px;
}
/* src/custom/button.css */
.root {
    border-radius: 0px;
    height: 48px;
}

Proposed workflow

As proposed, to change a Venia component's presentation, developers would typically only need to change values in the Tailwind configuration file, which would automatically propagate to the Venia component. (For more extreme customizations, the Targetable APIs are still available.) The following example demonstrates how this process works.

PWA Studio's code

/* textInput.js */
import defaultClasses from "./textInput.css"

const TextInput = props => {
    const classes = useStyle(defaultClasses, props.classes)

    return (
        <input className={classes.root} type="text">
    )
}
/* textInput.css */
.root {
    composes: height-controls;
    composes: rounded-controls;
}

Developer's code

/* tailwind.config.js */
const config = {
    presets: [venia],
    theme: {
        extend: {
            borderRadius: {
                controls: "0px"
            },
            height: {
                controls: "48px"
            }
        }
    }
}

module.exports = config