src/next is src (the main export), src/next-node is src/node with short subpaths (/http, /workers, /plugins, /plugins/runtime, /app, /surfaces, /web, /react, /tui, /tui/launcher); the foundations get /json and /watch; Chord v1 moves to src/old (/old, /old/node) until p2 runs on v2; tests and docs move likewise (test/, docs/, test/old, docs/old). Durable and env import the foundations from their subpaths.
12 KiB
title: Looks and Themes: how an app shares its styles and components with plugins cover: Looks / and Themes kicker: A proposal · Chord v2 · 2026-11-02 subtitle: One app, a notes plugin and a stranger's theme plugin, on a browser page and in a terminal stats:
- 1 | registry for themes, shared by web and terminal
- 0 | copies of the app's components inside plugins
- 0 | page reloads to switch a theme
note: |
A proposal by the redesign session, asked for by the owner on 2026-11-02 ("can you write a small explainer about
what you envision"). It builds on decided rules: the app's versioned API split by host kind, plugins importing only
that API, registries for what plugins add (decisions of 2026-11-01 in
decisions.md). Nothing here is built; slice 11 (web and TUI) and slice 13 (the app kit) are where it would land. colophon: Looks and Themes · proposal · 2026-11-02
Before we begin
Acme's notes app has a look: a sidebar, cards with rounded corners, an orange accent, a dark mode. Its own plugins draw panels inside that look, and so should a stranger's plugin. Then someone wants a Solarized theme, and writes it as a plugin. And the same app also runs in a terminal, where there are no CSS files and no React, only text and colours.
Three questions follow. How does a plugin get the app's components and styles without carrying its own copy? How does it style its own parts so they fit, without stepping on other plugins? And how does a plugin contribute a theme that the app, every other plugin and both surfaces pick up?
Five things to know
:::findings
- Shared components come from the app's API. The web module of the app's API (
@acme/app/v3/web) exports them; it is in the base bundle once, and every plugin uses that instance. - Looks are design tokens. Colours, radii and fonts are CSS custom properties on the page (
--accent,--bg); the app's components and every plugin's CSS use them. - A plugin's own CSS is scoped. CSS modules (
notes.module.css) keep its class names its own; the build already collects and ships each facet's stylesheet. - A theme is data in one registry.
Themeslives in the app's shared API module; an entry names colours for roles (accent, background, text, ...). The web turns the active one into CSS variables, the terminal into colours. - A plugin contributes a theme like anything else. It contributes an entry to
Themes; the picker shows it, choosing it restyles the page and the terminal at once, editing the plugin restyles them on its rollout. :::
Words used throughout
The app's API is the versioned set of modules plugins may import (@acme/app/v3, shared by every host kind, and
@acme/app/v3/web, /v3/tui for one kind). A host package is a module the running process provides once to all
plugins. A design token is a named value of the look (the accent colour), written as a CSS custom property
(--accent) on the web. A role is what a colour is for (accent, background, error), as opposed to the colour
itself. A theme is one set of values for every role. A registry is an extension point plugins contribute to.
Shared Components
A plugin's script is built without the app's API: imports of it are left as names and answered by the page at load time (decision "Plugins import only the app's versioned API"). So when the app exports components from its web module, every plugin gets the very same functions, from the base bundle the page loaded once:
// src/api/v3/web.ts (the app)
export { Sheet, Button, List, Toolbar } from "../../web/components.tsx";
export { Panels } from "./panels.web.ts";
// plugins/notes/web.tsx
import { defineFacet } from "@acme/app/v3";
import { Panels, Sheet, Button } from "@acme/app/v3/web";
export default defineFacet((host, self) => {
self.contribute(Panels, { title: "Notes", Component: () => <Sheet><Button>New note</Button></Sheet> });
});
Two things follow. A plugin never ships a copy of the app's components, so a page with twenty plugins loads them once. And when the app improves a component, every plugin shows the improvement after the app's next restart, without being rebuilt. Components that change shape incompatibly get a new API version, as any API does.
Looks as Tokens
The app's stylesheet, part of the base bundle, defines the look as CSS custom properties on the page's root:
:root {
--accent: #e8590c;
--bg: #111317;
--fg: #e8e8e8;
--muted: #8a8f98;
--radius: 10px;
--font: "Inter", system-ui, sans-serif;
}
The app's components use only these variables, never fixed colours. A plugin does the same in its own CSS:
/* plugins/notes/notes.module.css */
.card { background: color-mix(in srgb, var(--bg) 92%, var(--fg)); border-radius: var(--radius); }
.title { color: var(--accent); font-family: var(--font); }
import styles from "./notes.module.css";
<div className={styles.card}><h2 className={styles.title}>Shopping</h2></div>
Because everything reads the variables, changing the variables changes everything: the app's components and every plugin's parts, at once, without reloading anything. That is what makes themes cheap.
Keeping plugins apart
Two plugins both writing .card { ... } would overwrite each other's styles. CSS modules solve that: esbuild gives each
class in a *.module.css file a name unique to that file, and the import returns the mapping. The build already
collects each web facet's CSS into one stylesheet, served next to its script and swapped when the plugin reloads, so
nothing new is needed except the convention. Plain CSS files still work; their class names should then start with the
plugin's name.
The Terminal
A terminal has no stylesheets, but the same split works. The app's terminal module exports its components, built on whichever terminal UI library the app chose, and a theme lookup by role:
// plugins/notes/tui.ts
import { defineFacet } from "@acme/app/v3";
import { Panels, Box, theme } from "@acme/app/v3/tui";
const render = (notes) => Box({ title: theme.fg("accent", "Notes"), lines: notes.map((n) => theme.fg("fg", n.text)) });
A plugin never writes raw escape codes; it asks for a role (accent, muted, error), and the app's lookup turns
the active theme's colour into what the terminal supports: true colour, or the nearest of 256 colours.
Themes as a Registry
A theme names a colour for each role. Roles, not surfaces: an accent is an accent on a page and in a terminal. So the registry lives in the app's shared API module, and its entries are plain data, safe to import from any host kind:
// src/api/v3/index.ts (the app)
export type Theme = {
id: string;
name: string;
mode: "dark" | "light";
colors: { accent: string; bg: string; fg: string; muted: string; error: string; warning: string; success: string };
web?: Readonly<Record<string, string>>; // extra CSS variables: radius, fonts, shadows
tui?: Readonly<Record<string, string>>; // terminal-only overrides: a 256-colour fallback for a role
};
export const Themes = defineRegistry<Theme, readonly Theme[]>("acme.themes", { version: 1 });
The app contributes its default themes from its own plugins, like any other contribution. Its theme picker lists the registry's current entries; the choice is a user preference.
:::flow wide animate | A theme from a plugin reaches the page and the terminal through one registry. row "Plugins" muted box sol "solarized plugin" "contributes a Theme" box app "app's theme plugin" "contributes the defaults" row "Shared API" accent box reg "Themes registry" "data: roles to colours" accent row "Surfaces" blue box web "web page" "active theme → CSS variables on :root" blue box tui "terminal" "active theme → colours by role" blue arrow sol -> reg "contribute" arrow app -> reg "contribute" arrow reg -> web "watch" arrow reg -> tui "watch" :::
On the web, the page watches Themes and the user's choice; when either changes it writes the active theme's
colours (and its web extras) as CSS variables on :root. Nothing reloads; the browser repaints.
In the terminal, the app's theme lookup reads the same entry; the next redraw uses the new colours.
A Theme Plugin
A theme plugin is the smallest kind of plugin there is: data, contributed. As single-file plugins (decision "Single-file plugins"), one per surface it wants to be visible on, sharing the data:
// plugins/solarized.web.ts
import { defineFacet, Themes } from "@acme/app/v3";
import { solarized } from "./solarized-data.ts"; // or inline: single-file plugins import only the API
export default defineFacet((_host, self) => self.contribute(Themes, solarized));
Since a single-file plugin may import only the app's API, a theme with two surfaces is either two files with the data
inlined, or a small directory plugin (plugins/solarized/ with theme.ts, web.ts, tui.ts).
When its author changes a colour, the watcher marks the plugin dirty; the next rollout reloads it on the server and in every open page and terminal; the registry recomposes; pages that use Solarized repaint. No page reloads, nothing on screen is lost.
When the app changes its roles
The app adds a role (link) in a later release. Old themes do not name it. The registry's version guards this: the app
bumps Themes to version 2 and maps version 1 entries by filling the new role from the default theme. An old theme
keeps working, slightly less complete; nobody's page breaks.
What a Plugin Cannot Do
A plugin cannot change the app's base stylesheet or its components: those are base code, changed by the app's
release. It can contribute a theme (the app's looks, re-valued), style its own parts with scoped CSS that reads the
tokens, and place UI through registries like Panels. That line keeps a page coherent with twenty plugins from
twenty authors, and keeps one bad plugin from breaking everyone's layout.
Open Questions
- The roles. A fixed set the kit defines (accent, background, text, muted, error, warning, success) or each app's own. Recommendation: the kit defines a base set and an app may add roles; themes fill the base set at least.
- Where
Themeslives. In the app kit (every kit app gets it and the CSS-variable writer) or in each app. Recommendation: the kit ships it, with the picker's preference and the writer; apps add roles and their defaults. - Light and dark. A theme carries one mode; following the operating system's preference picks the user's chosen dark or light theme. Recommendation: yes, two choices per user (one dark, one light).
- Fonts and assets in themes. Allowed through
webextras with URLs to the plugin's assets (the build serves them), or colours only. Recommendation: allowed, under the page's CSP (font-src,img-srcself).
Reference
| Piece | Where | What |
|---|---|---|
| shared components (web) | @acme/app/v3/web |
React components, one instance in the base bundle |
| shared components (terminal) | @acme/app/v3/tui |
the app's terminal components and theme lookup |
| design tokens | the app's base stylesheet | CSS custom properties on :root |
| plugin styles | *.module.css in the plugin |
scoped class names, shipped with the facet's script |
Themes |
@acme/app/v3 (shared) |
registry of theme data, read by both surfaces |
| theme plugin | plugins/<name>.web.ts, .tui.ts or a directory |
contributes a Theme |