571 lines
17 KiB
Markdown
571 lines
17 KiB
Markdown
<!-- go-doc:generated:begin source=go-bananas:docs/integration.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
|
|
Imported from docs/integration.md in the go-bananas repository.
|
|
|
|
---
|
|
|
|
# Integrating a go-bananas theme
|
|
|
|
Everything here works without installing anything. A theme is data delivered over
|
|
HTTP, so the integration is a URL, not a dependency.
|
|
|
|
---
|
|
|
|
## Contents
|
|
|
|
1. [The thirty-second version](#the-thirty-second-version)
|
|
2. [How theming works](#how-theming-works)
|
|
3. [Choosing an integration](#choosing-an-integration)
|
|
4. [Any HTML page](#1-any-html-page)
|
|
5. [Plain JavaScript](#2-plain-javascript)
|
|
6. [React](#3-react)
|
|
7. [Server-rendered frameworks](#4-server-rendered-frameworks)
|
|
8. [Tailwind](#5-tailwind)
|
|
9. [Reading tokens in JavaScript](#reading-tokens-in-javascript)
|
|
10. [Email and documents](#email-and-documents)
|
|
11. [Runtime switching](#runtime-switching)
|
|
12. [Versioning and caching](#versioning-and-caching)
|
|
13. [Offline and failure behaviour](#offline-and-failure-behaviour)
|
|
14. [Multiple themes on one page](#multiple-themes-on-one-page)
|
|
|
|
---
|
|
|
|
## The thirty-second version
|
|
|
|
```html
|
|
<link rel="stylesheet" href="https://themes.example.com/rt/v1/theme.css?slug=midnight">
|
|
```
|
|
|
|
Done. Every `--bn-*` variable is now defined, and the bundled `bn-*` component
|
|
classes are available.
|
|
|
|
---
|
|
|
|
## How theming works
|
|
|
|
A theme renders to a stylesheet of CSS custom properties, all prefixed `--bn-`, plus
|
|
a small component layer.
|
|
|
|
```css
|
|
[data-bananas-theme="midnight"] {
|
|
--bn-color-semantic-background: #1a1d23;
|
|
--bn-color-semantic-text: #d9dbe5;
|
|
--bn-color-semantic-primary: #7c6bf5;
|
|
--bn-space-4: 1rem;
|
|
--bn-radius-lg: 0.75rem;
|
|
}
|
|
|
|
[data-bananas-theme="midnight"][data-bananas-appearance="dark"] { /* … */ }
|
|
|
|
.dark [data-bananas-theme="midnight"],
|
|
[data-bananas-theme="midnight"].dark { /* … */ }
|
|
```
|
|
|
|
Two attributes do all the work:
|
|
|
|
| Attribute | Values | Effect |
|
|
| --- | --- | --- |
|
|
| `data-bananas-theme` | a theme slug | Activates that theme on the element and its subtree |
|
|
| `data-bananas-appearance` | `light`, `dark` | Selects the colour scheme |
|
|
|
|
Put `data-bananas-theme` on `<html>` for the whole page, or on any element for a
|
|
subtree. Several themes can coexist.
|
|
|
|
The stylesheet also honours a `.dark` ancestor class and `prefers-color-scheme`, so it
|
|
fits an application that already toggles dark mode the Tailwind or shadcn way.
|
|
|
|
### The component layer
|
|
|
|
The generated stylesheet includes a small set of classes written entirely in terms of
|
|
the variables: `.bn-btn`, `.bn-card`, `.bn-input`, `.bn-badge`, `.bn-alert`,
|
|
`.bn-table`, `.bn-link`.
|
|
|
|
Use them to look correct immediately, then replace them with your own components as
|
|
soon as you want them. They exist so that the first integration needs no CSS work at
|
|
all.
|
|
|
|
They are placed in `@layer bananas`, so your own unlayered CSS always wins the
|
|
cascade. You will never need `!important` to override them.
|
|
|
|
Request them with `?variant=tokens` if you want the variables without the component
|
|
layer.
|
|
|
|
---
|
|
|
|
## Choosing an integration
|
|
|
|
| Your stack | Use |
|
|
| --- | --- |
|
|
| Server-rendered HTML, any framework | [Any HTML page](#1-any-html-page) |
|
|
| A SPA you control | [Plain JavaScript](#2-plain-javascript) |
|
|
| React | [React](#3-react) |
|
|
| Django, Rails, Laravel, Go templates, Jinja | [Server-rendered](#4-server-rendered-frameworks) |
|
|
| Tailwind | [Tailwind](#5-tailwind) |
|
|
| Canvas, charts, PDFs, email | [Reading tokens](#reading-tokens-in-javascript) |
|
|
|
|
---
|
|
|
|
## 1. Any HTML page
|
|
|
|
The stylesheet link. Two tags if you want the theme to follow the operating system.
|
|
|
|
```html
|
|
<link rel="stylesheet" href="https://themes.example.com/rt/v1/theme.css?slug=midnight">
|
|
<script async src="https://themes.example.com/rt/v1/embed.js"
|
|
data-theme="midnight"
|
|
data-appearance="system"></script>
|
|
```
|
|
|
|
`embed.js` is a single file with no dependencies. It sets the attributes and fetches
|
|
the stylesheet before first paint.
|
|
|
|
Configure it with data attributes:
|
|
|
|
| Attribute | Values | Default |
|
|
| --- | --- | --- |
|
|
| `data-theme` | a theme slug | `default` |
|
|
| `data-appearance` | `light`, `dark`, `system` | `system` |
|
|
| `data-variant` | `full`, `tokens`, `minified` | `full` |
|
|
| `data-storage` | `false` to skip localStorage | persists |
|
|
| `data-auto-dark` | `true` to follow the system | follows `data-appearance` |
|
|
|
|
A generated snippet for any theme is at `/rt/v1/embed?slug=<slug>`, including a live
|
|
preview of what it looks like.
|
|
|
|
---
|
|
|
|
## 2. Plain JavaScript
|
|
|
|
An ES module loaded by URL.
|
|
|
|
```html
|
|
<script type="module">
|
|
import { applyTheme, watchSystemAppearance } from 'https://themes.example.com/rt/v1/runtime.js'
|
|
|
|
watchSystemAppearance() // follow the operating system preference
|
|
await applyTheme('midnight') // fetch and apply
|
|
</script>
|
|
```
|
|
|
|
Or with a `package.json`-free bundler, by aliasing the URL:
|
|
|
|
```js
|
|
// vite.config.js, webpack, esbuild — the tool does not care which.
|
|
resolve: { alias: { 'go-bananas/runtime': 'https://themes.example.com/rt/v1/runtime.js' } }
|
|
```
|
|
|
|
The module API:
|
|
|
|
| Export | Purpose |
|
|
| --- | --- |
|
|
| `applyTheme(slug, options?)` | Fetch and apply. Returns the resolved payload. |
|
|
| `setAppearance(appearance)` | Change the scheme without re-fetching. |
|
|
| `getAppearance()`, `getSlug()` | Read the current state. |
|
|
| `clearTheme()` | Remove the theme, restoring the page's own styles. |
|
|
| `loadManifest()` | Fetch the available themes. |
|
|
| `loadTheme(slug, appearance?)` | Fetch one theme's resolved tokens. |
|
|
| `getVariables(name?)` | Read a token by name, or all of them. |
|
|
| `watchSystemAppearance(callback?)` | Follow the operating system. Returns an unsubscribe. |
|
|
| `subscribe(listener)` | Observe theme changes. Returns an unsubscribe. |
|
|
| `assetUrl(path, query?)` | Build a runtime asset URL, for your own fetches. |
|
|
|
|
There is also a global build that attaches the same API to `window.Bananas`, for a
|
|
page that cannot use modules:
|
|
|
|
```html
|
|
<script src="https://themes.example.com/rt/v1/global.js"></script>
|
|
<script>Bananas.applyTheme('midnight')</script>
|
|
```
|
|
|
|
---
|
|
|
|
## 3. React
|
|
|
|
A provider and hooks, loaded through an import map. **React stays your own copy.**
|
|
The module imports the bare specifier `react`, which the import map resolves to the
|
|
application's own React — two copies of React in one page break hooks, because each
|
|
copy has its own dispatcher state.
|
|
|
|
```html
|
|
<script type="module">
|
|
import { BananasProvider } from 'https://themes.example.com/rt/v1/react.js'
|
|
import { createRoot } from 'https://esm.sh/react-dom@19/client'
|
|
|
|
createRoot(document.getElementById('root')).render(
|
|
<BananasProvider slug="midnight" autoDark>
|
|
<App />
|
|
</BananasProvider>,
|
|
)
|
|
</script>
|
|
```
|
|
|
|
Or in a bundled application, alias the URL to a bare specifier:
|
|
|
|
```js
|
|
// vite.config.js
|
|
resolve: { alias: { 'go-bananas/runtime': 'https://themes.example.com/rt/v1/react.js' } }
|
|
```
|
|
|
|
```jsx
|
|
import { BananasProvider, useBananas, useThemeVariable } from 'go-bananas/runtime'
|
|
```
|
|
|
|
### Props
|
|
|
|
| Prop | Default | Purpose |
|
|
| --- | --- | --- |
|
|
| `slug` | — | The theme to apply |
|
|
| `appearance` | `system` | `light`, `dark` or `system` |
|
|
| `autoDark` | `false` | Follow the operating system |
|
|
| `baseUrl` | inferred | Runtime base, for a cross-origin deployment |
|
|
| `storage` | `true` | Persist the user's choice |
|
|
| `variant` | `full` | Render variant |
|
|
| `loadManifest` | `false` | Fetch the theme list on mount |
|
|
| `onError` | — | Called when loading fails |
|
|
|
|
### Hooks
|
|
|
|
```jsx
|
|
import { useBananas, useThemeVariable, useThemeVariables, useAvailableThemes } from 'go-bananas/runtime'
|
|
|
|
function ThemeSwitcher() {
|
|
const { slug, appearance, setTheme, setAppearance, status } = useBananas()
|
|
const { themes } = useAvailableThemes()
|
|
|
|
if (status === 'loading') return <Spinner />
|
|
|
|
return (
|
|
<>
|
|
<select value={slug ?? ''} onChange={(event) => setTheme(event.target.value)}>
|
|
{themes.map((theme) => (
|
|
<option key={theme.slug} value={theme.slug}>{theme.name}</option>
|
|
))}
|
|
</select>
|
|
|
|
<button onClick={() => setAppearance(appearance === 'dark' ? 'light' : 'dark')}>
|
|
{appearance === 'dark' ? 'Light' : 'Dark'}
|
|
</button>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function ThemedButton() {
|
|
// Returns the resolved CSS value, so the component works in any scheme.
|
|
const background = useThemeVariable('color-semantic-primary')
|
|
const foreground = useThemeVariable('on-primary')
|
|
return <button style={{ background, color: foreground }}>Save</button>
|
|
}
|
|
```
|
|
|
|
`useBananas()` throws when used outside a provider, rather than returning a default.
|
|
A silent default would let a component render with the wrong theme and never report
|
|
the mistake.
|
|
|
|
### Cross-application consistency
|
|
|
|
`useSyncExternalStore` is used under the provider, so a theme change made outside
|
|
React — a switcher in another part of the page, a browser navigation, a
|
|
`postMessage` from an iframe — repaints the tree rather than tearing it.
|
|
|
|
---
|
|
|
|
## 4. Server-rendered frameworks
|
|
|
|
The stylesheet link is a `<link>` tag. It works identically in Django, Rails,
|
|
Laravel, Go's `html/template`, Jinja, ERB and anywhere else, because the mechanism is
|
|
the browser's, not a framework's.
|
|
|
|
```jinja
|
|
{# Django #}
|
|
<link rel="stylesheet" href="{{ theme_service_url }}/rt/v1/theme.css?slug={{ theme_slug }}">
|
|
```
|
|
|
|
```erb
|
|
<%# Rails ERB %>
|
|
<link rel="stylesheet" href="<%= themes_url %>/rt/v1/theme.css?slug=<%= @theme_slug %>">
|
|
```
|
|
|
|
```go
|
|
{{/* Go html/template */}}
|
|
<link rel="stylesheet" href="{{ .ThemeBase }}/rt/v1/theme.css?slug={{ .ThemeSlug }}">
|
|
```
|
|
|
|
For a per-request theme, resolve the slug in your handler and template it in. The
|
|
stylesheet is immutable by URL and safe to cache at any layer, so rendering the link
|
|
per request costs nothing.
|
|
|
|
### Go
|
|
|
|
```go
|
|
func themeStylesheet(base, slug string) template.HTML {
|
|
return template.HTML(fmt.Sprintf(
|
|
`<link rel="stylesheet" href="%s/rt/v1/theme.css?slug=%s">`, base, url.PathEscape(slug)))
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Tailwind
|
|
|
|
Two options, depending on whether the theme must be live.
|
|
|
|
### Live: consume the variables
|
|
|
|
Works with any Tailwind version, needs no rebuild, and a theme publish applies
|
|
immediately.
|
|
|
|
```css
|
|
/* app.css */
|
|
@import 'tailwindcss';
|
|
|
|
@theme inline {
|
|
--color-primary: var(--bn-color-semantic-primary);
|
|
--color-background: var(--bn-color-semantic-background);
|
|
--color-foreground: var(--bn-color-semantic-text);
|
|
--color-muted: var(--bn-color-semantic-text-muted);
|
|
--color-border: var(--bn-color-semantic-border);
|
|
--color-danger: var(--bn-color-semantic-danger);
|
|
--radius-card: var(--bn-radius-lg);
|
|
}
|
|
```
|
|
|
|
Then use the semantic names normally: `bg-background text-foreground bg-primary
|
|
text-primary rounded-card`.
|
|
|
|
### Build-time: generate a preset
|
|
|
|
For a theme that changes rarely and should be baked into the CSS.
|
|
|
|
```bash
|
|
curl -s "$THEMES/rt/v1/theme.json?slug=acme" > tokens.json
|
|
npx @tokens-studio/sd-transform-cli tokens.json tokens.json
|
|
npx tailwindcss --theme ./tokens.json -i app.css -o out.css
|
|
```
|
|
|
|
The JSON is a standard [DTCG](https://www.designtokens.org/) document, so it feeds
|
|
any token pipeline rather than only this one.
|
|
|
|
---
|
|
|
|
## Reading tokens in JavaScript
|
|
|
|
Some things cannot read CSS: a canvas chart, a PDF generator, an email template, a
|
|
colour picker that needs to show a palette.
|
|
|
|
```js
|
|
const { variables, tokens, theme } = await fetch(
|
|
`${THEMES}/rt/v1/theme.json?slug=midnight`,
|
|
).then((response) => response.json())
|
|
|
|
theme.slug // 'midnight'
|
|
theme.version // 3
|
|
variables['--bn-color-semantic-primary'] // '#7c6bf5'
|
|
variables['--bn-radius-lg'] // '0.75rem'
|
|
```
|
|
|
|
The payload has three parts:
|
|
|
|
| Field | Shape | Use |
|
|
| --- | --- | --- |
|
|
| `theme` | identity | Which theme and version this is |
|
|
| `variables` | flat map | CSS custom property name to value |
|
|
| `tokens` | nested DTCG | The tree, for mapping onto component props |
|
|
|
|
The same is available from the runtime module as `getVariables()` and `loadTheme()`,
|
|
with caching.
|
|
|
|
### Chart example
|
|
|
|
```js
|
|
const { variables } = await loadTheme('midnight')
|
|
|
|
new Chart(canvas, {
|
|
data,
|
|
options: {
|
|
plugins: {
|
|
legend: { labels: { color: variables['--bn-color-semantic-text-muted'] } },
|
|
scales: {
|
|
x: { grid: { color: variables['--bn-color-semantic-border'] } },
|
|
y: { grid: { color: variables['--bn-color-semantic-border'] } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## Email and documents
|
|
|
|
Email clients have no CSS custom property support. Resolve the variables server-side
|
|
and inline the values.
|
|
|
|
```go
|
|
func themeForEmail(base, slug string) map[string]string {
|
|
resp, err := http.Get(base + "/rt/v1/theme.json?slug=" + url.QueryEscape(slug))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var payload struct {
|
|
Variables map[string]string `json:"variables"`
|
|
}
|
|
if json.NewDecoder(resp.Body).Decode(&payload) != nil {
|
|
return nil
|
|
}
|
|
return payload.Variables
|
|
}
|
|
|
|
// Inline the values; a <style> block is stripped by most clients.
|
|
html := fmt.Sprintf(
|
|
`<td style="background:%s;color:%s;font-family:%s">…</td>`,
|
|
v["--bn-color-semantic-surface-raised"],
|
|
v["--bn-color-semantic-text"],
|
|
v["--bn-font-family-sans"],
|
|
)
|
|
```
|
|
|
|
Resolve once per message and cache it. A theme changes rarely, so a five-minute cache
|
|
is plenty.
|
|
|
|
---
|
|
|
|
## Runtime switching
|
|
|
|
```js
|
|
// A theme picker.
|
|
import { applyTheme, loadManifest } from '.../rt/v1/runtime.js'
|
|
|
|
const { themes } = await loadManifest()
|
|
for (const theme of themes) {
|
|
button.onclick = () => applyTheme(theme.slug)
|
|
}
|
|
```
|
|
|
|
Or with no JavaScript at all:
|
|
|
|
```html
|
|
<select onchange="document.documentElement.dataset.bananasTheme = this.value">
|
|
<option value="midnight">Midnight</option>
|
|
<option value="daylight">Daylight</option>
|
|
</select>
|
|
```
|
|
|
|
The theme picker must be outside the themed subtree, or it restyles itself on every
|
|
change. Put it in a fixed header above the themed container.
|
|
|
|
---
|
|
|
|
## Versioning and caching
|
|
|
|
Runtime responses are immutable by URL.
|
|
|
|
```bash
|
|
# The latest version. This URL changes when the theme is republished.
|
|
/rt/v1/theme.css?slug=midnight
|
|
|
|
# A specific version. This URL never changes, ever.
|
|
/rt/v1/theme.css?slug=midnight&v=7
|
|
```
|
|
|
|
Pinning `v` is what makes a safe deploy possible: build against version 7, and the
|
|
render cannot change underneath you. The `/rt/v1/themes/{slug}` path form resolves
|
|
to the latest version.
|
|
|
|
| Header | Use |
|
|
| --- | --- |
|
|
| `ETag` | Revalidation. Answer a `304` and transfer no body. |
|
|
| `Cache-Control` | `max-age=60, s-maxage=300, stale-while-revalidate=86400` |
|
|
| `X-Theme-Version` | Which version was served. Log it. |
|
|
| `X-Theme-Updated-At` | When it was published. |
|
|
| `sourceURL` comment in the CSS | Navigate from browser devtools to the endpoint |
|
|
|
|
Log `X-Theme-Version` alongside your request id. "The theme looks wrong" becomes
|
|
answerable: you know which version that request resolved.
|
|
|
|
### Recommended policy
|
|
|
|
**Consumers:** CDN with `s-maxage` in the hundreds and a long
|
|
`stale-while-revalidate`. Revalidation is a few milliseconds and no body.
|
|
|
|
**Browsers:** leave the `Cache-Control` as served. The version-scoped URL means the
|
|
cache entry is never stale.
|
|
|
|
**Rollback:** publishing is a new version, and the latest-version URL follows it. A
|
|
rollback propagates within one `s-maxage` window.
|
|
|
|
---
|
|
|
|
## Offline and failure behaviour
|
|
|
|
A theme that fails to load leaves the page as it was, with the application's own
|
|
styles. It does not break the page.
|
|
|
|
That is deliberate. A theming service is a presentation concern; it should never be
|
|
able to take an application down. Every entry point catches its own failure:
|
|
|
|
```js
|
|
applyTheme('midnight').catch((error) => {
|
|
reportError(error) // the page is still usable
|
|
})
|
|
```
|
|
|
|
If you need a theme to be non-optional, add a `<style>` block in your own CSS with
|
|
your brand values as the fallback. The custom properties will be overridden when the
|
|
theme arrives, and used when it does not.
|
|
|
|
---
|
|
|
|
## Multiple themes on one page
|
|
|
|
Theme attributes scope to a subtree, so a documentation site with per-product themes
|
|
is one attribute per section.
|
|
|
|
```html
|
|
<body>
|
|
<header data-bananas-theme="midnight">
|
|
<!-- always the dark theme -->
|
|
</header>
|
|
|
|
<main>
|
|
<section data-bananas-theme="daylight">
|
|
<!-- light theme, even though the page default is dark -->
|
|
</section>
|
|
<section data-bananas-theme="high-contrast">
|
|
<!-- maximum contrast -->
|
|
</section>
|
|
</main>
|
|
</body>
|
|
```
|
|
|
|
Two rules worth knowing:
|
|
|
|
- **Only one theme stylesheet is loaded at a time.** Scoping means several themes on
|
|
one page share the variables in the most recently applied stylesheet. Themes whose
|
|
token *sets* differ will collide.
|
|
- **Appearance is per subtree.** `data-bananas-appearance` on a section overrides the
|
|
document's scheme for that section only.
|
|
|
|
```html
|
|
<section data-bananas-theme="daylight" data-bananas-appearance="dark">
|
|
<!-- the daylight theme, in its dark scheme -->
|
|
</section>
|
|
```
|
|
|
|
If you need genuinely different variable sets on one page, request each theme's
|
|
stylesheet as a separate `<link>` and scope each to its own subtree. That works
|
|
because the selectors are attribute-scoped; the later link does not overwrite the
|
|
earlier one.
|
|
|
|
---
|
|
|
|
## Checklist
|
|
|
|
- [ ] A `<link>` to the theme stylesheet, or `applyTheme` called before first paint
|
|
- [ ] The theme attribute set on `<html>`, for a page-wide theme
|
|
- [ ] Components consuming semantic tokens (`color.semantic.primary`), not palette steps
|
|
- [ ] Dark mode handled: `autoDark`, or `data-bananas-appearance` on `<html>`
|
|
- [ ] A fallback, so a theme that fails to load leaves the page usable
|
|
- [ ] `X-Theme-Version` logged with your request id
|
|
- [ ] For a multi-theme page, one stylesheet per distinct token set, correctly scoped
|
|
<!-- go-doc:generated:end -->
|