# 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 ``` --- ## 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 . 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 ``` ### 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 ``` ```jsx import { BananasProvider, useBananas, useThemeVariable } from 'go-bananas/runtime' function App() { return ( ) } function Badge() { const { resolvedAppearance } = useBananas() const primary = useThemeVariable('color-semantic-primary') return {resolvedAppearance} } ``` ### Or one script tag ```html ``` ### 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
Deployed

Everything here comes from a design token.

healthy ``` --- ## 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 │──┐ , 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.