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
176
docs/adr/0001-embedded-sqlite.md
Normal file
176
docs/adr/0001-embedded-sqlite.md
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
# ADR 0001: Embedded SQLite rather than a client/server database
|
||||
|
||||
- **Status:** accepted
|
||||
- **Date:** 2026-10-04
|
||||
- **Deciders:** platform team
|
||||
- **Applies to:** all deployments of the current major version
|
||||
|
||||
## Context
|
||||
|
||||
go-bananas stores themes. A theme is a slug, some metadata and a JSON document of
|
||||
design tokens, typically 10–60 kB. A useful instance has tens to a few thousand of
|
||||
them.
|
||||
|
||||
The workload shape is:
|
||||
|
||||
- **Reads:** constant and bursty. Every page view in every consuming application
|
||||
requests CSS. A service with ten consumer applications and a few hundred users
|
||||
can easily see thousands of stylesheet requests a minute, and 95%+ of them are
|
||||
the same handful of bytes served from a render cache.
|
||||
- **Writes:** rare. A theme is edited by a human. A busy week might be fifty writes.
|
||||
- **Total size:** a few megabytes. Even ten thousand large themes would fit in
|
||||
fifty.
|
||||
|
||||
The conventional answer for a service with this shape is PostgreSQL. That answer is
|
||||
right for almost every service and wrong for this one, for reasons worth writing
|
||||
down rather than rediscovering in a year.
|
||||
|
||||
## Decision
|
||||
|
||||
Use SQLite through `modernc.org/sqlite`, a pure-Go translation, on a local volume.
|
||||
|
||||
## Rationale
|
||||
|
||||
### What a client/server database would add
|
||||
|
||||
For this dataset, Postgres buys nothing and costs:
|
||||
|
||||
- **A network hop per query.** A local SQLite read is microseconds; a query over a
|
||||
pod network is a millisecond and a tail-latency distribution with a long right
|
||||
tail. For a stylesheet endpoint on the critical path of every page view, that
|
||||
tail is the difference between a fast site and a janky one.
|
||||
- **A connection pool to get wrong.** Every deployment needs its own tuning, and the
|
||||
failure mode of under-tuning is a saturated pool under load.
|
||||
- **Credentials to create, distribute, rotate and revoke.** Another secret in
|
||||
Kubernetes, another thing to get wrong.
|
||||
- **A schema migration process.** For a dataset that fits in a few megabytes, this
|
||||
is a disproportionate amount of ceremony.
|
||||
- **A second thing to operate.** Backups, upgrades, monitoring, incident response,
|
||||
for a component whose whole job is to hold some JSON.
|
||||
- **A second failure mode.** The database becoming unavailable is now the most
|
||||
likely way the theme service goes down.
|
||||
|
||||
### What SQLite gives up
|
||||
|
||||
Honesty requires stating the costs:
|
||||
|
||||
- **One writer at a time.** Correct at this write rate. A theme edit is a
|
||||
millisecond-scale transaction; even a hundred concurrent editors would not
|
||||
saturate it.
|
||||
- **Single-node.** This is the real constraint. See Scaling below.
|
||||
- **No network access.** A consuming application cannot query the database directly.
|
||||
It does not need to; the runtime API is the interface.
|
||||
|
||||
### Why this particular configuration is fast
|
||||
|
||||
The default SQLite configuration is not fast. These settings are what make it fast
|
||||
enough that the question does not arise:
|
||||
|
||||
| Setting | Effect |
|
||||
| --- | --- |
|
||||
| `journal_mode=WAL` | Readers never block the writer and the writer never blocks readers. This is the single most important setting. |
|
||||
| `synchronous=NORMAL` | In WAL mode this is durable across an application crash and risks only the last transactions on an operating-system crash. A lost publish is re-doable; an outage is not. |
|
||||
| `cache_size=-16000` | A 16 MiB page cache, which holds the entire dataset. |
|
||||
| `mmap_size=268435456` | The file is read through the page cache directly, removing a copy from the read path. |
|
||||
| `temp_store=MEMORY` | Sort and group-by spill stays in memory. |
|
||||
| Max 8 connections | SQLite serialises writers. A large pool adds contention, not throughput. This is counter-intuitive and correct. |
|
||||
| In-memory render cache | The steady-state runtime request performs **no database access at all**. |
|
||||
|
||||
The render cache is the decisive optimisation. Rendering a theme traverses its whole
|
||||
token document; serving the cached string is one map lookup. Because the cache key
|
||||
includes the theme version, publishing invalidates exactly one theme's entries and
|
||||
disturbs nothing else. The database is consulted only on a cache miss, which is a
|
||||
publish rather than a read.
|
||||
|
||||
### Why `modernc.org/sqlite` rather than `mattn/go-sqlite3`
|
||||
|
||||
`mattn/go-sqlite3` is a cgo binding to the C library. It is faster, and it means:
|
||||
|
||||
- `CGO_ENABLED=1`, so the release image needs a C toolchain at build time and libc
|
||||
at run time, and cross-compilation needs a cross-C toolchain;
|
||||
- a statically linked binary is impossible, so the image carries a C library whose
|
||||
CVEs need tracking;
|
||||
- `go install` from source fails on a machine without a compiler.
|
||||
|
||||
`modernc.org/sqlite` is a transpiled Go implementation: no cgo, a fully static
|
||||
binary, and cross-compilation is `GOOS=linux GOARCH=arm64` and nothing else. The
|
||||
performance difference is irrelevant here because the hot path is the in-memory
|
||||
render cache, not SQLite.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**PostgreSQL.** The conventional choice. Rejected on the grounds above. It remains
|
||||
the right answer if the product changes shape — see Scaling.
|
||||
|
||||
**Redis.** Excellent for this read-heavy workload, and a good fit for a render
|
||||
cache. But it is another service to operate for a cache that is already
|
||||
process-local and loses nothing on restart, because the source of truth is the
|
||||
database. Rejected as unnecessary operational surface. Worth revisiting if a
|
||||
many-node deployment needs a shared cache; the render cache is an interface, so
|
||||
that change is contained.
|
||||
|
||||
**bbolt.** Faster than SQLite for pure key/value workloads and simpler. Rejected
|
||||
because the schema needs indexes for listing, filtering and sorting, and because
|
||||
SQLite's query planner handles the "a few hundred rows filtered by status and
|
||||
ordered by date" shape without the ceremony of managing bbolt buckets by hand.
|
||||
|
||||
**A JSON file per theme on disk.** No dependency at all. Rejected because concurrent
|
||||
writes need locking, and because every listing requires reading and parsing every
|
||||
file. SQLite is a better version of this idea that is already written.
|
||||
|
||||
**An in-memory store with write-through to object storage.** Attractive for scale.
|
||||
Rejected for v1: it removes the transactional guarantee that makes version history
|
||||
and rollback correct, and it adds an object-storage dependency. The write path
|
||||
here is not hot enough to justify losing the invariant.
|
||||
|
||||
## Scaling
|
||||
|
||||
SQLite on a local volume is single-node. Two shapes are supported:
|
||||
|
||||
1. **One replica, one volume.** The recommended production shape. Horizontally
|
||||
scalable only in the sense that the service is so cheap that one replica serves
|
||||
a very large number of consumers.
|
||||
|
||||
2. **Many replicas, a shared filesystem.** Works when the storage class supports
|
||||
concurrent mounts — NFS, CephFS, or a ReadWriteMany block device. WAL mode
|
||||
requires the filesystem to support shared memory mapping, which these do. The
|
||||
Helm chart warns when a ReadWriteOnce volume is combined with more than one
|
||||
replica, because that combination cannot work.
|
||||
|
||||
If the workload ever outgrows both — genuinely concurrent multi-writer access across
|
||||
nodes — the migration is to Postgres. It is contained: `internal/store` is the only
|
||||
package that writes SQL, and its interface is defined by the methods it exposes
|
||||
rather than by SQLite's features.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Good**
|
||||
|
||||
- One less thing to operate, back up, monitor and secure.
|
||||
- No credential to leak.
|
||||
- A static binary and a small image with no libc.
|
||||
- A backup is `cp` of one file. There is no consistent-snapshot dance.
|
||||
- The render cache can be process-local because there is only one process.
|
||||
|
||||
**Bad**
|
||||
|
||||
- Cannot scale horizontally past one node without a shared filesystem.
|
||||
- A shared-filesystem deployment is slower than local disk, and WAL over NFS is
|
||||
sensitive to latency. Measure before choosing it.
|
||||
- Single-writer means a pathological write loop would queue. Nothing in the
|
||||
application can produce one.
|
||||
|
||||
**Neutral**
|
||||
|
||||
- The `store` package has a wider interface than a repository might otherwise need,
|
||||
because it must express listing, filtering and sorting. Moving to another engine
|
||||
means reimplementing that surface.
|
||||
|
||||
## Revisit when
|
||||
|
||||
- A single instance cannot serve the load, and profiling shows the database rather
|
||||
than the render cache is the bottleneck.
|
||||
- Concurrent writers become a real requirement, for example if themes can be edited
|
||||
by an automated pipeline from several deployments.
|
||||
- The dataset outgrows a few hundred megabytes, at which point the time to back up
|
||||
by copying a file becomes its own problem.
|
||||
142
docs/adr/0002-runtime-delivery.md
Normal file
142
docs/adr/0002-runtime-delivery.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# 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.
|
||||
101
docs/adr/0003-stateful-sessions.md
Normal file
101
docs/adr/0003-stateful-sessions.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# ADR 0003: Stateless signed session cookies
|
||||
|
||||
- **Status:** accepted
|
||||
- **Date:** 2026-10-04
|
||||
|
||||
## Context
|
||||
|
||||
The administration API needs a session. The service may run more than one replica
|
||||
behind a load balancer, and the login callback may be handled by a different pod
|
||||
from the request that started it.
|
||||
|
||||
## Decision
|
||||
|
||||
A session is a signed JSON payload in an `HttpOnly`, `Secure`, `SameSite` cookie.
|
||||
There is no server-side session table.
|
||||
|
||||
## Rationale
|
||||
|
||||
**The load-balancer problem.** With a server-side session store, a session created
|
||||
by one replica must be readable by another. That means either sticky sessions (which
|
||||
lose the pod on every rollout) or a shared store (Redis or a database table), which
|
||||
is the operational surface [ADR 0001](0001-embedded-sqlite.md) set out to avoid.
|
||||
A signed cookie removes the question entirely.
|
||||
|
||||
**The transaction state has the same problem.** The OIDC flow carries a PKCE
|
||||
verifier and a return path from the authorize redirect to the callback. Keeping that
|
||||
in server-side state reintroduces the same requirement. It is a second signed cookie,
|
||||
read and consumed exactly once.
|
||||
|
||||
**The lifetime is short.** An administrative session is eight hours by default.
|
||||
The cost of not being able to revoke a session before it expires is bounded by that
|
||||
window.
|
||||
|
||||
**What it costs.** Two things, stated plainly:
|
||||
|
||||
1. **Revocation.** A stolen session cannot be invalidated before it expires. The
|
||||
mitigations are an eight-hour lifetime and, for a full invalidation, rotating the
|
||||
signing key, which logs out every session at once. Rotating is a deployment, not
|
||||
a button.
|
||||
2. **Payload size.** The cookie carries identity and roles. That is a few hundred
|
||||
bytes, comfortably inside the 4 kB browser limit. A larger claim set would push
|
||||
against it.
|
||||
|
||||
## What was rejected
|
||||
|
||||
**A server-side session table in SQLite.** Would work, and would add revocation.
|
||||
Rejected because it adds a table, a write on every login, a sweep job, and a
|
||||
migration path for a problem the eight-hour window already bounds.
|
||||
|
||||
**Redis.** Would work. Rejected as operational surface for the same reason the
|
||||
render cache stayed process-local.
|
||||
|
||||
**JWT as a bearer token in localStorage.** Rejected on security grounds. A bearer
|
||||
token in `localStorage` is readable by any script on the page, so one XSS bug
|
||||
becomes a credential theft. An `HttpOnly` cookie is not readable by script, and
|
||||
`SameSite=Lax` plus the session cookie's scope mean a cross-site form post cannot
|
||||
ride it. The session is a cookie precisely so that it is not script-accessible.
|
||||
|
||||
**Signed but not encrypted.** The payload is readable by the user holding the
|
||||
cookie. It contains a subject, an email, a display name and roles — no secrets. It
|
||||
is signed, not encrypted, so that a client can read it and debugging is possible.
|
||||
Encrypting it would add a key for no confidentiality gain on this content.
|
||||
|
||||
## Implementation requirements
|
||||
|
||||
Every one of these is a security property, and each has a test:
|
||||
|
||||
- HMAC-SHA256 signature, compared with `subtle.ConstantTimeCompare`. A
|
||||
byte-by-byte early return leaks the correct prefix length to an attacker measuring
|
||||
responses.
|
||||
- `HttpOnly`, so script cannot read it.
|
||||
- `Secure` in any non-development environment, enforced by configuration
|
||||
validation.
|
||||
- `SameSite`, defaulted to `Lax`. `None` requires `Secure`, enforced by validation,
|
||||
because browsers reject the combination and the session would silently never
|
||||
survive a redirect.
|
||||
- Payload expiry, checked server-side on every request. The cookie's own `Max-Age`
|
||||
is a client hint, not a control.
|
||||
- An absent or invalid role set defaults to viewer. A cookie carrying no roles must
|
||||
never be treated as carrying privileges.
|
||||
- The signing secret is at least 32 bytes, and an ephemeral one is generated in
|
||||
development rather than requiring configuration that does not matter locally.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Good**
|
||||
|
||||
- Any replica can authenticate any request, with no stickiness and no shared store.
|
||||
- A rollback of the Deployment does not log anyone out.
|
||||
- Nothing to sweep, nothing to migrate.
|
||||
|
||||
**Bad**
|
||||
|
||||
- No per-session revocation. Bounded by the session lifetime.
|
||||
- Rotating the key logs out everyone, not one session.
|
||||
|
||||
**Neutral**
|
||||
|
||||
- Logout must clear the cookie rather than delete a record, so a client that keeps
|
||||
a copy can still replay until expiry. Mitigated by the short lifetime.
|
||||
- The payload is readable by its holder. Acceptable because it contains no secrets.
|
||||
70
docs/adr/0004-typescript-version.md
Normal file
70
docs/adr/0004-typescript-version.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# ADR 0004: TypeScript 6.0.3 rather than 7.x
|
||||
|
||||
- **Status:** accepted
|
||||
- **Date:** 2026-10-04
|
||||
- **Revisit when:** typescript-eslint ships TypeScript 7 support
|
||||
|
||||
## Context
|
||||
|
||||
The brief was to take the latest version of everything. For TypeScript, the latest
|
||||
release is 7.0.2, which is the native Go port of the compiler (`tsgo`).
|
||||
|
||||
## Decision
|
||||
|
||||
Pin TypeScript to 6.0.3 in both `web/` and `sdk/`.
|
||||
|
||||
## Rationale
|
||||
|
||||
TypeScript 7.0.2 ships `tsc.js` and a version module. It does **not** ship a
|
||||
JavaScript compiler API:
|
||||
|
||||
```console
|
||||
$ npm install typescript@7.0.2
|
||||
$ node -e "const ts=require('typescript'); console.log(typeof ts.createProgram)"
|
||||
undefined
|
||||
```
|
||||
|
||||
That API is what every type-aware linting tool consumes. `typescript-eslint@8.71.0`,
|
||||
the current release, declares:
|
||||
|
||||
```
|
||||
peerDependencies: { typescript: ">=4.8.4 <6.1.0" }
|
||||
```
|
||||
|
||||
So TypeScript 7 fails to install at all alongside the standard linting stack, and
|
||||
would fail at run time if forced past the peer check.
|
||||
|
||||
The choice is therefore between:
|
||||
|
||||
- **TypeScript 7.x** — a much faster compiler, and no type-aware ESLint rules at all,
|
||||
because the tooling that provides them cannot run. `npm install` fails outright
|
||||
without `--legacy-peer-deps`, which is a smell in a project rather than a solution.
|
||||
- **TypeScript 6.0.3** — the newest release that ships both the CLI and the
|
||||
JavaScript API, satisfying every tool in the toolchain.
|
||||
|
||||
## Why not upgrade anyway and lose the linting
|
||||
|
||||
The project's standards require type-aware linting: `@typescript-eslint` rules that
|
||||
need the compiler's type information, which is what catches the mistakes that matter
|
||||
in a codebase using `unknown` at API boundaries. Dropping it to gain compiler speed
|
||||
is a bad trade: the speed is a developer-experience improvement, and the linting is
|
||||
a correctness control.
|
||||
|
||||
## What this costs
|
||||
|
||||
TypeScript 6.0.3 is slower than 7.0.2. On this codebase the typecheck completes in
|
||||
well under a second, so the difference is not observable, and the compilation cost
|
||||
that matters is Vite's, which uses its own transform.
|
||||
|
||||
## When to revisit
|
||||
|
||||
When typescript-eslint declares support for TypeScript 7, which requires the
|
||||
JavaScript API or a native alternative to it. That is a one-line version bump plus
|
||||
a `make check` run. This ADR exists so that the pin is a decision with a reason
|
||||
rather than an accident that outlives its justification.
|
||||
|
||||
## A related note: `baseUrl` is deprecated
|
||||
|
||||
TypeScript 6 deprecates `compilerOptions.baseUrl`. The project resolves `paths`
|
||||
relative to the tsconfig instead, which is the documented replacement and removes a
|
||||
compiler option that only ever existed to make `paths` work.
|
||||
119
docs/adr/0005-shared-token-contract.md
Normal file
119
docs/adr/0005-shared-token-contract.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# ADR 0005: One token contract for every application, with a legacy alias layer
|
||||
|
||||
- **Status:** accepted
|
||||
- **Date:** 2026-10-05
|
||||
- **Revisit when:** an application adopts Tailwind v4 as its only styling mechanism, which
|
||||
would let the alias layer be dropped
|
||||
|
||||
## Context
|
||||
|
||||
Six applications consume design decisions today, each in its own dialect:
|
||||
|
||||
| Application | Mechanism | Palettes | Light/dark | Token indirection |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `go-doc` | Tailwind v4 `@theme` | 1, dark hardcoded | none | `--color-ink-*` |
|
||||
| `go-guess` | one 2800-line CSS file | 1, light hardcoded | none | 11 `:root` vars, mostly bypassed by ~120 hardcoded hexes |
|
||||
| `go-loose` | plain CSS | 4 × 2 modes | works | `[data-theme=X]` + `[data-mode=light]` |
|
||||
| `go-mad` | plain CSS, copied from `go-loose` | 1 × 2 modes | CSS present, JS dead | `[data-mode=X]` |
|
||||
| `go-places` | JS-injected `<style>` | 2 objects | broken | inline custom properties |
|
||||
| `go-tell` | plain CSS, **no custom properties at all** | 4, all dark | none | 340 hardcoded hexes |
|
||||
|
||||
Four problems follow from that, and only one of them is aesthetic:
|
||||
|
||||
1. **No shared vocabulary.** `--navy` in `go-guess` and `--surface` in `go-loose` mean
|
||||
unrelated things, so nothing can be shared and no token can be changed in one place.
|
||||
2. **No light mode in three of six.** A theme is the wrong level of abstraction to fix
|
||||
this: the missing axis is *appearance*, and a theme that is dark-only cannot express
|
||||
it.
|
||||
3. **Two incompatible theme mechanisms.** `go-loose` sets `data-theme` and `data-mode`
|
||||
as separate attributes; `go-tell` sets `data-theme` only, with the mode baked into
|
||||
each palette. An application can adopt one or the other, not both.
|
||||
4. **Tailwind is a different language.** A Tailwind application cannot consume a
|
||||
`:root` block; it needs `@theme`.
|
||||
|
||||
The brief was to consolidate, find the middle ground, and make that styling available to
|
||||
every application.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt `go-loose`'s two-attribute model as the contract, because it is the only
|
||||
mechanism that is both working and already copied by another application:
|
||||
|
||||
```html
|
||||
<html data-theme="tokyo-night" data-mode="dark">
|
||||
```
|
||||
|
||||
Two orthogonal axes. `data-theme` is the palette, `data-mode` is the appearance. Every
|
||||
theme therefore has both a light and a dark rendering, which fixes problem 2 without
|
||||
inventing a second concept.
|
||||
|
||||
Token names are namespaced with `--bn-`, and the rendered stylesheet emits a **legacy
|
||||
alias block** underneath:
|
||||
|
||||
```css
|
||||
:root[data-theme='tokyo-night'][data-mode='dark'] {
|
||||
--bn-bg: #1a1b26;
|
||||
--bn-surface: #20222f;
|
||||
/* ... */
|
||||
}
|
||||
|
||||
/* Compatibility with applications that already use these names. */
|
||||
:root[data-theme='tokyo-night'][data-mode='dark'] {
|
||||
--bg: var(--bn-bg);
|
||||
--surface: var(--bn-surface);
|
||||
--text: var(--bn-text);
|
||||
--muted: var(--bn-muted);
|
||||
--line: var(--bn-border);
|
||||
--accent: var(--bn-accent);
|
||||
--good: var(--bn-good);
|
||||
--danger: var(--bn-danger);
|
||||
--radius: var(--bn-radius);
|
||||
}
|
||||
```
|
||||
|
||||
The alias block is the middle ground made literal. `go-loose` and `go-mad` keep working
|
||||
unchanged, and the other four applications can migrate by deleting their own `:root`
|
||||
block and renaming nothing.
|
||||
|
||||
## Rationale
|
||||
|
||||
**Namespaced names, because the stylesheet is injected into someone else's page.** An
|
||||
unprefixed `--surface` on a host page is a collision waiting to happen, and the host's
|
||||
value wins or loses depending on order. `--bn-bg` cannot collide with anything.
|
||||
|
||||
**Aliases, because a rename is a coordinated six-application change.** Emitting both
|
||||
means an application adopts the contract by adding one `<link>`, with no call sites
|
||||
edited. That is the difference between a migration that happens and one that is
|
||||
proposed.
|
||||
|
||||
**Two axes rather than N themes.** Six applications currently have 13 palettes between
|
||||
them, several of which are the same palette twice. Splitting palette from appearance
|
||||
turns 13 one-dimensional choices into 7 palettes × 2 modes, and makes "give me a light
|
||||
version" a solvable problem instead of a request for a new theme.
|
||||
|
||||
**Attributes, not classes.** `[data-theme=X]` beats `.theme-x` because an attribute is
|
||||
one axis for two features, and because a consumer can set it in the `<head>` before
|
||||
first paint. A class list forces the two axes into an ordering the cascade then has to
|
||||
be told about.
|
||||
|
||||
**`color-mix`, not extra tokens.** `go-loose` and `go-mad` already derive hover and
|
||||
subtle states with `color-mix(in srgb, var(--bn-accent) 12%, var(--bn-surface))`. That
|
||||
is kept: it means one accent serves every interaction state, instead of a hand-tuned
|
||||
ladder that drifts between themes.
|
||||
|
||||
**`--bn-font-*` and `--bn-radius` join the set.** Both applications that have a scale
|
||||
disagree (`go-loose` uses 16px cards, `go-mad` 8px; `go-doc` uses Tailwind defaults).
|
||||
Consolidation without them would leave typography and geometry app-local.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The rendered stylesheet grows by the alias block. It is small, it is compressible, and
|
||||
it is removed entirely once an application has renamed its own references.
|
||||
- `go-doc` needs a three-line Tailwind bridge, because `@theme` cannot read a runtime
|
||||
custom property. It becomes `@theme inline { --color-bn-bg: var(--bn-bg) }`.
|
||||
- `go-tell`'s four themes are palettes with the appearance baked in. They become four
|
||||
`data-theme` values with their dark values as the `dark` mode, and their light
|
||||
counterparts have to be authored. That work is the honest cost of the migration.
|
||||
- Appearance preference is per-application. Two applications on the same browser do not
|
||||
share a `data-mode` unless they agree on a storage key; the shipped components use one
|
||||
key per origin and document it.
|
||||
Loading…
Add table
Add a link
Reference in a new issue