Jerry Orta
← Concepts

Design Library Architecture

  • architecture
  • design-system
  • css
  • angular
  • theming

Sample code — the actual source files this note examines, in the public ngx-experiments repo:

A design library is best understood as two libraries rather than one. The components live in a design library; the values that color them live in a separate themes library. Between them lies nothing but a contract of CSS custom properties — no import, no shared build. That seam is the entire design. It is what allows one set of components to wear six different skins, to restyle with a class swap rather than a recompile, to encode a domain's own vocabulary in its tokens, and still tree-shake to only what an application imports.

Everything below is grounded in two real, public libraries built to the same spine: a shared design system (dlc- prefix — the @nge/ui-design-library components over the @nge/themes token sets) and a personal-finance domain built on top of it (ldg- prefix). One defines its values from scratch; the other inherits them and adds only its own — and that contrast is the point.

Two libraries, one contract

Draw the line in the right place and the coupling disappears. The design library knows structure and behavior — a button is a button, a card is a card. The themes library knows values — one persona's primary is navy, another's is sage. Neither library imports the other. They meet at a namespace of CSS variables — --dlc-* for the shared library, --ldg-* for a domain such as ledger.

A component reads the contract:

/* design library — dlc-button reads a token without knowing its eventual value */
.dlc-button--primary button {
  background-color: var(--dlc-secondary);
  color: var(--dlc-on-secondary);
}

A theme supplies it:

/* themes — .dlc-professional-light supplies values for the same names */
.dlc-professional-light {
  --dlc-secondary: #c4841d; /* gold */
  --dlc-on-secondary: #ffffff;
}

The contract itself is written down — a token file (_dlc-tokens.scss) that declares every --dlc-* name and what it means, with the actual values living in the persona files. The component depends on the names; the theme owns the values; the token file is the treaty both sign. Adding a component touches no theme; adding a theme touches no component. The five sections that follow are all consequences of that one cut.

Encapsulation, inverted

Angular's default is to scope a component's styles to itself — emulated encapsulation rewrites the component's selectors so they cannot leak out or in. A design library wants the opposite, so every library component opts out:

@Component({
  encapsulation: ViewEncapsulation.None,
  host: { class: 'dlc-button' },
  selector: 'dlc-button',
  // …
})

That is a real, shipped component — dlc-button from @nge/ui-design-library, with its source linked at the top of this note.

With ViewEncapsulation.None there is no automatic scoping, so the library supplies its own by hand: a host class matching the selector, and SCSS wrapped in that same class — never :host, which does not behave under None.

.dlc-button {            /* wrapper class, matches host — the manual scope */
  button {
    border-radius: var(--dlc-radius-md);
    font-family: var(--dlc-font-family-body);
  }
}

Why give up the framework's scoping? Because emulated encapsulation would wall off the very thing the contract depends on. CSS custom properties cascade through the DOM tree, and a theme is simply those properties declared high on the document root. With encapsulation turned off, the token values flow freely from down into every component; the wrapper class keeps the library's own rules from leaking while letting the tokens through. Inverting the default is what makes a page-level theme reach a deeply nested button at all.

A component that needs no theme

Consider the button token again: what renders if no theme is applied? A bare Storybook cell, a unit test, a component dropped on an empty page — none of them carry a .dlc-professional-light ancestor. The answer is that every color token carries a literal fallback, so a component is self-sufficient:

.ldg-account-card__button {
  background: var(--ldg-card, #edf1f5);
  border: 1px solid var(--ldg-border, #c4d0da);
  color: var(--ldg-on-surface, #121518);
}

If --ldg-card is defined, the theme wins; if nothing defined it, the component still paints #edf1f5 and looks correct. The theme becomes an override, not a dependency. This is also where the library draws its firmest boundary against a framework theme runtime: these are own-namespace ---* tokens with real hex fallbacks, never --mat-sys-* and never a mat-* element — an approach proven out without Angular Material anywhere in the component's styles. A component ships knowing how to look like itself, and a theme only ever refines that.

A theme is a class, not a build

Because a theme is only a set of values, switching themes is a class swap, not a rebuild. The themes library ships one class per look, each re-declaring the same contract with different values:

.dlc-professional-light { --dlc-primary: #0f2b3c; --dlc-secondary: #c4841d; /* navy + gold */ }
.dlc-home-light         { --dlc-primary: #3d7b5f; --dlc-secondary: #c4704d; /* sage + terracotta */ }

The shared themes library ships six of these — three brand personas (professional for brokers, home for clients, service-provider for vendors) across light and dark. An entry mixin emits them all, and the application includes it once:

@use 'dlc-themes';
@include dlc-themes.dlc-theme-mixin();
/* then, on the document: <html class="dlc-professional-light"> */

Swapping the class on re-colors every component on the page in a single paint — no recompile, no rebuilt bundle, no component aware it happened. Class specificity beats a :root default, so themed values win wherever the class is present. One detail matters here: the persona class belongs on the document root, because the base layer styles body background, text, and scrollbars through the same var(--dlc-*) tokens — apply the class to a nested element and those page-level globals are never reached.

Tokens speak the domain's language

A token layer is not limited to primary and surface. It is the place a domain encodes its own vocabulary — the same ubiquitous language from Domain-Driven Design, reaching all the way into the palette. The ledger domain deals in money, so it defines what money means as color:

/* financial polarity — deliberately its own signal, not a validation state */
@mixin ldg-money-tokens-light() {
  --ldg-money-positive: #047857; /* emerald — credits, income, balance up */
  --ldg-money-negative: #be123c; /* rose — debits, expenses, balance down */
}

These are new values, not aliases of a generic --success/--error, and that is intentional: a credit is not a passing form field, so financial polarity reads as its own signal. The domain also owns a qualitative palette for its spending-category chart — eight hues spread around the wheel, kept distinct from the money colors so a category never reads as a gain or a loss:

@mixin ldg-category-tokens-light() {
  --ldg-category-1: #2563eb; /* blue */
  --ldg-category-2: #ea580c; /* orange */
  /* … */
  --ldg-category-8: #475569; /* slate — the "other" catch-all */
}

There is even a rule recorded in the token's documentation: assign categories to slots in a fixed order and never re-point a slot's hue once data has shipped with it, or every historical chart silently relabels itself. The palette is not decoration — it is a stable part of the domain's contract with its own past data. Tokens are where design meets domain modeling.

Define it, or inherit it

Here is where the two libraries diverge, and it is the most useful contrast in the whole design. Both ride the same spine — components read a --prefix-* contract, themes supply it — but they make opposite choices about where the values come from.

The shared foundation defines. @nge/themes owns the full --dlc-* palette outright and writes all six persona themes from scratch — every navy, every gold, hand-tuned. It is the base every consumer builds on.

A domain inherits. Ledger does not redefine a palette; its tokens are pure passthrough aliases of that shared persona contract (--dlc-*):

/* ledger — one indirection per token, declared once for all six persona classes */
@mixin ldg-aliases() {
  --ldg-surface: var(--dlc-surface, #f8f9fa);
  --ldg-card:    var(--dlc-surface-container, #edf1f5);
  --ldg-primary: var(--dlc-primary, #0f2b3c);
  /* … */
}

Custom-property indirection does the work: --ldg-surface resolves against whichever .dlc-* persona class is active on an ancestor, so the aliases follow the shared personas with no persona-switching logic of their own — declared once, never duplicated per persona. Ledger writes fresh values only for what is genuinely its own (the money and category tokens above). Its components follow the same approach, composing shared dlc-* primitives and adding domain pieces — an account card, a budget card — on top.

The same architecture offers two dials. The shared library defines the contract so every domain has a floor to stand on; a domain that needs to move quickly aliases it and overrides only its deltas. The relationship runs both ways: a component that proves broadly useful is promoted up the hierarchy — from a domain library into the shared one — because the only thing that changes is its prefix, and the contract shape stays identical.

The seam is also a bridge

One last dividend of making the contract nothing but CSS variables: the themes library becomes the single place to translate a domain's palette into another library's contract. A shared charts library exposes its own --giga-chart-* tokens (the theming seam described in Charts Library Architecture). The themes library does not fork the charts library to recolor it — each persona theme simply binds the chart contract to its own tokens:

.dlc-professional-light {
  /* --- chart token bridge --- */
  --giga-chart-surface:   var(--dlc-surface);
  --giga-chart-primary:   var(--dlc-primary);
  --giga-chart-secondary: var(--dlc-secondary);
  --giga-chart-tertiary:  var(--dlc-warning); /* no tertiary in the palette — nearest match */
}

Now a shared chart renders in full persona dress — different for each of the six personas — and the charts library never learns the theme layer exists. The same technique recolors the Storybook review chrome (--sb-review-*) per persona. Every one of these is one library's contract expressed in another's values, and the themes layer is where that translation lives. The seam that decouples components from values also lets whole libraries meet without depending on each other.

The shape an agent can extend

All of this has the same second payoff as the charts library: the pieces are small, additive, and governed by a written contract, so change is bounded. A new component follows a fixed recipe — a prefixed selector, a matching host class, a wrapper-class stylesheet, tokens with literal fallbacks, a Storybook story. A new theme is one class file re-declaring the contract. A new token is a line in the token file plus a value in each theme. Nothing edits a central registry; there is no shared switch to break.

That is exactly the shape I hand to Claude Code. The skills I rely on encode this contract directly — one verifies (and auto-fixes) that a component's tokens and accessibility hooks match the standard, another promotes a component up the placement hierarchy as a pure prefix swap. The agent works from the same best-practices document I would, and the library's own lint and tests are the gate. The architecture that keeps the design system coherent is the same one that makes extending it a low-risk, well-specified task.

Why it holds together

Six decisions, one shape — and a dividend:

  • Two libraries, one contract — components own structure, themes own values, and a CSS-variable namespace is the only thing between them, so neither change touches the other.
  • Encapsulation invertedViewEncapsulation.None plus a hand-written wrapper class lets theme tokens cascade from the document root into every component instead of being walled off.
  • Literal fallbacks — every token carries a real default, so a component renders correctly with no theme at all; the theme is an override, never a dependency (and never Angular Material).
  • A theme is a class — one class per look re-declares the contract, so switching among six personas is a class swap on , not a recompile.
  • Tokens speak the domain — money polarity, a stable category palette; the token layer is where a domain's language becomes color.
  • Define it or inherit it — the shared foundation writes all six personas from scratch; a domain aliases that contract and overrides only its deltas, on the identical spine.
  • The seam is a bridge — because the contract is only variables, the themes layer can translate a domain's palette into a shared chart's or Storybook's contract, letting libraries meet without importing each other.

The payoff is that the sixth persona costs what the first one did — and a shared chart, a new component, and an AI agent all read from the same contract.