149 lines
6.8 KiB
Markdown
149 lines
6.8 KiB
Markdown
|
|
<!-- go-doc:generated:begin source=go-bananas:docs/adr/0002-runtime-delivery.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
|
||
|
|
Imported from docs/adr/0002-runtime-delivery.md in the go-bananas repository.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
# ADR 0002: Deliver themes at run time rather than as a package
|
||
|
|
|
||
|
|
- **Status:** accepted
|
||
|
|
- **Date:** 2026-10-04
|
||
|
|
|
||
|
|
## Context
|
||
|
|
|
||
|
|
The conventional way to ship a design system to applications is a package: an npm
|
||
|
|
package, a Go package, a Maven artifact. The consuming application installs it,
|
||
|
|
imports from it, and rebuilds when it changes.
|
||
|
|
|
||
|
|
That model is good, and it is the right answer for most design-system artefacts.
|
||
|
|
The question here is whether it is the right answer for *design tokens specifically*.
|
||
|
|
|
||
|
|
## Decision
|
||
|
|
|
||
|
|
Themes are served over HTTP at run time as CSS custom properties and JSON. The
|
||
|
|
runtime SDKs are ES modules loaded by URL through an import map. Nothing is
|
||
|
|
installed by the consumer.
|
||
|
|
|
||
|
|
## Rationale
|
||
|
|
|
||
|
|
### The cost of the package model, for tokens specifically
|
||
|
|
|
||
|
|
Design tokens are unusual among design-system artefacts. They are:
|
||
|
|
|
||
|
|
- **entirely data.** No behaviour to tree-shake, no components to code-split;
|
||
|
|
- **consumed at a single moment.** At first paint, by a stylesheet link;
|
||
|
|
- **changing on a different cadence from code.** A designer adjusts a colour; no
|
||
|
|
application code needs to change.
|
||
|
|
|
||
|
|
The package model imposes costs that fit code perfectly and fit data badly:
|
||
|
|
|
||
|
|
| Cost | Fits code? | Fits tokens? |
|
||
|
|
| --- | --- | --- |
|
||
|
|
| Version resolution and peer conflicts | Yes | No. Nothing here has a peer. |
|
||
|
|
| A rebuild of every consumer | Necessary | Pure overhead. The CSS output is byte-identical. |
|
||
|
|
| Coordinated deploys on version skew | Necessary | Avoidable: a runtime fetch has no skew. |
|
||
|
|
| A published version to track | Necessary | A database row, which an operator already tracks. |
|
||
|
|
| Install friction | Usually fine | Often the blocker. A Go service with a templated frontend cannot `npm install` at all. |
|
||
|
|
| Tree-shaking and chunk sizing | Necessary | Nothing to shake. |
|
||
|
|
|
||
|
|
The specific motivating case: a service that serves server-rendered HTML cannot
|
||
|
|
consume a design-system npm package without introducing a Node build into its
|
||
|
|
release pipeline. That is a large change made to deliver a CSS file. Serving the
|
||
|
|
CSS directly removes it.
|
||
|
|
|
||
|
|
### What runtime delivery buys
|
||
|
|
|
||
|
|
**Republishing is a database write.** A designer adjusts a colour, clicks publish,
|
||
|
|
and every application picks it up on its next request. There is no version to cut,
|
||
|
|
no build to trigger, no consumer to upgrade.
|
||
|
|
|
||
|
|
**No skew.** An application never holds a stale copy of a theme in its
|
||
|
|
`node_modules`, because it never held one. Rolling back a theme is a database write
|
||
|
|
too, and is atomic across every consumer.
|
||
|
|
|
||
|
|
**No supply chain.** A token file is not code. It cannot execute anything, it has
|
||
|
|
no transitive dependencies, and it cannot be compromised in the way an npm package
|
||
|
|
can. This is a genuinely large reduction in attack surface.
|
||
|
|
|
||
|
|
**Language neutrality.** Any environment that can make an HTTP request and read CSS
|
||
|
|
can consume a theme: Go, Rust, PHP, Jinja, Django templates, a static site
|
||
|
|
generator, a server-rendered email.
|
||
|
|
|
||
|
|
### What it costs
|
||
|
|
|
||
|
|
Honesty requires stating the costs:
|
||
|
|
|
||
|
|
**A network hop at first paint.** A stylesheet link is a blocking request. Mitigated
|
||
|
|
by immutable-by-URL caching: a CDN serves it from an edge, a publish changes the
|
||
|
|
version and therefore the URL, and a revalidation costs a few milliseconds and no
|
||
|
|
body. This is the same cost model as a web font, which every application already
|
||
|
|
pays.
|
||
|
|
|
||
|
|
**A runtime dependency.** The service must be up for a consuming application to look
|
||
|
|
right. Mitigated by a hard failure being a *fallback to the application's own
|
||
|
|
styles*, not an error page: a failed theme load leaves the page unstyled rather than
|
||
|
|
broken, which is exactly the right degradation.
|
||
|
|
|
||
|
|
**CSS custom properties as the only interface.** A token cannot be compiled into a
|
||
|
|
Tailwind class, a Sass mixin or a styled-component prop. Anything that needs
|
||
|
|
compilation is not served by this system. That is the boundary of the approach, and
|
||
|
|
it is a real boundary rather than a detail.
|
||
|
|
|
||
|
|
**A version-pinning story is needed.** Runtime delivery means a consumer can be
|
||
|
|
mid-request while a theme is republished. The answer is that a published theme's
|
||
|
|
version never changes — a publish creates version N+1 — so a URL pinned to version
|
||
|
|
N is immutable forever, and `/rt/v1/themes/{slug}` resolves to the latest. This is
|
||
|
|
the same model as an immutable container tag.
|
||
|
|
|
||
|
|
## Alternatives considered
|
||
|
|
|
||
|
|
**An npm package of design tokens.** Rejected for the reasons above. It remains the
|
||
|
|
right answer for anything requiring compilation, and the two models compose: an
|
||
|
|
application can build a Tailwind preset from a theme at its own build time, using
|
||
|
|
`/rt/v1/theme.json`, and then use the runtime for the parts that must be live.
|
||
|
|
|
||
|
|
**A CSS file on a CDN, uploaded by hand.** The delivery mechanism without the
|
||
|
|
management. It removes the coupling but gives up atomic publish, rollback, history
|
||
|
|
and per-theme authorisation. This is what the service automates.
|
||
|
|
|
||
|
|
**Server-side rendering of inline styles.** Each application renders the variables
|
||
|
|
into its own HTML. Zero latency, but every application must implement a fetcher, a
|
||
|
|
cache, a fallback and a revalidation policy, and they will all get it subtly
|
||
|
|
different. Centralising it means one correct implementation.
|
||
|
|
|
||
|
|
**Web Components for the tokens.** Not applicable. Tokens are not components.
|
||
|
|
|
||
|
|
**A GraphQL API.** Rejected: the payload is one object, so a single GET is simpler,
|
||
|
|
cacheable at the HTTP layer, and readable with `curl`. GraphQL would add a schema,
|
||
|
|
a server and a client library to deliver a JSON document.
|
||
|
|
|
||
|
|
## Consequences
|
||
|
|
|
||
|
|
**Good**
|
||
|
|
|
||
|
|
- Publishing is immediate, atomic and reversible.
|
||
|
|
- No consumer rebuild, no version skew, no package to track.
|
||
|
|
- Consumers in any language, including ones that cannot use npm at all.
|
||
|
|
- A theme cannot execute code.
|
||
|
|
- Caching is a solved problem rather than a design decision, because URLs are
|
||
|
|
immutable.
|
||
|
|
|
||
|
|
**Bad**
|
||
|
|
|
||
|
|
- A network hop on the critical rendering path, mitigated by aggressive caching.
|
||
|
|
- Only expressible as CSS custom properties. Anything needing compilation is out of
|
||
|
|
scope.
|
||
|
|
- The service is a runtime dependency for every consumer's first paint.
|
||
|
|
- Debugging "why does this consumer look wrong" requires knowing which theme and
|
||
|
|
version it resolved. Mitigated by `X-Theme-Version` and `X-Theme-Updated-At`
|
||
|
|
response headers, and by the `sourceURL` comment in the generated CSS that makes
|
||
|
|
browser devtools navigate back to the endpoint.
|
||
|
|
|
||
|
|
**Neutral**
|
||
|
|
|
||
|
|
- The admin UI is itself a consumer, which means the delivery path is exercised on
|
||
|
|
every interaction. A change that breaks delivery breaks the tool that maintains
|
||
|
|
themes, which is a useful continuous check.
|
||
|
|
- The runtime SDKs are built and served rather than published, so they are versioned
|
||
|
|
by the service's release rather than by a registry.
|
||
|
|
<!-- go-doc:generated:end -->
|