diff --git a/packages/lit-dev-content/site/_data/authors.json b/packages/lit-dev-content/site/_data/authors.json
index 69c41c872..5afd6a58e 100644
--- a/packages/lit-dev-content/site/_data/authors.json
+++ b/packages/lit-dev-content/site/_data/authors.json
@@ -59,5 +59,15 @@
"image": {
"url": "authors/elliott-marquez.webp"
}
+ },
+ "brian-vann": {
+ "name": "Brian Taylor Vann",
+ "links": {
+ "github": "taylor-vann",
+ "linkedin": "brian-vann"
+ },
+ "image": {
+ "url": "authors/brian-vann.webp"
+ }
}
}
diff --git a/packages/lit-dev-content/site/_includes/article.html b/packages/lit-dev-content/site/_includes/article.html
index e693f88ea..6962ffa29 100644
--- a/packages/lit-dev-content/site/_includes/article.html
+++ b/packages/lit-dev-content/site/_includes/article.html
@@ -42,6 +42,13 @@
{% endif %}
+ {% if authorData.links.linkedin %}
+
+
+
+ {% endif %}
{% endif %}
diff --git a/packages/lit-dev-content/site/_includes/articles.html b/packages/lit-dev-content/site/_includes/articles.html
index 3beccf0fb..b07a983c4 100644
--- a/packages/lit-dev-content/site/_includes/articles.html
+++ b/packages/lit-dev-content/site/_includes/articles.html
@@ -15,7 +15,8 @@
-{% endblock %}
+
+ {% endblock %}
{% block content %}
{% set toc = content | toc %}
diff --git a/packages/lit-dev-content/site/articles/article/article.11tydata.js b/packages/lit-dev-content/site/articles/article/article.11tydata.js
index 5fdf6acaa..dce4cc55c 100644
--- a/packages/lit-dev-content/site/articles/article/article.11tydata.js
+++ b/packages/lit-dev-content/site/articles/article/article.11tydata.js
@@ -4,4 +4,5 @@ module.exports = {
permalink: (data) => `/articles/${data.page.fileSlug}/`,
},
tags: ['articles'],
+ thumbnailExtension: 'jpg',
};
diff --git a/packages/lit-dev-content/site/articles/article/redux-reactive-controllers.md b/packages/lit-dev-content/site/articles/article/redux-reactive-controllers.md
new file mode 100644
index 000000000..03110510c
--- /dev/null
+++ b/packages/lit-dev-content/site/articles/article/redux-reactive-controllers.md
@@ -0,0 +1,633 @@
+---
+title: Redux + Lit with Reactive Controllers
+publishDate: 2023-12-14
+summary: Use Reactive Controllers to integrate libraries like Redux into Lit.
+thumbnail: /images/articles/redux_lit_controller
+thumbnailExtension: webp
+tags:
+ - reactive-controllers
+ - redux
+eleventyNavigation:
+ parent: Articles
+ key: Redux + Lit with Reactive Controllers
+ order: 0
+author:
+ - elliott-marquez
+ - brian-vann
+---
+
+Lit makes it easy to create web components – reusable HTML elements with shared logic. However, different elements often have similar behaviors, and creating another element just for sharing a behavior may be excessive.
+
+[Reactive Controllers](/docs/composition/controllers/) can help with the problem of sharing logic across components without having to create a new web component. They are similar to custom hooks in React, and in this article, we will use them to integrate the state manager Redux with Lit's rendering lifecycle for a more self-contained, composable, idiomatic Lit experience.
+
+By the end of this article, you will learn how to use Reactive Controllers to integrate third party libraries into Lit by integrating Redux into Lit. To do this, we will create a Reactive Controller that selects part of a Redux state and updates a component whenever the state updates.
+
+## What is a Reactive Controller?
+
+Reactive Controllers are a programming pattern that makes it easy to share logic between components by hooking into a component’s reactive update lifecycle. They achieve this by expecting an object that exposes an interface rather than having to create a new component or subclassing like you would with a mixin. For those familiar with React, Reactive Controllers are similar to custom hooks,
+while mixins are analogous to higher-order components.
+
+One advantage of the Reactive Controller pattern is that it creates a **with** relationship rather than an **is** relationship. For example, a component that uses a Reactive Controller that incorporates Redux logic is a **component with Redux selector abilities**, whereas a mixin that does the same would mean that the **component is a Redux selector component**. This type of composability results in code that is more portable, self-contained, and easier to refactor. This is because components that inherit via subclassing are more closely coupled with the logic they inherit.
+
+
+
+Reactive Controllers are just an interface – a pattern, which makes them easier to use with other component systems without committing to a specific architecture. This makes it possible to create Reactive Controller adapters that work with other frameworks and libraries, such as [React](https://www.npmjs.com/package/@lit/react#usecontroller), Vue, Angular, and Svelte.
+
+## What is Redux?
+
+[Redux](https://redux.js.org/) is a mature library that introduces patterns to manage state across a JavaScript application. The Lit team does not have a particular endorsement for a single state management library, but we will be using Redux as an example for creating a Reactive Controller, because the patterns used in integrating Redux into Lit with a Reactive Controller may be used to integrate for other popular libraries.
+
+There are three general concepts that Redux describes:
+
+1. Stores
+2. Actions
+3. Reducers
+
+Stores contain the current application state. Actions describe what kind of change to make to the state, and reducers take actions and apply them to the current state to return a new state. Here is a diagram derived from the [official Redux documentation](redux.js.org/tutorials/essentials/part-1-overview-concepts#redux-application-data-flow ?) that depicts the interaction pattern between these concepts:
+
+
+
+1. The UI has an interaction that triggers an event handler
+2. The event handler dispatches an Action to the store
+3. A reducer takes the current state and the action and computes the new state
+4. The reducer updates the state in the store
+5. The UI is updated with the newest state with a state subscription and, in our case, a Reactive Controller
+
+Lit would cover the UI (blue) section of this diagram – rendering and event handling. Redux would handle the orange, green, and red parts of this diagram. The example in this article is to create a Reactive controller that handles the interaction between the updated state and the UI by hooking into both Lit’s reactive update lifecycle and Redux’s state updates.
+
+## The Reactive Controller Interface
+
+The Reactive Controller package has two interfaces: one for the controller, `ReactiveController`, and one for the host that it is hooking into, `ReactiveControllerHost`.
+
+### ReactiveController
+
+The `ReactiveController` interface exposes four methods:
+
+- [`hostConnected()`](/docs/api/controllers/#ReactiveController.hostConnected)
+- [`hostDisconnected()`](/docs/api/controllers/#ReactiveController.hostDisconnected)
+- [`hostUpdate()`](/docs/api/controllers/#ReactiveController.hostUpdate)
+- [`hostUpdated()`](/docs/api/controllers/#ReactiveController.hostUpdated)
+
+In Lit, `hostConnected()` is called when the host component is placed in the DOM, or, if the component is already placed in the DOM, when the Reactive Controller is attached to the component. This is a good place to do initialization work when the host component is ready to be used such as adding event listeners.
+
+Similarly, `hostDisconnected()` is called when the element is removed from the DOM. This is a good place to do some cleanup work such as removing event listeners.
+
+`hostUpdate()` is called before the element is about to render or re-render. This is a good place to synchronize or compute state before rendering.
+
+`hostUpdated()` is called after an element has just rendered or re-rendered. This is a good place to synchronize or compute a state that is reliant on rendered DOM. It is often discouraged to request an update to the host in this part of the lifecycle unless absolutely necessary as it may cause an unnecessary re-render of the component just after it has already rendered. Request host updates in `hostUpdated()` only when `hostUpdate()` cannot be utilized and add guards against infinite re-renders.
+
+### ReactiveControllerHost
+
+Reactive Controllers typically have access to an instance of an object that implements the `ReactiveControllerHost` interface, which is often passed to them upon initialization. This allows the Reactive Controller to attach itself to the host and request that it update and re-render. Lit elements implement this so they can serve as Reactive Controller hosts, but the Reactive Controller pattern is not exclusive to Lit.
+
+The `ReactiveControllerHost` interface exposes three methods and one property:
+
+- [`addController(controller: ReactiveController)`](/docs/api/ReactiveElement/#ReactiveElement.addController)
+- [`removeController(controller: ReactiveController)`](/docs/api/ReactiveElement/#ReactiveElement.removeController)
+- [`requestUpdate()`](/docs/api/ReactiveElement/#ReactiveElement.requestUpdate)
+- [`updateComplete: Promise`](/docs/api/ReactiveElement/#ReactiveElement.updateComplete)
+
+The `addController()` method takes in the controller that you want to hook into the host’s lifecycle.
+
+{% aside "info" "no-header" %}
+
+If the host is already attached to the DOM or rendered onto the page – it is recommended that implementations of `ReactiveControllerHost` call `hostConnected()` after attaching the Reactive Controller via `addController()`.
+
+{% endaside %}
+
+A common pattern in Lit is to attach the current instance of the controller to the host in the `constructor()` of the ReactiveController. For example:
+
+```ts
+export class MyController implements ReactiveController {
+ private host: ReactiveControllerHost;
+
+ constructor(host: ReactiveControllerHost, options: MyOptions) {
+ this.host = host;
+ this.host.addController(this);
+ }
+}
+```
+
+The `removeController()` method is used less frequently than the other callbacks. It is useful when you do not want the controller to update with the host, such as: the host updates too often, the `hostUpdate()` or `hostUpdated()` methods have slow or expensive logic, or you do not need the controller to run its updates while the component has been removed from the document.
+
+The `requestUpdate()` method is used to request the host component to re-run its update lifecycle and re-render. This is often called when the controller has a value that updates and should be reflected in the DOM. For example, the `@lit/task` package’s `Task` controller will do asynchronous work like fetching data or asynchronous rendering, and it calls the host’s `requestUpdate()` method to reflect that the state of the task has changed to pending, in progress, completed, or error which should be rendered in the component.
+
+The read-only `updateComplete` property is often used in conjunction with `requestUpdate()` method. A `ReactiveControllerHost`’s update lifecycle is assumed to be asynchronous, so the `updateComplete` property is a promise that resolves when the host’s update lifecycle has completed. This is useful for controllers that need to update the DOM and then read from it. For example, imagine a controller that resizes a DOM element and needs to then read its new dimensions. This controller would update a property, call `requestUpdate()`, await `host.updateComplete`, and then read the DOM.
+
+## Redux Patterns
+
+Redux has a bit of verbosity associated with it in order to enforce the Redux state management patterns. In this article we will be making a simple component that renders circles and squares, renders how many of them exist, and can increment or decrement the amount of circles and squares. This component will have its state managed by Redux.
+
+
+
+### Initial State
+
+We need to define an initial state, so in a store. file we will give the initial state the following shape:
+
+```ts
+export type Shape = 'square' | 'circle';
+export type State = Record<`${Shape}s`, number> & {shapeList: Shape[]};
+
+const initialState: State = {
+ squares: 0,
+ circles: 0,
+ shapeList: []
+};
+```
+
+### Reducers
+
+Next we will write the reducer which will define which types of actions this store will be able to accept as well as determine how to update the state. Our reducer will have the following actions:
+
+- `incrementShape()`
+- `decrementShape()`
+- `resetShapes()`
+
+Here is how the reducer could look like in the store. file:
+
+```ts
+import {createSlice, PayloadAction} from '@reduxjs/toolkit';
+...
+
+const initialState: ShapesState = {squares: 0, circles: 0, shapeList: []};
+
+const shapeSlice = createSlice({
+ name: 'shapes',
+ initialState,
+ reducers: {
+ incrementShape: (state, action: PayloadAction) => {
+ const shape = action.payload;
+ state[`${shape}s`] += 1;
+ state.shapeList.push(shape);
+ },
+ decrementShape: (state, action: PayloadAction) => {
+ const shape = action.payload;
+ const index = state.shapeList.lastIndexOf(shape);
+ if (index > -1) {
+ state[`${shape}s`] -= 1;
+ state.shapeList.splice(index, 1);
+ }
+ },
+ resetShapes: (state) => {
+ state.circles = 0;
+ state.squares = 0;
+ state.shapeList = [];
+ }
+ }
+});
+
+export const {
+ incrementShape, decrementShape, resetShapes
+} = shapeSlice.actions
+```
+
+### Store
+
+Next we will create the store and initialize it with the reducer created by the `shapeSlice`:
+
+```ts
+import { configureStore } from '@reduxjs/toolkit';
+
+// The shapeSlice.reducer is the reducer we made in the last section
+const store = configureStore({reducer: shapeSlice.reducer});
+```
+
+We have now successfully created a Redux store that has an initial state and can update its state using the reducer.
+
+
+
+### Actions
+
+We can dispatch actions to the store using the [`store.dispatch(action)`](https://redux.js.org/api/store#dispatchaction) method. Let us create a `shape-dials` element that has circle and square increment buttons as well as a reset button. And upon click, will dispatch the appropriate actions:
+
+
+
+```ts
+import { LitElement, html, css } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import {
+ incrementShape,
+ decrementShape,
+ resetShapes
+} from './store.js';
+
+
+@customElement('shape-dials')
+export class ShapeDials extends LitElement {
+ render() {
+ return html`
+
+
+ circles
+
+
+
+
+
+ squares
+
+
+
+
+ `;
+ }
+
+ /*
+ Event listener callbacks broadcast actions using a store's
+ own dispatch method.
+ */
+ private decrementCircles() {
+ store.dispatch(decrementShape('circle'));
+ }
+
+ private incrementCircles() {
+ store.dispatch(incrementShape('circle'));
+ }
+
+ private decrementSquares() {
+ store.dispatch(decrementShape('square'));
+ }
+
+ private incrementSquares() {
+ store.dispatch(incrementShape('square'));
+ }
+
+ private reset() {
+ store.dispatch(resetShapes());
+ }
+}
+```
+
+## Writing the SelectorController Reactive Controller
+
+Now we have our state managed by Redux and are dispatching actions to the store. The store is updating its state using the reducer.
+
+
+
+Next we need to give some elements the ability to connect and subscribe to changes to the store and update the UI. On top of that we will write a “Selector” which will select a specific datum from the overall state, and if that value changes, we will tell the Reactive Controller host to re-render.
+
+### Attaching the Controller and Accepting options
+
+First we will write the definition of the `SelectorController` class and attach it to the host element so that the Reactive Controller can hook into the host’s reactive update lifecycle:
+
+```ts
+import type { ReactiveController, ReactiveControllerHost } from 'lit';
+
+export class SelectorController implements ReactiveController {
+ host: ReactiveControllerHost;
+
+ constructor(host: ReactiveControllerHost) {
+ this.host = host;
+ host.addController(this);
+ }
+}
+```
+
+Next, let's accept the following options: the Redux store in which we would like to subscribe to, as well as the Selector we would like to use to select what data we would like to pick out from the store:
+
+```ts
+import type { Store, Action as RAction } from '@reduxjs/toolkit';
+...
+
+export type Selector = (state: State) => Result;
+
+export class SelectorController<
+ State,
+ Action extends RAction,
+ Result = unknown
+> implements ReactiveController {
+ private _host: ReactiveControllerHost;
+ private _store: Store;
+ private _selector: Selector;
+
+ constructor(
+ host: ReactiveControllerHost,
+ store: Store,
+ selector: Selector,
+ ) {
+ this._host = host;
+ host.addController(this);
+
+ this._store = store;
+ this._selector = selector;
+ }
+}
+```
+
+Now let’s initialize the initially selected value so that the user can access the state’s initial value with `selectorController.value` using the user's selector:
+
+```ts
+...
+export class SelectorController<
+ State,
+ Action extends RAction,
+ Result = unknown
+> implements ReactiveController {
+ ...
+ value: Result;
+
+ constructor(
+ host: ReactiveControllerHost,
+ store: Store,
+ selector: Selector,
+ ) {
+ ...
+ this.value = selector(store.getState());
+ }
+}
+```
+
+### Updating the Component on Store Update
+
+We now have a controller that initializes to the initial state of the store, next let’s update `this.value` when the state updates and then tell the host element to re-render when we have detected a change in the selected value.
+
+Redux stores have a [`Store.subscribe(listener)`](https://redux.js.org/api/store#subscribelistener) method which will call a given callback whenever the state of the store updates. Let's hook into this, update `this.value`, and tell the host to update when the component is connected to the DOM:
+
+```ts
+...
+export class SelectorController<
+ State,
+ Action extends RAction,
+ Result = unknown
+> implements ReactiveController {
+ ...
+
+ hostConnected() {
+ this.store.subscribe(() => {
+ const selected = this._selector(this._store.getState());
+ if (selected !== this.value) {
+ this.value = selected;
+ this._host.requestUpdate();
+ }
+ });
+ }
+}
+```
+
+Great! Now the controller will update its value and tell the host element to update whenever the state changes. Additionally, by first comparing the previous state to the current state, we can avoid re-rendering components that don't need to be re-rendered which can improve performance. Nice!
+
+In conclusion, we need to improve our component so that it does not re-render when the component is disconnected from the page and the store’s state changes. Redux’s `Store.subscribe()` method returns an `unsubscribe()` function. Let’s keep track of this and unsubscribe from the store’s changes when the component disconnects.
+
+```ts
+...
+export class SelectorController<
+ State,
+ Action extends RAction,
+ Result = unknown
+> implements ReactiveController {
+ ...
+ private _unsubscribe: () => void;
+ ...
+
+ hostConnected() {
+ this._unsubscribe = this._store.subscribe(() => {
+ ...
+ });
+ }
+
+ hostDisconnected() {
+ this._unsubscribe();
+ }
+}
+```
+
+We now have a Redux `SelectorController` Reactive Controller that listens to a Redux store for state changes, selects a value from the state, and updates the host element whenever that state value changes!
+
+## Using the Controllers
+
+Now that we have a functioning controller, let’s use it! Let’s create two components and update our shape dial’s controls:
+
+1. `shape-count`
+ - A component that counts the shapes and the total number of shapes
+2. `shape-list`
+ - A component that visualizes the shapes that we have added
+
+And finally we will update `shape-dials` to disable the buttons when they are not applicable. The application should look something like this:
+
+
+
+### shape-count
+
+`shape-count` should be a component that only subscribes and reads from the store. Let us create a custom element and render the table:
+
+```ts
+import { LitElement, html} from 'lit';
+import { customElement} from 'lit/decorators.js';
+
+@customElement('shape-count')
+export class ShapeCount extends LitElement {
+ render() {
+ return html`
+
+
+
${0}
+
circles
+
+
+
${0}
+
squares
+
+
+
${0}
+
total
+
+
+ `;
+ }
+}
+```
+
+Next we want to render actual counts rather than just `0`. In this case we will need all values from the state:
+
+- `state.circles`
+- `state.squares`
+- `state.shapeList`
+
+To achieve this, we will need a broad Redux selector that selects the entire state:
+
+```ts
+...
+import { SelectorController } from './selector-controller.js';
+import { store } from './store.js';
+
+@customElement('shape-count')
+export class ShapeCount extends LitElement {
+ private sc = new SelectorController(this, store, (state) => state);
+
+ render() {
+ const {circles, squares, shapeList} = this.sc.value;
+
+ return html`
+
+
+
${circles}
+
circles
+
+
+
${squares}
+
squares
+
+
+
${shapeList.length}
+
total
+
+
+ `;
+ }
+}
+```
+
+The counts are now pulled from the state in Redux and any updates to the state will update the component!
+
+To accomplish this, we initialized the `SelectorController` with the shared Redux store and rendered the entire state.
+
+### shape-list
+
+`shape-list` should be a component that only subscribes and reads the `state.shapeList` state from the store. Let's create a custom element with boilerplate, and render an array of `
` elements with classes set to the shape name. Our pre-provided CSS styles will render the squares and circles based on the class name.
+
+```ts
+import { customElement } from 'lit/decorators.js';
+import { LitElement, html} from 'lit';
+
+@customElement('shape-list')
+export class ShapeList extends LitElement {
+ render() {
+ const shapeList = [];
+
+ return shapeList.map(
+ shape => html``
+ );
+ }
+}
+```
+
+Next, let’s initialize our `SelectorController` to hook into Redux and render the `shapeList`:
+
+```ts
+...
+import { SelectorController } from './selector-controller.js';
+import { store } from './store.js';
+
+@customElement('shape-list')
+export class ShapeList extends LitElement {
+ private sc = new SelectorController(this, store, (state) => state.shapeList);
+
+ render() {
+ const shapeList = this.sc.value;
+ ...
+ }
+}
+```
+
+The `shape-list` component should now be responsive to changes in the Redux store!
+
+We were able to accomplish this by initializing the `SelectorController` with the shared Redux store. We then selected only the `shapeList` from the state and updated the host element only when the arrays were truly not equal.
+
+### Preventing invalid inputs in shape-dials
+
+To prevent invalid inputs, we will use our `SelectorController` to disable the buttons in the `shape-dials` component. For example, we want to disable the decrement buttons when the respective shape count is 0, and we want to disable the reset button when the length of the `shapeList` is 0.
+
+We will be using the entire state object again, so the selector will be broad. Let’s add the SelectorController to our `shape-dials` component.
+
+```ts
+...
+import {
+ ...
+ store,
+} from './store.js';
+import { SelectorController } from './selector-controller.js';
+
+@customElement('shape-dials')
+export class ShapeDials extends LitElement {
+ private sc = new SelectorController(this, store, (state) => state);
+
+ render() {
+ const {circles, squares, shapeList} = this.sc.value;
+
+ return html`
+
+
+ circles
+
+
+
+
+
+ squares
+
+
+
+
+ `;
+ }
+
+ ...
+}
+```
+
+We should now have the decrement and reset buttons disabled upon invalid input, and a fully functional Lit-Redux application!
+
+## Integrating Other Libraries
+
+`SelectorController` is a simple integration. It only handles selectors, but it could easily abstract more of Redux into the controller, such as automatically dispatching actions when a property changes. Reactive Controllers give you the freedom to abstract as little or as much of a library as you want.
+
+### State Managers
+
+Reactive Controllers are useful for integrating state managers because it is common to want to update the view whenever the state changes or update the state manager when the UI changes.
+
+A great example of using Reactive Controllers for state management is the Apollo Element’s [Apollo Query Reactive Controllers](https://apolloelements.dev/api/core/controllers/query/) for Apollo GraphQL. Reactive Controllers can fit nicely in other similar projects like [MobX](https://mobx.js.org/README.html), [RxJS](https://rxjs.dev/), or [Zustand](https://github.com/pmndrs/zustand#using-zustand-without-react).
+
+### Data Fetching and Rendering Libraries
+
+Reactive Controllers are also useful for integrating libraries that fetch data and need to synchronize with Lit’s rendering or update lifecycle.
+
+For example, the [@lit/task](/docs/data/task/) library can perform simple async logic and easily render results and status. Reactive Controllers can fit nicely in other similar projects such as [Axios](https://axios-http.com/), [TanStack Query](https://tanstack.com/query/latest), or the experimental [@lit-labs/router](https://www.npmjs.com/package/@lit-labs/router).
+
+### Your Own Bespoke Behavior
+
+Reactive Controllers are generally a good way to share logic across components and we cannot cover every possible use case here. For example [Material Design’s Material Components](https://material-web.dev) have written their own bespoke controllers such as the [SurfacePositionController](https://github.com/material-components/material-web/blob/v1.1.1/menu/internal/controllers/surfacePositionController.ts) which can position a popup surface next to an anchor or [TypeaheadController](https://github.com/material-components/material-web/blob/v1.1.1/menu/internal/controllers/typeaheadController.ts) which can automatically select an item from a list just by typing the first few letters of the item like an autocomplete.
+
+Reactive Controllers are flexible, focused, and a great way to integrate libraries into your Lit project or any framework of your choice.
\ No newline at end of file
diff --git a/packages/lit-dev-content/site/css/articles.css b/packages/lit-dev-content/site/css/articles.css
index 536718eb5..c38789562 100644
--- a/packages/lit-dev-content/site/css/articles.css
+++ b/packages/lit-dev-content/site/css/articles.css
@@ -7,6 +7,16 @@ main {
padding-block-start: 0;
}
+article img {
+ max-width: 100%;
+ display: block;
+ margin-inline: auto;
+}
+
+article img:not([width]) {
+ width: 100%;
+}
+
/* ------------------------------------
* Header Section
* ------------------------------------ */
@@ -74,7 +84,6 @@ main {
* ------------------------------------ */
article {
font-size: 1rem;
- font-family: 'Manrope', sans-serif;
}
.articleFeedList {
diff --git a/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/application-sans-labels.webp b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/application-sans-labels.webp
new file mode 100644
index 000000000..cea1caeb7
Binary files /dev/null and b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/application-sans-labels.webp differ
diff --git a/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/application.webp b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/application.webp
new file mode 100644
index 000000000..7a72ab6de
Binary files /dev/null and b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/application.webp differ
diff --git a/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/mixins-vs-controllers.webp b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/mixins-vs-controllers.webp
new file mode 100644
index 000000000..4514b72a7
Binary files /dev/null and b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/mixins-vs-controllers.webp differ
diff --git a/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/redux-completed-2x.webp b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/redux-completed-2x.webp
new file mode 100644
index 000000000..66fbe1d4d
Binary files /dev/null and b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/redux-completed-2x.webp differ
diff --git a/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/redux-completed.webp b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/redux-completed.webp
new file mode 100644
index 000000000..c6d971c2e
Binary files /dev/null and b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/redux-completed.webp differ
diff --git a/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/redux-cycle-2x.webp b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/redux-cycle-2x.webp
new file mode 100644
index 000000000..d37eedf8f
Binary files /dev/null and b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/redux-cycle-2x.webp differ
diff --git a/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/redux-cycle.webp b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/redux-cycle.webp
new file mode 100644
index 000000000..102bca81b
Binary files /dev/null and b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/redux-cycle.webp differ
diff --git a/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/shape-dials.webp b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/shape-dials.webp
new file mode 100644
index 000000000..1b536fab0
Binary files /dev/null and b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/shape-dials.webp differ
diff --git a/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/ui-completed-2x.webp b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/ui-completed-2x.webp
new file mode 100644
index 000000000..55fd48bec
Binary files /dev/null and b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/ui-completed-2x.webp differ
diff --git a/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/ui-completed.webp b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/ui-completed.webp
new file mode 100644
index 000000000..63856e191
Binary files /dev/null and b/packages/lit-dev-content/site/images/articles/redux-reactive-controllers/ui-completed.webp differ
diff --git a/packages/lit-dev-content/site/images/articles/redux_lit_controller.webp b/packages/lit-dev-content/site/images/articles/redux_lit_controller.webp
new file mode 100644
index 000000000..600183c92
Binary files /dev/null and b/packages/lit-dev-content/site/images/articles/redux_lit_controller.webp differ
diff --git a/packages/lit-dev-content/site/images/articles/redux_lit_controller_2x.webp b/packages/lit-dev-content/site/images/articles/redux_lit_controller_2x.webp
new file mode 100644
index 000000000..22db836ce
Binary files /dev/null and b/packages/lit-dev-content/site/images/articles/redux_lit_controller_2x.webp differ
diff --git a/packages/lit-dev-content/site/images/authors/brian-vann.webp b/packages/lit-dev-content/site/images/authors/brian-vann.webp
new file mode 100644
index 000000000..347bc6f94
Binary files /dev/null and b/packages/lit-dev-content/site/images/authors/brian-vann.webp differ
diff --git a/packages/lit-dev-content/site/images/social/linkedin.svg b/packages/lit-dev-content/site/images/social/linkedin.svg
new file mode 100644
index 000000000..a2abddcee
--- /dev/null
+++ b/packages/lit-dev-content/site/images/social/linkedin.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/packages/lit-dev-content/site/learn/index.html b/packages/lit-dev-content/site/learn/index.html
index 74293794f..1f4ebfa19 100644
--- a/packages/lit-dev-content/site/learn/index.html
+++ b/packages/lit-dev-content/site/learn/index.html
@@ -38,10 +38,10 @@
Learn
/>
{% elif content.kind == "article" and content.thumbnail %}
{
}
}
if (kind === "article" || kind === "tutorial") {
- const { thumbnail } = content;
+ const { thumbnail, thumbnailExtension } = content;
if (thumbnail) {
// An article or tutorial can optionally provide an image thumbnail
// (without a suffix).
const expectedImages = [
- `${thumbnail}_2x.jpg`,
- `${thumbnail}.jpg`
+ `${thumbnail}_2x.${thumbnailExtension || 'jpg'}`,
+ `${thumbnail}.${thumbnailExtension || 'jpg'}`
];
expectedImages.forEach(validateImageExists);
}
diff --git a/packages/lit-dev-tests/known-good-urls.txt b/packages/lit-dev-tests/known-good-urls.txt
index b65d68348..7ce5a7d67 100644
--- a/packages/lit-dev-tests/known-good-urls.txt
+++ b/packages/lit-dev-tests/known-good-urls.txt
@@ -1,5 +1,5 @@
# External URLS that are known to be 200s, so that we don't need to hit them
-# every time we run on CI, so that it's way les flaky.
+# every time we run on CI, so that it's way less flaky.
http://localhost:8000/dev/
https://twitter.com/buildWithLit
https://shoelace.style/#:~:text=LitElement
@@ -516,3 +516,4 @@ https://www.youtube.com/watch?v=l6Gn5uV83sw
https://github.com/e111077
https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js
https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js
+https://www.linkedin.com/in/brian-vann/
\ No newline at end of file
diff --git a/packages/lit-dev-tests/src/playwright/learn.spec.js-snapshots/learnCatalog-darwin.png b/packages/lit-dev-tests/src/playwright/learn.spec.js-snapshots/learnCatalog-darwin.png
index d36039cbf..f8758cbf0 100644
Binary files a/packages/lit-dev-tests/src/playwright/learn.spec.js-snapshots/learnCatalog-darwin.png and b/packages/lit-dev-tests/src/playwright/learn.spec.js-snapshots/learnCatalog-darwin.png differ