← Back to Konde Blog
ENGINEERING

KDF — the design framework powering Konde

KDF is the token-based design framework we built to keep fifty-six themes in lockstep across Studio, Caster, KW Render, and Konde Docs. Tokens in JSON, classes in TSX, dynamic-resolved at render — here is the architecture and why it works.

When you ship a desktop app, a publishing platform, a docs site, and a landing-page renderer, you have a problem most product teams do not: design coherence across surfaces that were built by different people, in different runtimes, deployed to different places. Studio is SwiftUI. Konde Docs is server-rendered HTML on Cloudflare Workers. KW Render is a static-site generator. Caster runs as a SwiftUI panel inside Studio plus a public web app on Vercel.

If we tried to keep these in sync by copy-pasting Figma colours into four codebases, we would lose. So we built KDF — Konde Design Framework — to be the single source of truth that all four surfaces read from.

The shape of the problem

A theme is a set of tokens. Brand colour, surface colour, text-1, text-2, accent, success, warning, error, six radii, six elevations, two type scales. Forty or so values per theme, give or take. Multiply by fifty-six themes (we ship fifty-four optional plus creamy light and midnight dark as defaults), and you have ~2,200 individual values.

Three constraints fell out from the start:

  1. No build-time baking. If a user picks seafoam in Studio at 2pm, this blog should render in seafoam at 2:01pm without us redeploying anything. That rules out compiled-in CSS.
  2. Same tokens, different runtimes. The token must resolve to a SwiftUI Color, a CSS variable, and a Tailwind-style class — without us hand-mapping each.
  3. Dual mode. Every theme has a light and a dark variant. Switching between them must be instant and never reflow the layout.

The architecture

Three layers:

Layer 1 — Token JSON

Each theme is a single JSON file. Roughly a hundred lines, mostly the colour palette plus a handful of structural overrides. A trimmed example:

{
  "name": "seafoam",
  "base": "creamy",
  "light": { "accent": "#15a34a", "surface-1": "#f5f8f6", "...": "..." },
  "dark":  { "accent": "#5dd39e", "surface-1": "#0c1815", "...": "..." }
}

The base field points to a fallback theme — when a value is missing, we look it up in the base. This means a new theme typically only needs to override the dozen tokens that actually differ from creamy or midnight, not all forty.

Layer 2 — The resolver

A small TypeScript module (~200 lines) that takes a token JSON plus a mode (light | dark) and emits a stylesheet. Output is plain CSS custom properties scoped to a :root selector:

:root[data-theme="seafoam"][data-mode="light"] {
  --accent: #15a34a;
  --surface-1: #f5f8f6;
  /* ... */
}

The resolver is shared code. It runs in the Konde Docs Worker (server-side at request time), in Studio (Swift bridge calls the same TS via JavaScriptCore), and in KW Render (build step). One source, three runtimes, identical output.

Layer 3 — The classes

In TSX, components reference tokens via classes that we generate from a manifest:

<button className="bg-accent text-on-accent rounded-md shadow-elev-1">
  Sign up
</button>

bg-accent is generated to background: var(--accent). We did not adopt Tailwind directly — we authored a small ~600-line atomic CSS file that mirrors Tailwind's API but reads from our token names. This sounds like reinventing the wheel until you realise that mapping fifty-six themes through Tailwind's config would have been gnarlier than writing the atomic CSS ourselves.

What you get from this

Theme switching is one DOM attribute change. Set data-theme="seafoam" on the root, and every component on the page paints with the new tokens — no re-render, no React reconciliation, no flash of unstyled content. We measured the swap at <16ms on an M1.

Adding a theme is a JSON file. Yoona-or-an-agent drops a new file in themes/, runs the resolver, the theme appears in Studio's picker. No code changes. No deploy.

Cross-surface coherence is automatic. When you pick seafoam in Studio, Studio writes the choice to your local Konde data. Konde Docs reads it on its next render (the Worker fetches the user's active theme via your AKK). Caster reads it. KW Render reads it. The whole Konde footprint paints together.

What we got wrong the first time

Two things we had to rip out:

Attempting CSS-in-JS for theming. Day one, we tried to do this with vanilla-extract so that types flowed from token JSON into TSX. Beautiful in principle. In practice, the build-step coupling broke our "no build-time baking" constraint — every theme change required a rebuild of every consuming surface. Custom properties solve this without the type ergonomics, and we accepted that trade-off.

Encoding semantic intent in the tokens. Originally we had --button-primary-bg, --button-primary-text, etc. — semantic names that described usage. After two months we had three hundred tokens and constant arguments about whether --card-header-text should equal --text-1 or --text-2. We collapsed to forty primitive tokens (just colours, sizes, radii, elevations) and let TSX components combine them. The semantic layer lives in components, where it belongs.

The open question

KDF is internal-only today. The obvious next move is to ship it as a public module, so the same theme picker that makes Studio paint your blog in seafoam also paints third-party Konde extensions. We are likely to do this in Q4 alongside the KSS App Store launch, but it is the kind of API design we want to get right rather than fast.

If you have built something similar at your shop, I would love to compare notes. Find me on X.

Share X LinkedIn Facebook