go-bananas/README.md
Daniel Noulet 2967f1a5af
Some checks are pending
Build, test, publish, deploy / build-test (push) Waiting to run
Build, test, publish, deploy / publish (push) Blocked by required conditions
Build, test, publish, deploy / deploy-prd (push) Blocked by required conditions
refactor
2026-10-10 02:30:01 +02:00

470 lines
18 KiB
Markdown

# go-bananas
**Runtime design-token service.** Author design-token themes in a web interface,
serve them to any application as CSS custom properties at run time. Consumers
install nothing and have no build step.
```html
<!-- That is the whole integration. -->
<link rel="stylesheet" href="https://themes.example.com/rt/v1/theme.css?slug=midnight">
```
---
## Why this exists
The usual way to ship a design system is a package. That works, and it has a cost
paid on every release of the design system and on every rebuild of every consumer:
version resolution, peer dependency conflicts, a published version to track, and a
coordinated deploy when the two drift apart.
This service takes the other approach. A theme is a document in a database. An
application asks for it over HTTP. Republishing a theme is a database write; every
application that has ever loaded the page picks it up on its next request. There is
no package to version, no dependency to resolve, and no build to re-run.
The trade is that themes must be expressible as CSS custom properties. That covers
colour, typography, spacing, radius, shadow, motion and z-index — which is what
almost every design token actually is. What it cannot cover is anything that needs
to be compiled into the consuming application: a Tailwind preset, a set of React
components, a Sass mixin library. Those remain packages. This is not a replacement
for a component library, and it is not meant to be.
---
## Contents
- [Quick start](#quick-start)
- [How a theme is consumed](#how-a-theme-is-consumed)
- [The token model](#the-token-model)
- [Built-in themes](#built-in-themes)
- [The runtime API](#the-runtime-api)
- [The administration API](#the-administration-api)
- [Architecture](#architecture)
- [Why SQLite](#why-sqlite)
- [Deployment](#deployment)
- [Development](#development)
- [Documentation](#documentation)
---
## Quick start
### With Docker
```bash
git clone https://github.com/urpi/go-bananas
cd go-bananas
make compose-up
```
Open <http://localhost:8080>. Authentication is bypassed in this mode: every
request is a local administrator. The mode is refused outside a development
environment, so it cannot be deployed by accident.
### Without Docker
Requires Go 1.27 and Node 22 or later.
```bash
make install # Go and Node dependencies
make build # binary, admin UI and runtime SDK
make run # http://localhost:8080
```
### With Kubernetes
```bash
helm upgrade --install go-bananas oci://ghcr.io/urpi/charts/go-bananas \
--namespace go-bananas --create-namespace \
--set config.publicBaseUrl=https://themes.example.com \
--set identity.oidc.issuer=https://auth.example.com/realms/urpi \
--set identity.oidc.existingSecret=go-bananas-auth
```
The chart defaults to the shared development realm at `auth.lcl-urpi.be`. See
[docs/deployment.md](docs/deployment.md) for the full production checklist.
---
## How a theme is consumed
Three integration shapes, in increasing order of control. All three are served
from the same origin as the theme CSS, so nothing is fetched cross-origin.
### 1. A stylesheet link
Works in any HTML page, server-rendered or not, in any framework, including ones
written before this existed.
```html
<link rel="stylesheet" href="https://themes.example.com/rt/v1/theme.css?slug=midnight">
```
### 2. The framework-free runtime
An ES module loaded by URL. Use it to switch themes at run time — a theme picker, a
per-user preference, a per-tenant brand.
```js
import { applyTheme, watchSystemAppearance } from 'https://themes.example.com/rt/v1/runtime.js'
watchSystemAppearance() // follow the operating system
applyTheme('midnight') // fetch and apply
document.documentElement.dataset.bananasTheme = 'daylight' // or just set the attribute
```
### 3. React
A provider and hooks, loaded through an import map. React stays the host
application's own copy; nothing is bundled and nothing is installed.
```html
<script type="importmap">
{ "imports": {
"react": "https://esm.sh/react@19",
"react-dom/client": "https://esm.sh/react-dom@19/client",
"go-bananas/runtime": "https://themes.example.com/rt/v1/react.js"
} }
</script>
```
```jsx
import { BananasProvider, useBananas, useThemeVariable } from 'go-bananas/runtime'
function App() {
return (
<BananasProvider slug="midnight" autoDark>
<Shell />
</BananasProvider>
)
}
function Badge() {
const { resolvedAppearance } = useBananas()
const primary = useThemeVariable('color-semantic-primary')
return <span style={{ color: primary }}>{resolvedAppearance}</span>
}
```
### Or one script tag
```html
<script async src="https://themes.example.com/rt/v1/embed.js" data-theme="midnight"></script>
```
### Reading tokens from JavaScript
Some things cannot read CSS: a canvas chart, a PDF generator, an email template.
```js
const { variables, tokens } = await fetch(
'https://themes.example.com/rt/v1/theme.json?slug=midnight'
).then((response) => response.json())
const primary = variables['--bn-color-semantic-primary']
```
### Using the variables directly
Every token becomes a CSS custom property prefixed `--bn-`.
```css
.my-card {
background: var(--bn-color-semantic-surface-raised);
color: var(--bn-color-semantic-text);
border: 1px solid var(--bn-color-semantic-border);
border-radius: var(--bn-radius-lg);
padding: var(--bn-space-6);
font-family: var(--bn-font-family-sans);
box-shadow: var(--bn-shadow-md);
transition: background-color var(--bn-duration-fast) var(--bn-ease-standard);
}
```
Or use the bundled component classes, which are written entirely in terms of the
generated variables:
```html
<div class="bn-card bn-card--raised">
<div class="bn-card__header">Deployed</div>
<p class="bn-card__body">Everything here comes from a design token.</p>
</div>
<button class="bn-btn">Primary</button>
<button class="bn-btn bn-btn--secondary">Secondary</button>
<span class="bn-badge bn-badge--good">healthy</span>
```
---
## The token model
A theme is a [W3C Design Tokens Community Group](https://www.designtokens.org/)
document. That format was chosen deliberately: it is an existing standard, so
tokens exported from this service can be imported into Figma, Style Dictionary or
any other DTCG-aware tool.
```json
{
"color": {
"palette": {
"brand": {
"500": { "$type": "color", "$value": "#7c6bf5" },
"600": { "$type": "color", "$value": "#6a58e8" }
}
},
"semantic": {
"primary": { "$type": "color", "$value": "#7c6bf5" },
"on-primary": { "$type": "color", "$value": "#0f0d1f" },
"background": { "$type": "color", "$value": "#1a1d23" }
}
},
"font": {
"family": { "sans": { "$type": "fontFamily", "$value": "Inter, system-ui, sans-serif" } },
"size": { "md": { "$type": "fontSize", "$value": 14 } }
},
"space": { "4": { "$type": "dimension", "$value": 16 } },
"$appearances": {
"dark": {
"color": { "semantic": { "background": { "$type": "color", "$value": "#0b1220" } } }
}
}
}
```
**Palettes are for building; semantic roles are for using.** A component consumes
`color.semantic.primary`, never `color.palette.brand.500`. That indirection is what
makes it possible to re-tint a whole product by changing one palette step, and it
is why the engine derives the foreground pairs automatically.
**Light and dark are one document.** `$appearances` holds a partial token document
per colour scheme, deep-merged over the base. A token absent from the dark
overrides simply inherits its light value, so a theme with a dozen genuine
differences does not need two hundred.
**The engine derives the rest.** For a theme that does not define them:
| Derived | Rule |
| --- | --- |
| `--bn-color-on-*` | Black or white, whichever has the higher WCAG contrast ratio against the base colour |
| `--bn-color-*-hover` | The base mixed 8% toward the foreground of the scheme |
| `--bn-color-*-active` | Mixed 16% |
| `--bn-color-*-subtle` | The base at 12% alpha, for badge and hover-row backgrounds |
| `--bn-color-*-tint` | The base mixed 14% into the background |
Anything you define yourself wins: derivation only fills gaps.
### Validation
Publishing is gated on validation. The engine checks structure, value types and
colour syntax, and warns about a missing dark scheme, an unused recommended token
and text that fails WCAG AA.
Every built-in theme is asserted against WCAG AA for body text on every surface it
is likely to sit on, in both schemes — a test, not a review. The `high-contrast`
theme is held to AAA.
---
## Built-in themes
Four ship inside the binary and are reconciled at start-up. A built-in theme an
operator has edited is never overwritten; the reconciler compares the version.
| Slug | Scheme | Purpose |
| --- | --- | --- |
| `midnight` | dark | The default. Violet-accented dark interface on a slate ramp. |
| `daylight` | light | The same brand hue, inverted, with softer shadows. |
| `mono` | light | Near-greyscale and dense. For tables, consoles and data tooling. |
| `high-contrast` | both | Pure black on pure white, thick strokes, no translucency. AAA. |
To make one of your own, duplicate it and edit the copy.
---
## The runtime API
Public by default. A theming service that required a credential could not be
consumed from a stylesheet link, which is the primary use case. Every response is
cacheable and carries an `ETag`.
| Endpoint | Purpose |
| --- | --- |
| `GET /rt/v1/theme.css?slug=…` | A stylesheet. `appearance`, `variant` and `v` are optional. |
| `GET /rt/v1/theme.json?slug=…` | Resolved tokens, flat and nested. |
| `GET /rt/v1/manifest.json` | The theme list, for building a picker. |
| `GET /rt/v1/embed?slug=…` | A ready-to-paste snippet for the target framework. |
| `GET /rt/v1/runtime.js` | The framework-free ES module. |
| `GET /rt/v1/react.js` | The React provider and hooks. |
| `GET /rt/v1/global.js` | The same API on `window.Bananas`. |
| `GET /rt/v1/embed.js` | The one-line script. |
```bash
curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight'
curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight&v=7' # pin a version
curl 'https://themes.example.com/rt/v1/manifest.json' | jq '.themes[].slug'
```
Path-addressable equivalents exist for consumers that prefer REST shapes or a CDN
configured with path patterns: `/rt/v1/themes/{slug}/theme.css`.
### Cache behaviour
Runtime responses are immutable by URL: publishing a theme changes its version,
and a version-pinned URL never changes. That lets a CDN cache them hard and
revalidate in a few milliseconds. `If-None-Match` is honoured, so a redeploy costs
a revalidation and no body transfer.
---
## The administration API
Authenticated, never cached, and every mutation audited. See
[docs/api.md](docs/api.md).
| Method | Path | Role | Purpose |
| --- | --- | --- | --- |
| `GET` | `/api/v1/themes` | viewer | List and search |
| `GET` | `/api/v1/themes/{slug}` | viewer | One theme with its tokens |
| `POST` | `/api/v1/themes` | editor | Create a draft |
| `PATCH` | `/api/v1/themes/{slug}` | editor | Update, with optimistic concurrency |
| `POST` | `/api/v1/themes/{slug}/publish` | editor | Publish |
| `DELETE` | `/api/v1/themes/{slug}` | admin | Delete |
| `GET` | `/api/v1/themes/{slug}/versions` | viewer | History |
| `POST` | `/api/v1/themes/{slug}/versions/{v}/rollback` | editor | Restore as a new version |
| `POST` | `/api/v1/tokens/validate` | viewer | Validate without saving |
| `GET` | `/api/v1/tokens/contrast?fg=…&bg=…` | viewer | WCAG contrast |
| `GET` | `/api/v1/tokens/palette?hue=…` | viewer | Generate a ramp |
| `GET` | `/api/v1/admin/audit` | admin | The activity log |
---
## Architecture
```
┌──────────────────────────────────────┐
Browser ───────▶│ /rt/v1 runtime, public, cached │──┐
<link>, fetch │ /api/v1 admin, authenticated │ │ in-memory
│ /auth/* OIDC login flow │ │ render cache
└──────────────────────────────────────┘ │
│ │
chi router + middleware │
│ │
┌────────────────▼─────────────────────────┴────┐
│ auth OIDC · sessions · RBAC │
│ httpapi handlers, no business logic │
│ tokenengine colour maths, CSS and JSON render │
│ domain entities and invariants │
│ store SQLite (WAL), repositories │
└──────────────────┬──────────────────────────────┘
│
SQLite on a local volume
```
The load-bearing decisions, each argued in `docs/adr/`:
- [ADR 0001](docs/adr/0001-embedded-sqlite.md) — why SQLite and not Postgres
- [ADR 0002](docs/adr/0002-runtime-delivery.md) — why delivery is at run time
- [ADR 0003](docs/adr/0003-stateful-sessions.md) — stateless signed cookies
The properties that matter:
- **The admin UI consumes the runtime.** It loads themes from `/rt/v1` and maps the
variables onto Tailwind's namespaces. A change that breaks theme delivery breaks
the editor, immediately, rather than being discovered by a customer.
- **Handlers hold no business logic.** They decode, call, encode and translate
errors. Anything worth reasoning about lives in `domain` or `tokenengine` where it
is testable without an HTTP server.
- **The render cache is pre-warmed** at start-up, so the first request after a
deploy is already fast. A write invalidates exactly one theme's entries.
- **The database connection pool is deliberately small.** SQLite serialises
writers; more connections buy contention, not throughput.
---
## Why SQLite
Themes are a few thousand JSON documents, read constantly and written rarely.
That is precisely the shape an embedded engine is good at, and a client/server
database would add a network hop, a connection pool, a second container,
credentials to rotate and a schema migration process to a service whose entire
dataset comfortably fits in a few megabytes.
The configuration that makes it fast rather than merely adequate:
| Setting | Why |
| --- | --- |
| `journal_mode=WAL` | Readers never block the writer, and the writer never blocks readers |
| `synchronous=NORMAL` | Durable across a process crash; risks only the last transactions on an OS crash. A re-done publish is not an outage |
| `cache_size=-16000` | A 16 MiB cache holds the whole dataset |
| `mmap_size=256 MiB` | The page cache serves reads without a copy |
| Max 8 connections | SQLite serialises writers; a large pool adds contention |
| Prepared statements | The hot runtime query is compiled once |
| In-memory render cache | The steady-state runtime request touches no database at all |
The engine is `modernc.org/sqlite`, a pure-Go translation. No cgo, so the release
image builds static and the cross-compiled binaries are genuinely portable.
**Scaling past one replica** needs either a shared filesystem or a different
database. Both are documented in [docs/deployment.md](docs/deployment.md#scaling).
---
## Deployment
The Helm chart validates its own configuration at render time and refuses to
produce a manifest it knows is wrong:
```bash
$ helm template g deploy/helm/go-bananas --set config.shutdownTimeoutMs=60000
Error: config.shutdownTimeoutMs (60000) must be less than
deployment.terminationGracePeriodSeconds (45s), otherwise Kubernetes sends
SIGKILL before graceful shutdown finishes and in-flight requests are cut off.
```
Guarded at render time: a missing or malformed `publicBaseUrl`, an unknown auth
mode, a missing OIDC secret, both secret sources at once, `skipTLSVerify` in
production, and a shutdown timeout that exceeds the grace period. Failing at render
time means a bad release never reaches the API server, so there is no half-created
Deployment and no `CrashLoopBackOff` to diagnose.
Security posture by default: non-root, read-only root filesystem, all capabilities
dropped, no API token mounted, no CPU limit, security headers on every response, and
a Content-Security-Policy strict enough that the admin UI needed no exceptions for
script sources.
---
## Development
```bash
make help # every task
make install # dependencies
make dev-all # API on :8080, admin UI on :5173
make check # format, lint and test everything
```
Requires Go 1.27 and Node 22 or later. `make tools` installs `air`,
`golangci-lint` and `sqlc`.
## Documentation
| Document | Contents |
| --- | --- |
| [docs/architecture.md](docs/architecture.md) | How the pieces fit, and why |
| [docs/standards.md](docs/standards.md) | **Coding standards and conventions. Read this before contributing** |
| [docs/integration.md](docs/integration.md) | Consuming themes, every framework |
| [docs/api.md](docs/api.md) | The HTTP API, with examples |
| [docs/deployment.md](docs/deployment.md) | Running it, at any scale |
| [docs/development.md](docs/development.md) | Working on it |
| [docs/operations.md](docs/operations.md) | Monitoring, backup, runbook |
| [docs/security.md](docs/security.md) | Threat model and hardening |
| [docs/styling-contract.md](docs/styling-contract.md) | The shared styling contract, and how an application adopts it |
| [docs/dev-infrastructure.md](docs/dev-infrastructure.md) | **This machine's dev infrastructure: audit, port map, fixes, and the issue log to append to** |
| [docs/adr/](docs/adr/) | Architecture decision records |
## Licence
MIT.