Skip to main content

Architecture

How the pieces fit, and where a change goes

Six layers, each depending only on the ones before it. One test that decides whether new work is a component or a pattern. A JavaScript model where markup works first and behaviour is added, never required. Everything that talks to the outside world behind an interface.


The layer model

Each layer depends only on the ones before it. Nothing downstream is allowed to reach back — a component may not know which product is using it, and a foundation may not know a component exists.

  1. Principles what does not change, and why
  2. Foundations colour, type, space, motion — the values everything is measured against
  3. Components one building block, one purpose, no task attached
  4. Patterns components plus behaviour, solving a recurring user task
  5. Derived systems a design system of your own: its foundation, Dessau DS’s everything else — and no dependency on Dessau DS at runtime
  6. Products yours — the only layer allowed to know its own domain

Why the direction is one-way

The moment a component knows about addresses, contracts, invoices or orders, it stops being reusable and becomes a copy waiting to be made. Domain knowledge belongs in the product layer, and the way down is through configuration and content — never through a special case inside the component.

The cascade enforces the same direction in CSS: see cascade layers on the foundations page. Your stylesheet is unlayered, so it beats every DDS rule without a specificity fight and without !important.

Component or pattern?

The question that decides where new work goes, and the one most often answered by file size instead of by what the thing actually is.

Component

One reusable building block with one purpose. It has variants, sizes and states, and it does not know what task it is being used for.

button · field · checkbox · switch · select · badge · card · dialog · table · tabs · disclosure · avatar · chip · stepper

Pattern

Components plus behaviour, solving a recurring user task. It owns what a single element cannot: focus order, live announcements, request lifecycle, error recovery, and the fallback when the clever path fails.

address search · autocomplete · form validation · search and results · filtering · multi-step form · conditional fields · authentication

Five tests, in order. The first one that answers, answers.

  1. Does it render sensibly in isolation, with no task in mind?
    Yes: component. A button is a button with no context.
  2. Does describing it require the word “then”?
    “The user types, then results appear, then selecting one fills the fields.” That is a pattern.
  3. Does it own asynchronous behaviour, focus management or live announcements?
    Pattern. Those are exactly the concerns a single element cannot hold.
  4. Would two different products use it for two different tasks?
    Yes: component. No: pattern.
  5. Does it talk to an external service?
    Pattern, and the service goes behind a provider interface.

Worked examples

Whether each thing is a component or a pattern, and why
Thing Which Why
Text inputComponentNo task attached
ComboboxPatternQuery lifecycle, aria-activedescendant, announcements
Address searchPatternCombobox + fields + provider + fallback
DialogComponentA container; the platform owns the behaviour
Confirm before deletingPatternDialog + wording + focus + consequence
TableComponentPresents rows; task-agnostic
Search and resultsPatternFour states, announcements, request lifecycle
Error messageComponentOne labelled message
Form validationPatternWhen to show, summary, focus, recovery
ChipComponentA removable token
FilteringPatternChips + controls + results + empty + URL state

The failure mode to avoid

A “component” that knows about addresses. A “pattern” that is really just a styled box. Both happen when the decision is made by how big the file is rather than by the tests above.

The JavaScript model

Four functions, and nothing else. Markup exists first and works first; JavaScript finds elements that opted in via a data-dds-* attribute and adds behaviour the platform does not provide.

DDS.register(name, selector, setup)   // register an enhancement
DDS.enhance(root)                     // apply enhancements in a subtree
DDS.announce(message, options)        // speak to assistive technology
DDS.theme                             // read / set / observe the theme
DDS.lockScroll() / DDS.unlockScroll() // hold the page still behind a modal
                                     // surface — reference-counted, offset kept

Idempotent, re-runnable, and order-independent

DDS.enhance(element) after inserting markup is all a server-rendered, HTMX, Turbo or framework-driven product needs. There is no lifecycle to hook into and nothing to tear down; elements are marked once enhanced and skipped thereafter.

A register() call arriving after the initial sweep enhances matching elements immediately, so script order cannot decide whether anything works. That is not a theoretical nicety: an earlier version swept the document before any pattern file had registered, and nothing on any page was enhanced — silently, because markup without behaviour still renders and still submits.

No Web Components, deliberately

  • Shadow DOM encapsulates away the custom properties the entire token architecture depends on.
  • A custom element that has not upgraded renders as nothing — the opposite of progressive enhancement.
  • It forces a shared JavaScript runtime on every consumer, including the ones that render on the server.

What is shared is the CSS and token layer plus reference markup including ARIA. Behaviour is offered — dds/js/ is genuinely usable — but never required. A product may reimplement any behaviour in its own idiom and keep identical markup and styling.

External services

Anything that talks to a service goes behind a provider: one object with one method, documented as an interface. A third-party service is always specific to a country or a contract, and it will be replaced at least once during a product's life.

A provider must

  • return a promise, even when resolving synchronously;
  • honour an AbortSignal, so a slow earlier response cannot overwrite a fast later one;
  • reject on failure rather than resolving empty, so “the service is down” and “there is no such thing” can be worded differently — they need different words, and a caller that cannot tell them apart will pick the wrong one;
  • never be required for the task to be completable by hand.

The reference case is the address provider. Everything genuinely reusable — the interaction, the keyboard handling, the announcements, the fallback to manual entry — lives in the pattern; everything specific lives behind the interface. Swapping the provider is one object, and the pattern does not change.

Verification is part of the architecture

Several classes of failure in this system are silent — no console error, no broken layout, no failing test, just a piece of design quietly absent. They are caught by script, not by review.

Every check run by npm run check, and what each one catches
Script Catches
check-css.mjs An undefined custom property, a primitive colour leaking past the semantic layer, a raw colour value, a class the JavaScript toggles that no stylesheet defines, display on a dialog outside [open], a class whose display defeats the hidden attribute, and a container query whose container does not exist.
check-version.mjs package.json's version and dds/js/dds.js's VERSION constant disagreeing — the release-day mistake of bumping one and forgetting the other.
check-contrast.mjs Any colour pair below its WCAG 2.2 AA threshold, in both themes — text, borders, focus rings and status fills.
check-accent-separation.mjs Two accent colours that have drifted close enough to be mistaken for each other, in either theme — which makes a chart legend or a set of category tags say nothing while every contrast check still passes.
check-icons.mjs A Unicode glyph or emoji used as an icon, a content: escape drawing one, a <use> naming a symbol that is not on the page (which renders as nothing at all), a script building a <use> for a symbol the sprite does not have, and a symbol nothing uses and no one has declared a reason for.
check-agent-index.mjs A claim in agent/index.json that no longer holds — a missing class, file, hook or specification section — and the reverse: a component in the CSS that no entry covers, which an agent cannot discover and will build a second time.
check-reference.mjs A documented component with no rendered example, an anchor or in-page link that does not resolve, a token name no stylesheet declares, an asset that does not load, unbalanced markup, a forced data-theme with no rule to match, a flex component missing its -body wrapper, a <video> or <audio> with no transcript beside it, and a stale generated block.
check-enhancement-coverage.mjs A registered enhancement with no browser test, a spec that never says what it covers, a coverage note that has gone stale in either direction, and a @covers naming an enhancement nothing registers.
check-adoption.mjs A path named in the README or in agent/ that does not exist, and a script in dds/js/ that the behaviour table in new-product.md has stopped agreeing with in either direction.
build-foundations.mjs
verify only · regenerate to fix
A stale machine-readable export, when run with --check. The CSS is the source of truth and dds/foundations.json is downstream of it.
sync-breakpoints.mjs
verify only · regenerate to fix
A stale breakpoint table on the foundations page, and a container query with no stated reason for its threshold.
sync-checks.mjs
verify only · regenerate to fix
A stale verification table on the architecture page, and a check script that never says what it catches.
sync-responsive.mjs
verify only · regenerate to fix
A specification whose responsive summary no longer matches the index.
sync-reference-nav.mjs
verify only · regenerate to fix
A reference page missing from the site navigation, an entry pointing at a page that does not exist, a page in reference/ that no group claims (unless it is a dds-reference-owner page), a derived-system-owned page whose dds-reference-group names no group, and a wrong or missing aria-current="page".
sync-reference-toc.mjs
verify only · regenerate to fix
A stale "On this page" navigation, when run with --check.
sync-icons.mjs
verify only · regenerate to fix
A stale inline icon sprite, or a page referring to an icon the sprite does not have.
sync-cache-busting.mjs
verify only · regenerate to fix
A stylesheet or script served from a stale browser cache, which presents as a component defect rather than as a caching problem.
audit-whitelabel.mjs Any prohibited term in a committable file or in a commit message — names, domain vocabulary, internal hosts, cliché placeholder data, and phrases that describe provenance without naming it.

All of it zero-dependency, Node standard library only. One command: npm run check.

Generated from the check:* scripts in package.json and the header comment of each script, by scripts/sync-checks.mjs. A hand-written list of checks is the first thing to go stale — the version in agent/architecture.md was missing four of them within a week.

And what a script cannot catch

Anything that is only true once the cascade has run: a custom property resolving differently because of inheritance, a UA stylesheet beating an author rule, focus actually moving where it was meant to, inert genuinely removing a subtree from the tab order. Each of those has shipped broken here, and each looked correct in the source.

That is what tests/ is for: npx playwright test, on all three engines. WebKit is not optional — it is the engine on every iPhone and iPad, and it is where a container query or :has() behaves differently first. Firefox is not optional either, for the opposite reason: Blink and WebKit agree with each other more often than either agrees with Gecko, so two of them can be green while the third is where a feature behind @supports actually diverges.

What the suite still cannot do is look at the pages. A layout that is wrong but not broken — a mask clipping a shape badly, an odd rag, something a few pixels off centre — passes every assertion in it.

How a product consumes Dessau DS

Pinned and local. Never loaded at runtime from a shared URL, so a change here can never reach a product untested.

A pinned artefact, not a live endpoint

A submodule pinned to a commit, or a copy. Both are deliberate: an update is something a product chooses, at a moment when someone is watching. The alternative — a CDN URL every product loads — means a change here becomes a change in production everywhere, simultaneously, with no review.

There is no build step and no runtime dependency. The stylesheets can be linked directly in the layer order, imported through dds/dds.css, or concatenated by whatever pipeline already exists. All three are equivalent.

The seven-step setup is agent/recipes/new-product.md; the README routes to it. A derived design system takes a different route and substitutes the foundation rather than overriding it — agent/recipes/derive-a-standalone-system.md.

Browser support, and what happens below it

Baseline 2023: Chrome 111, Safari 16.4, Firefox 121 and later. That floor is what makes container queries, :has(), cascade layers, 1lh and inert usable without a polyfill.

Below it, there is no fallback and that is a decision rather than an oversight — but it has a visible consequence worth knowing: every component stays permanently in its narrow form, because no container query ever matches. The result is usable and looks like a bug.