| .forgejo | ||
| .github/workflows | ||
| cmd/go-bananas | ||
| deploy | ||
| docs | ||
| hack | ||
| internal | ||
| sdk | ||
| web | ||
| .air.toml | ||
| .dockerignore | ||
| .editorconfig | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| .golangci.yml | ||
| .prettierignore | ||
| .prettierrc.json | ||
| cat | ||
| compose.yaml | ||
| docker-bake.hcl | ||
| Dockerfile | ||
| Dockerfile.release | ||
| go.mod | ||
| go.sum | ||
| goreleaser.yaml | ||
| LICENSE | ||
| Makefile | ||
| openapi.yaml | ||
| README.md | ||
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.
<!-- 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
- How a theme is consumed
- The token model
- Built-in themes
- The runtime API
- The administration API
- Architecture
- Why SQLite
- Deployment
- Development
- Documentation
Quick start
With Docker
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.
make install # Go and Node dependencies
make build # binary, admin UI and runtime SDK
make run # http://localhost:8080
With Kubernetes
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 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.
<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.
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.
<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>
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
<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.
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-.
.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:
<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 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.
{
"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. |
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.
| 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 — why SQLite and not Postgres
- ADR 0002 — why delivery is at run time
- ADR 0003 — stateless signed cookies
The properties that matter:
- The admin UI consumes the runtime. It loads themes from
/rt/v1and 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
domainortokenenginewhere 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.
Deployment
The Helm chart validates its own configuration at render time and refuses to produce a manifest it knows is wrong:
$ 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
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 | How the pieces fit, and why |
| docs/standards.md | Coding standards and conventions. Read this before contributing |
| docs/integration.md | Consuming themes, every framework |
| docs/api.md | The HTTP API, with examples |
| docs/deployment.md | Running it, at any scale |
| docs/development.md | Working on it |
| docs/operations.md | Monitoring, backup, runbook |
| docs/security.md | Threat model and hardening |
| docs/styling-contract.md | The shared styling contract, and how an application adopts it |
| docs/dev-infrastructure.md | This machine's dev infrastructure: audit, port map, fixes, and the issue log to append to |
| docs/adr/ | Architecture decision records |
Licence
MIT.