No description
Find a file
Daniel Noulet 618b700feb
Some checks failed
Build, test, publish, deploy / build-test (push) Has been cancelled
Build, test, publish, deploy / publish (push) Has been cancelled
Build, test, publish, deploy / deploy-prd (push) Has been cancelled
refactor
2026-10-10 02:30:24 +02:00
.forgejo ci: add Forgejo build and deployment workflows 2026-10-08 10:36:32 +02:00
.github/workflows initial commit 2026-10-07 15:11:35 +02:00
cmd/go-bananas initial commit 2026-10-07 15:11:35 +02:00
deploy refactor 2026-10-10 02:30:01 +02:00
docs refactor 2026-10-10 02:30:01 +02:00
hack refactor 2026-10-10 02:30:24 +02:00
internal refactor 2026-10-10 02:30:01 +02:00
sdk refactor 2026-10-10 02:30:24 +02:00
web refactor 2026-10-10 02:30:24 +02:00
.air.toml initial commit 2026-10-07 15:11:35 +02:00
.dockerignore initial commit 2026-10-07 15:11:35 +02:00
.editorconfig initial commit 2026-10-07 15:11:35 +02:00
.env.example refactor 2026-10-10 02:30:01 +02:00
.gitattributes initial commit 2026-10-07 15:11:35 +02:00
.gitignore initial commit 2026-10-07 15:11:35 +02:00
.golangci.yml initial commit 2026-10-07 15:11:35 +02:00
.prettierignore initial commit 2026-10-07 15:11:35 +02:00
.prettierrc.json initial commit 2026-10-07 15:11:35 +02:00
cat initial commit 2026-10-07 15:11:35 +02:00
compose.yaml refactor 2026-10-10 02:30:01 +02:00
docker-bake.hcl initial commit 2026-10-07 15:11:35 +02:00
Dockerfile initial commit 2026-10-07 15:11:35 +02:00
Dockerfile.release initial commit 2026-10-07 15:11:35 +02:00
go.mod initial commit 2026-10-07 15:11:35 +02:00
go.sum initial commit 2026-10-07 15:11:35 +02:00
goreleaser.yaml initial commit 2026-10-07 15:11:35 +02:00
LICENSE initial commit 2026-10-07 15:11:35 +02:00
Makefile refactor 2026-10-10 02:30:01 +02:00
openapi.yaml initial commit 2026-10-07 15:11:35 +02:00
README.md refactor 2026-10-10 02:30:01 +02:00

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

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.

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/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.


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.