initial commit
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
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
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
469
README.md
Normal file
469
README.md
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
# 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue