chore: adding docs
This commit is contained in:
parent
23816d00f7
commit
81c5112409
39 changed files with 7257 additions and 2 deletions
571
data/docs/go-bananas-integration.md
Normal file
571
data/docs/go-bananas-integration.md
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
<!-- 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 -->
|
||||
Loading…
Add table
Add a link
Reference in a new issue