linkedin / linkedin/css-blocks
[WIP] RFC: Component Re-Theming.
- Dominant language
- TypeScript
- Stars
- 6.3k
- Forks
- 154
- PR merge metrics
- No merged PRs in 30d
Description
## Summary
This RFC fulfills two use cases:
- As an end-user, I want to apply a global re-theme to any component using a block.
- As a library author, I want to publish Blocks that re-theme a component delivered in a separate package.
A sane shared component re-theming strategy is a panacea for most shared component override woes. With the static analysis afforded by CSS Blocks we are able to provide a system with:
1. Transparent re-theming APIs, packaged and delivered through node_modules, with little to no ceremony to apply;
2. Zero code bloat re-themes – the un-used visual styles never touch the client;
3. Well structured, and strictly enforced, style API footprints that enable proper visual style semver;
4. Immediate build time feedback for style application which result in failing builds, helpful errors, and prevent visually inconsistent code from ever hitting production.
## Motivation
Component authors ranging from design systems teams, shared in-app component maintainers, and individual product developers require a way to make changes to existing component styles both across the application, and on an instance-by-instance basis to meet the product needs to a sufficiently large enterprise app.
## Detailed design
At its core, a re-theme involves telling the application that, at run-time, it should be using a different set of classes to style a given component. Many of the largest concerns with re-theming a component are handled by CSS Blocks' static analysis guarantees and Block object inheritance system.
Today, CSS Blocks is already able to tell at build time if any two Blocks **implement** the same style interface. This API footprint implementation may come from either explicitly extending an existing Block or re-implementing every class and state present on another Block. For example, the three Blocks below all implement the same interface defined by `block1.css`:
```css
/* block1.css */
:scope { color: red; }
.class-1 { color: blue; }
.class-1[state|active] { color: green; }
```
```css
/* block2.css */
@block base from "./block1.css";
:scope { extends: base; }
.class-1[state|active] { color: purple; }
```
```css
/* block2.css */
@block base from "./block1.css";
:scope { implements: base; }
.class-1 { border: 1px solid red; }
.class-1[state|active] { background: green; }
```
Although possible, Blocks that fully implement the interface of another Block, without explicitly referencing the relationship through `extends` or `implements`, will not be available to use for component re-themes and will instead throw with
```
Error: Can not use "block-name" to theme "component-name". Blocks use for re-themes must explicitly `extend` or `implement` the interface of the base Block.
```
Depending on the use case, the application of a new Theme Block to a component with a default Block of the same interface can come in one of two flavors: Instance Re-Themes and Global Re-Themes. This proposal addresses both these consumption options.
Luckily, the two are variations on the same theme and will leverage identical concepts and implementation details, as we'll go through below. But first, let's explore the use cases and API options for these two theme consumption options.
### Global Component Re-Themes
A global re-theme of a component tells the CSS Blocks build to use a different Block for every instance of a component's use in the application.
The closest concept in other systems is an import alias. Essentially, wherever the application intends to use a Block at a certain path, the consumer can tell it to use the generated classes of another Block instead.
We can implement this at a very low level in `@css-blocks/core` by providing a new Block alias configuration option to the CSS Blocks build, much like webpack, Browserify, or Rollup. In the CSS Block configuration:
```js
{
...config,
retheme: {
"local/component/style.block.css": "other/component/style.blockc.css",
"@my-module/component/block/style.css": "@theme/component/block/style.css",
}
}
```
Throughout the application, this Block mapping will swap the runtime classes used in the source Block with the classes built from the theme Block, providing a functional global re-theme.
However, this configuration heavy API is not the most developer friendly interface for declaring globally re-themed styles. Luckily, we can leverage the Node.js module resolution system to provide application developers a system for constructing this re-theme configuration on the fly.
The node_modules resolution RFC introduced a new optional `package.json` configuration field for CSS Blocks. We can use this field to allow each module with a `package.json` to deliver a map of Blocks local to that module it expects to be used in liu of a source Block, if discovered in the parent application. This allows component and theme authors to deliver themes for other component in the registry or the same module. For example:
```json
/* /package.json */
{
"name": "my-application",
"devDependencies": {
"@my-library/modal": "1.0.0",
"@my-library/button": "1.0.0",
"@my-library/button-premium-theme": "1.0.0",
},
"css-blocks": {
"retheme": {
"@my-library/modal": "./theme/modal.block.css"
}
}
}
```
```json
/* /node_modules/@my-library/button-premium-theme/package.json */
{
"name": "@my-module/button-premium-theme",
"peerDependencies": {
"@my-library/button": "1.0.0",
},
"css-blocks": {
"retheme": {
"@my-library/button": "./theme/button.block.css"
}
}
}
```
The `retheme` field of a CSS Blocks module automatically applies to any local file to the module, or peer module installed alongside it and adheres to these rules:
- The lookup algorithm for the key value of the retheme option uses the same lookup as `@block` references, allowing theme authors to target node_modules or files local to the theme module itself.
- Local paths are required to start with `./`, all other paths are assumed to be a `node_module`.
- Any `node_module` targets discovered in `retheme` are required to specify a `peerDependency` version. A hard error is thrown if none is provided.
Because of dependency hoisting / flattening in modern module systems, this has the added effect of allowing the parent application to re-theme dev dependencies of deep children that meet the semver requirements. For example, consider a utility block framework called `@my-library/foundations` that delivers design primitives like color, box-shadow, to components packaged in the standalone modules `@my-library/button` and `@my-library/modal`:
```js
/* /package.json */
{
...fields,
"devDependencies": {
"@my-library/foundations": "2.0.0",
"@my-library/button": "8.0.0",
"@my-library/modal": "4.0.0",
"@my-library/foundations-premium-theme": "1.0.0",
},
"css-blocks": {
"retheme": {
"@my-library/modal": "./theme/modal.block.css"
}
}
}
```
```js
/* /node_modules/@my-library/button/package.json */
{
...fields,
"devDependencies": {
/* Same version as parent app, module is hoisted */
"@my-library/foundations": "^2.0.0",
}
}
```
```js
/* /node_modules/@my-library/modal/package.json */
{
...fields,
"devDependencies": {
/* Different version than parent app, module is not hoisted and no re-themes applied. */
"@my-library/foundations": "^1.0.0",
}
}
```
```js
/* /node_modules/@my-library/foundations-premium-theme/package.json */
{
...fields,
"peerDependencies": {
/* Delivers a color override for all semver matching versions of the package. */
"@my-library/foundations": "^2.0.0",
},
"css-blocks": {
"retheme": {
"@my-library/foundations/blocks/colors": "./blocks/colors.block.css"
}
}
}
```
It is important to note that these global re-themes will only be applied to deep peer dependencies that are a semver match, or within the module itself. A re-theme will never be foisted on the parent.
### Instance Component Re-Themes
In many cases for an application, it may be required to provide custom styles to an otherwise sandboxed component. In this situation, an application developer requires fine-grained control over the Block applied at runtime.
To provide an instance specific re-theme, an author may simply pass a Block that implements or extends the component's default Block to the invocation site using the special `block:` property. Ex:
```css
/* custom-button.block.css */
@block button from "@my-library/button";
:scope { extends: button; color: green; }
```
```css
/* stylesheet.block.css */
@export custom-button from "./custom-button.block.css";
```
```hbs
{{!-- template.hbs --}}
```
### `@export` As An Interface Property
Previously, the `@export` pragma was strictly used to expose multiple `@import`-able blocks across a module or file boundary for use in Style composition. In a world where Blocks may be passed across component boundaries, the `@exports` of a Block become more important. With this proposal, two changes to the behavior of `@export` must be made:
1. A Block is not said to `implement` the full interface of another Block file unless also has the same named `@export`s of the other Block.
2. In scope-dependent syntaxes (like Ember) a referenced Block will not available for use in the template unless it is `@export`ed. (note: in-stylesheet composition is allowed without re-exporting).
### Theming Complex Components – Propagating Blocks
In some cases, it may be at best tedious, and at worse nearly impossible to style a complicated component with a single Block association without some help from the component author. Consider the following module:
```json
{
"name": "@design-system/widget",
"devDependencies": {
"@design-system/foundations": "1.0.0"
},
"css-blocks": {
"main": "public-component.block.css"
}
}
```
```css
/* styles/private-component.block.css */
:scope { color: green; }
```
```hbs
/* templates/private-component.hbs */
I'm the private component!
```
```css
/* styles/public-component.block.css */
@block ( typography, shadow ) from "@design-system/foundations";
:scope { composes: "typography.large"; color: red; }
.icon { color: blue; }
@export shadow
```
```hbs
/* templates/public-component.hbs */
I'm the public component!
```
The above user-facing component is meant to be consumed exclusively through its public `` interface. Internally, it uses a ``, presumably to encapsulate some private business logic or to keep the code DRY.
From the perspective of a Global Re-Theme, overriding the `private-component` Block is tedious because it requires an explicit entry in our `css-blocks` configuration:
```json
{
"css-blocks": {
"retheme": {
"@design-system/widget/styles/private-component.hbs": "./path/to/widget/theme.block.css"
}
}
}
```
As tedious as the Global Re-Theme ceremony is, it is still better than the Instance Re-Theming – which is, as written above, entirely not possible.
> Bear in mind! There may be very good use cases for a developer to keep an internal component's styles private. This ability to keep sections of a component's styles private from instance level overrides is actually a language *feature* not a bug.
However, if the developer wants to make this internal component public for easy re-theming, it requires a simple change to `public-component`:
```css
/* styles/public-component.block.css */
@block ( typography, shadow ) from "@design-system/foundations";
:scope { composes: "typography.large"; color: red; }
.icon { color: blue; }
@export shadow
@export private-component from "./private-component.block.css";
```
```hbs
/* templates/public-component.hbs */
I'm the public component!
```
By adding `private-component` to `public-component`'s public interface (note: also the default Block of the module), any consumer is now *required* to provide a `private-component` export as part of their new implementation to satisfy the Block interface requirements. An override Block may look like this:
```css
@import base from "@design-system/widget;
:scope { extends: base; }
.icon { color: purple; }
@export ( private-component, shadow ) from "@design-system/widget;
```
The above example simply re-exports the same `private-component` from the original Block, while extending the default Block for `public-component`, but the re-theme author may now choose to deliver a new `private-component` Block if they so choose.
### Runtime Updates
Internally, the current runtime implementation does not suit itself to hot-reloading and/or swapping of class values. However, the proposal drafted in https://github.com/linkedin/css-blocks/issues/47 does lend itself to easy class switching based on either a global static rewrite or simple instance value passed in. This RFC should be considered dependent on https://github.com/linkedin/css-blocks/issues/47.
Since class lists are stored in a single array in the above mentioned Boolean Expression Runtime proposal, hot swapping of themes for a given component instance becomes trivial. The exact best implementation will require some experimentation (ex: writing all possible class theme lists in to the component source to be accessed dynamically vs pulling all classes out to their own route-split module vs something completely different), but I will flesh this RFC out with proposal details after some more technical exploration.
## How we teach this
Appropriate documentation on `css-blocks.com` and in the READMEs.
## Drawbacks
1. The change in `@export` behavior is a non-trivial breaking change for Ember. (But I believe is more expected than current behavior).
2. Automatic application of declared global rewrites defined by-module to peers may get hairy and leverages peerDependencies heavily, which is an easy foot-gun.
## Alternatives
- We may want to choose to not implement the module-based theme discovery feature up-front and instead require the parent app hand configure all re-themes. This will not block later implementation.
Open to any and all other ideas!
## Unresolved questions
The JSX syntax for all the above APIs will need to be well defined.
Contributor guide
Research direction
The RFC names @css-blocks/core, package.json, @block, and @export, but no implementation files or tests. Start by locating the existing handling for those entry points, then trace how a retheme configuration could cover both global and instance use. Done means the proposed interface is implemented with the stated validation and component theming behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- frontend
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100