-
Notifications
You must be signed in to change notification settings - Fork 685
Theming Proposal
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.
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
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.
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:
- Collect all declarations in the codebase
- Generate a utility classname & ruleset for each unique declaration
- 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.
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:
- Theme authors specify design values in a central configuration file
- Build plugins read the configuration file to generate utility rulesets
- Component authors add utility classnames to elements for presentation
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"
}
}
}
}
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?
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.
/*
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>
)
/*
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.
/*
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>
)
/*
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.
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.
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")
},
})
})
]
}
/*
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>
)
.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.
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.
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. |
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
andtailwindcss
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
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'stheme
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)
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]
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.
/* 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;
}
/* 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;
}
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.
/* 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;
}
/* tailwind.config.js */
const config = {
presets: [venia],
theme: {
extend: {
borderRadius: {
controls: "0px"
},
height: {
controls: "48px"
}
}
}
}
module.exports = config
- Sync calls:
- Check the calendar
- Recordings - https://goo.gl/2uWUhX
- Slack: #pwa Join #pwa
- Contributing
- Product