Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
469 lines
No EOL
18 KiB
Markdown
469 lines
No EOL
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-dev.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/adr/](docs/adr/) | Architecture decision records |
|
|
|
|
## Licence
|
|
|
|
MIT. |