Design Library Architecture
Sample code — the actual source files this note examines, in the public ngx-experiments repo:
- Component —
dlc-button.component.ts - SCSS —
dlc-button.component.scss - Theme —
_dlc-professional-light.scss
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 inverted —
ViewEncapsulation.Noneplus 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.