Design System API for Drupal — Concept Notes¶
Status: Ideation (revision 2 — after prior-art research and concept review)
1. Position¶
- A design system is independent of any component library. Proof: one design system can have several component libraries as implementations, and each can be created or deleted without affecting the design system.
- A component library focuses on reusability, atomic design and developer experience. A design system is about authoring experience and the end goal (consistent look and UX). A component library fills only a small part of it.
2. A modern Drupal, not the theming system as we know it¶
Drupal's theming system grew up when a theme meant "override whatever markup you need". That flexibility is why contrib UI looks inconsistent today: every module ships its own markup and CSS, and every theme patches it from the outside. A shared design system can't sit on top of that model; it needs a different one.
This concept deliberately targets a modernized Drupal and accepts that some long-standing practices stop being supported along the way. That's a feature, not collateral damage: it's what makes "looks and behaves right under any theme" achievable.
What the modern baseline assumes¶
| Area | Modern baseline |
|---|---|
| Components | SDC is the component model. Patterns are realized as components, not as template overrides of theme hooks. |
| Tokens | Core Design Tokens API (DTCG format) for declaration and storage. |
| CSS | Evergreen browsers only: custom properties, @layer, @scope, prefers-color-scheme / prefers-contrast media queries, logical properties, container queries. |
| JavaScript | No jQuery dependency in the contract. Islands in any framework, or none. |
| Theme role | A theme provides values and realizations for a contract. It does not reach into contrib markup. |
| Contrib role | Contrib codes against the contract. It ships no theme-specific CSS. |
What we stop doing (for code that opts in)¶
- Overriding contrib or core markup from themes as the primary way to make things look right. Themes supply tokens and pattern realizations; module markup is not their concern.
- Theme-specific CSS in contrib (
if Claro then…,gin.cssoverrides shipped by modules). - Hard-coded values (colors, spacing, radii) in module CSS.
- Deep base-theme inheritance as the way to share design decisions. Design decisions live in the design system stack, not in a theme chain.
- Preprocess-driven markup changes for styling purposes.
How we get there without breaking everyone at once¶
The break is opt-in and staged, in line with Drupal's own deprecate-in-minor, remove-in-major policy:
- Coexistence. Contract-based code and legacy theming live side by side. The contract ships default values (see Q2), so a module that uses it still looks acceptable under a legacy theme that knows nothing about it.
- Adoption by core UI first. Default Admin (and, for sites still on them, Claro and Gin) maps its tokens to the contract; admin-facing core and contrib UI moves to it.
- Deprecation. Once the contract covers the admin context, theme-hook overrides of migrated UI get deprecated.
- New default. A future major release makes the contract the default way to build UI. Legacy theming remains as a compatibility layer for as long as the community needs it.
| Approach | Pros | Cons |
|---|---|---|
| Clean break (new theming generation, no compatibility) | Simplest model; no legacy constraints; fastest to a coherent result | Leaves thousands of themes and modules behind; unlikely to be accepted in core |
| Staged opt-in (above) | Realistic for core; lets value appear early in the admin context | Two systems coexist for years; needs clear guidance on which to use |
| Pure add-on (never deprecate anything) | No resistance | Never fixes the inconsistency; the old model keeps winning by default |
Leaning: staged opt-in, but designed as if it were the clean break. The contract never compromises its model to fit legacy theming; the bridge does the compromising.
3. Problem statement¶
The Drupal 12 landscape¶
Drupal 12 simplifies the admin side and empties the frontend side:
- One admin theme in core. Default Admin, a fork of Gin, is the only admin theme core ships. Claro leaves core (it continues as the contrib
drupal/claro3.x); Gin stays in contrib. - No dedicated frontend theme in core. Olivero leaves core as well. The Standard profile installs only Default Admin and uses it for the frontend too, so real sites replace the frontend theme with a contrib or custom one (Mercury in Drupal CMS, starterkit copies, agency themes).
A single admin theme makes the admin half look solved. It isn't, and the frontend half has no reference designed for it.
Pain today¶
- Contrib has no exact API to make its UI match the admin theme. The closest thing is Default Admin's own
--admin-*namespace. That's the theme's implementation, not a contract: it's versioned with the theme, nothing says which names are stable, and it doesn't exist under Claro or Gin, which many sites keep using. So a module such as Media Library either couples itself to one theme's internals or ships its own CSS and looks slightly off everywhere. - Even a perfect admin match looks broken on the frontend. Contrib UI doesn't stay on admin routes. Login and contact forms, Webform, Commerce checkout, the Media Library widget in a frontend node form, the Navigation bar (default since 11.4) and the Canvas editing UI all render inside whatever frontend theme the site uses. A module styled against
--admin-*meets undefined variables there and falls back to unstyled, or drags the admin look into the site's design. A module styled for one frontend theme breaks on admin routes and in every other frontend theme. With no dedicated frontend theme in Drupal 12 core, there isn't even a frontend default to aim for. What the module needs is one set of names whose values change with the context it renders in. - Every frontend theme invents its own token names. Mercury clones, Canvas global CSS and Olivero (now contrib) all use different names, so nothing built for one fits another.
- AI-assisted generation has nothing precise to follow. Design guidance exists as prose, if at all; generated UI can't be checked against it.
Success criteria for the first milestone¶
- A contrib module (target: Media Library) contains no theme-specific CSS and no hard-coded values.
- It looks and behaves correctly under Default Admin, the Drupal 12 admin theme (and under Claro and Gin for sites still on them), in light, dark, RTL and forced-colors modes.
- The same module UI on a frontend page (e.g. the Media Library widget in a frontend node form), under a frontend theme that knows nothing about the contract, looks acceptable via the contract's frontend defaults: neither a fragment of the admin theme nor unstyled.
- A linter reports any hard-coded value in the module's CSS against a named rule.
4. Declare, fill, render¶
The whole system, followed through one example: the border of a card in Media Library.
Follow it interactively: pick a theme and a mode, and see every step from the module's CSS to the colour on screen.
| Step | Who | When | What happens to the card border |
|---|---|---|---|
| 1. Declare | The module developer | Once, in code | "My card has a border. By default, use the theme's primary border color." |
| 2. Fill | The site author | Any time, in the registry | "On our Mercury site, make it red." |
| 3. Render | Drupal | On every page (cached) | The page receives a CSS variable holding the right color for where it is shown. |
Step 1. Declare: the module says what it has¶
A module lists the design tokens its UI uses, in core's token file. Each token comes with a suggested mapping to the shared vocabulary, so it looks right under any theme without anyone configuring it.
# media_library.tokens.yml: shipped by the module
media-card:
border:
$type: color
$value: "{color.border.primary}" # the suggested mapping
This is an illustration; the shipped pilot maps media-card.border to color.border.default and uses color.border.primary for the selected border.
The shared vocabulary (color.border.primary, color.bg.surface, space.inline.m, …) belongs to this project and ships with default values. Pattern specs and rules are declared alongside it (§8). The file format is DTCG, the same one core's Design Tokens API uses (Q15).
Step 2. Fill: the author decides the values¶
The registry is where a site's design lives. Authors work in one context at a time, for example "Mercury" or "Mercury, dark mode", and set one value per token there. Every context has the same structure; only the values differ. Most of the time authors work on the shared vocabulary, and they only touch a component when it should look different.
Authors can also declare their own primitive tokens, such as a brand palette, once for the whole registry:
# Registry · primitives (illustrative, not the final format)
brand.navy.700: "#1d3557"
brand.navy.200: "#a8c5e6"
brand.red.600: "#c8102e"
brand.red.300: "#ff6b6b"
A value in a context can then be a reference to any token, a primitive or another semantic token, or a plain value:
# Registry · context: theme = Mercury
color.border.primary: "{brand.navy.700}"
media-card.border: "{brand.red.600}"
# Registry · context: theme = Mercury, mode = dark
color.border.primary: "{brand.navy.200}"
media-card.border: "{brand.red.300}"
# Registry · context: theme = Mercury, contrast = more
media-card.border: "#8b0000"
# everything left empty inherits from "theme = Mercury"
color.border.primary is a shared token, so everything that uses it follows. media-card.border is an override that affects only the media card. Dark mode and high contrast are contexts like any other, so adding a new one means adding one more value set of the same shape, not a new mechanism. A value left empty inherits from the broader context. Also worth knowing:
- Nobody starts from zero. A theme adapter pre-fills its theme's values, and can bring the theme's own palette as primitives.
- Primitives stay with the author. They are not part of the shared vocabulary, modules never reference them, and they are resolved to plain values in the page output, so no module can come to depend on one site's palette.
- Changes are published as snapshots, so they can be reviewed and rolled back. A context can have more than one, for example a Christmas snapshot that is active from 20 to 27 December.
Step 3. Render: the page gets the result¶
Contexts come in two kinds, and Drupal handles them differently:
- Server-side contexts (the theme; later the date) decide which values the page gets. Drupal layers them, contract defaults first, then the adapter's pre-fill, then the author's values, and caches the result per context. The highest layer that sets a token owns it in every context: an adapter that maps a token to the theme's own variable once keeps it in dark mode too, so the theme's own dark mode applies.
- Page-side contexts (dark mode, high contrast; later page regions) are all delivered on the page, each in its own block with its own selector. The browser's cascade picks the right value for each element.
/* What a Mercury page receives: generated and cached by Drupal */
@layer ds.context {
@layer base, contrast, mode; /* later wins on one element */
@layer base {
:root { /* default */
--ds-color-border-primary: #1d3557;
--ds-media-card-border: #c8102e;
}
[data-ds-mode="light"] { /* light chosen inside a dark page */
--ds-color-border-primary: #1d3557;
--ds-media-card-border: #c8102e;
}
}
@layer contrast {
@media (prefers-contrast: more) { /* contrast=more, from the OS setting */
:root { --ds-media-card-border: #8b0000; }
}
[data-ds-contrast="more"] { /* contrast=more, chosen on the page */
--ds-media-card-border: #8b0000;
}
}
@layer mode {
@media (prefers-color-scheme: dark) { /* mode=dark, from the OS setting */
:root:not([data-ds-mode="light"]) {
--ds-color-border-primary: #a8c5e6;
--ds-media-card-border: #ff6b6b;
}
}
[data-ds-mode="dark"] { /* mode=dark, chosen on the page */
--ds-color-border-primary: #a8c5e6;
--ds-media-card-border: #ff6b6b;
}
}
}
Each page-side context declares its selectors: a media query for the user's settings, an attribute for an explicit choice. Because [data-ds-mode="dark"] works on any element, a dark panel inside a light page just works. A theme declares which color schemes it supports (in its info file, its adapter or the registry). Only then does the OS setting switch it to dark, so a theme without a dark mode keeps module UI light on its light page. A block holds only the values that differ, so each extra context adds little. The order between contexts is fixed with sub-layers of ds.context (base, contrast, mode), so on one element the outcome doesn't depend on selector specificity: dark wins over more contrast, so a light high-contrast value never lands on a dark background. Nested regions are different: a contrast region inside a dark one declares its own values, which beat the inherited dark ones. That needs a combined value set (dark + more contrast) and combined selectors or @scope; v0 doesn't generate them yet.
The module's own CSS never changes. It only uses the names:
/* media-library.css: shipped by the module */
.media-card {
border-color: var(--ds-media-card-border);
}
On a Mercury page the border is red. Under Default Admin, or under a theme nobody configured, it follows that theme's primary border color. Twig templates, SDC components and JavaScript islands all work the same way, because they all read CSS variables. For uses outside CSS (email, PDF, AI tools), a PHP API returns the same values.
Ground rules¶
- A new module works with zero setup. Its suggested mappings point at the shared vocabulary, and every theme fills that.
- Authors have every option; modules have one. Authors may type a value, reference any token, or declare primitives; values are checked against the token's type, and contrast rules (§5) will also apply (v0 checks types only). Module CSS only ever uses semantic tokens and its own component tokens (§8).
- Every variation is a context, not a CSS function. Dark mode, high contrast or a page region need no special syntax such as
light-dark()(which only works for colors, and only for two values). Adding one adds a scoped block. - Contexts are a fixed, declared list. Server-side: theme (v0), date window (v3), tied to Drupal's caching. Page-side: mode and contrast (v0), page region (v2), delivered as scoped CSS. No ad-hoc conditions such as user role (Q14).
- Where the registry stores its data can change. v0 uses files; an editing UI and sources outside Drupal come later, without touching any module.
5. Registry and authoring experience¶
The registry is the one place where a site's design is decided. This section describes what working in it should feel like. Most of it arrives in v3; the earlier versions keep the data shaped so the editing UI can come later without any module changing.
Who works in it¶
| Who | Typical work | How |
|---|---|---|
| Site builder or designer | Brand palette, semantic values, a seasonal look | Editing UI on the site (v3) |
| Developer | Base values for a theme, reviewed in git | Files, deployed as config |
| Theme adapter | A sensible starting point for its theme | Pre-fill shipped with the adapter |
All three write the same kind of data, and every value records who set it.
What an author sees¶
One screen, one context at a time:
Context: Mercury › dark mode Draft · 3 changes
────────────────────────────────────────────────────────────────────────────
Components › Media Library
media-card.border {brand.red.300} set here [reset]
media-card.bg {color.bg.surface} suggested mapping [override]
Shared vocabulary › Color
color.border.primary {brand.navy.200} set here [reset]
color.bg.surface #1b2530 inherited · Mercury [override]
Palette
brand.red.300 #ff6b6b set here [edit]
- Pick a context. The theme first, then optionally a page-side context such as dark mode or high contrast. The breadcrumb shows what the context inherits from.
- Browse tokens in three groups. Palette holds the author's primitives. Shared vocabulary holds the semantic tokens, grouped by kind (color, space, type). Components lists every declared component with its tokens and their suggested mappings. That list exists because components declare what they have (§4, step 1).
- See where each value comes from: contract default, adapter pre-fill, suggested mapping, inherited from a broader context, or set here. Anything can be overridden; anything set here can be reset.
- Edit by type. A color picker for colors, a number with units for dimensions. The author either types a value or picks a reference from a list of tokens of the same type.
- See the effect before saving. A live preview shows the affected components in the chosen context, with switches for mode and contrast. A "used by" list shows the reach of a change: editing
color.border.primarymight affect 14 components in 6 modules.
Guard rails while editing¶
- Type checks. A value must match the token's type.
- Rules run live.
mustrules block publishing (text contrast below 4.5:1 against its background, a focus ring that disappears);shouldrules warn. Every message names its rule ID (§8). - No dangling references. A reference must resolve in every context that uses it, and a primitive that is still referenced can't be deleted.
Drafts, snapshots and rollback¶
- Edits collect in a draft per theme. Nothing reaches visitors until it is published.
- Publishing creates a snapshot. The publish screen shows a diff: what changed, in which contexts, and which components are affected.
- Scheduling. A snapshot can get a date window, such as the Christmas look, with a priority when windows overlap (Q14).
- Rollback republishes an earlier snapshot.
- Preview links let colleagues review a draft or a scheduled snapshot before it goes live.
Moving designs between tools and sites¶
- DTCG import and export of the palette and every context's value set, so designers can round-trip with Figma variables, Tokens Studio or Style Dictionary.
- Deployment. Base values travel as config (recipes, config sync). How that coexists with values edited on the live site is Q16.
What arrives when¶
| When | Authoring |
|---|---|
| v0 | Files: adapter pre-fills and author values, one YAML file per theme. A plain YAML editor per theme (Appearance » Design system) Check validates a change and previews every resolved token, with the source of its base value; Save validates and writes the same file. A Drush command shows the resolved value of any token in any context, and where it came from. |
| v1–v2 | A read-only registry browser in the admin UI: context picker, token list with sources, "used by", live preview. Useful for debugging long before editing exists. |
| v3 | The editing UI: drafts, live rule checks, publish, schedule, rollback, DTCG import and export. |
6. North star¶
The bigger picture states this for readers outside the project.
- Drupal itself (core + contrib + themes) shares a design-system contract.
- A contrib module (e.g. a Media Library widget) builds its UI against the contract and looks and behaves consistently under whatever theme is active.
- Its UI can be Twig, Svelte, React, vanilla JS, … (island architecture), so the contract is framework-agnostic.
- Design-system rules are precise enough that AI-assisted generation follows them and the output can be verified.
- The design system also covers authoring: content structure and editorial rules, not only UI.
The inversion¶
Declared in code (design_system_api) |
Filled by authors (registry) | Used at render (components) |
|---|---|---|
| Semantic token declarations (name, type, meaning) and default values | Semantic values per context (theme, mode, contrast, …) | Token names via var(--ds-…) |
| Component metadata exposed by core and contrib: component tokens with a suggested mapping to semantic ones, variants | Overrides per context, only where needed: another semantic token or a literal value | Their own tokens, resolved |
| Pattern specs (anatomy, states, behaviour, a11y) | Pattern realizations come from themes (SDC, web component, native HTML) | Patterns by ID |
| Rules (must / should / may) | May tighten rules | Must comply |
The ecosystem at a glance¶
§4 drawn out, with Media Library as the worked example. A component has two halves: its metadata (what it declares) feeds up into design_system_api at build time, and its implementation (Twig and CSS, an SDC, or a JS island) consumes resolved values at render time.
The module ships only two things: the mappings in its metadata and the var() names in its CSS. On Mercury the author's #c8102e wins. Under Default Admin, or any theme nobody filled, the border follows color.border.primary for that context, with no authoring. The module's code is the same in every case.
7. Domain model¶
Marked by when each entity is needed. "Later" items stay in the model as direction but are not built early.
| Entity | Description | Needed |
|---|---|---|
| DesignSystem | Root: identity, version, purpose, extends (layering) |
v0 |
| TokenDeclaration | Semantic name, type, meaning, default value (DTCG) | v0 |
| PrimitiveToken | An author's own raw value (a brand palette), declared in the registry, or brought by a theme adapter; referenced from context values, never by modules | v0 |
| ComponentDeclaration | What a core or contrib component exposes: its component tokens, each with a suggested mapping to a semantic token; later its variants and the patterns it realizes | v0 (tokens only, Media Library pilot); v1–v2 (variants, realized patterns) |
| Snapshot | A published, immutable version of the value sets for one server-side context (e.g. theme = Mercury): one set for the base context and one per page-side context (dark, high contrast), all the same shape. Optional schedule (active from / until) | v0 (one per theme), v3 (alternative and scheduled snapshots) |
| TokenValue | One value for one token in one context: a reference to another token (primitive, semantic) or a literal. Empty means inherit from the broader context | v0 |
| Rule | Atomic, addressable, graded guideline (see §8) | v0 (token rules only) |
| Context | A declared dimension. Server-side ones (theme, date window) choose which values are delivered and map to a Drupal cache context or max-age. Page-side ones (mode, contrast, page region) are delivered as selector-scoped CSS blocks | v0 (theme, mode, contrast), v2 (page region), v3 (date window) |
| Pattern | Abstract spec: purpose, anatomy, variants, states, behaviour, a11y contract | v1 |
| ContentPattern | Content structure and editorial rules (see Q1) | v2 |
| Realization | A theme or library realizes a Pattern: mapping and status | v2 |
| Implementation | An external component library targeting the DS | Later |
| Reference | Typed outbound link (Figma, Storybook, source) | Later (a links field until then) |
Principles that hold regardless of phase:
- Dependencies point one way. Realizations point to the DS; the DS never references implementation internals.
- Two lifecycles. Pattern maturity in the DS (draft → stable → deprecated) is separate from realization status (missing / partial / conformant / outdated).
- Provenance is one field. The resolved output records which snapshot each value came from (
from: <snapshot>). Who changed it and when is already tracked by git and config management.
8. Rules as data¶
Rules are atomic, addressable and graded (RFC 2119) so one source serves AI generation, verification and human docs.
id: drupal-ds:color/tokens-only
applies_to: tokens:color
level: must # must | should | may
statement: "Module CSS uses contract color tokens, never literal color values."
rationale: "Literal values break under other themes, dark mode and forced-colors mode."
examples:
do: ["color: var(--ds-color-fg-muted);"]
dont: ["color: #6b7280;"]
enforced_by:
- { tool: stylelint, rule: drupal-ds/color-tokens-only }
- This is the target shape. v0's
design_system_api.rules.ymlships a simpler one (summary,check, IDs without thedrupal-ds:prefix). - Metadata shape borrows from axe-core (id, tags, impact, help URL).
- The one layering rule that matters from day one: a
mustrule can never be relaxed by a lower layer. Anything more elaborate (relaxingshouldwith a recorded reason, etc.) waits until real usage needs it.
9. Layering and context¶
drupal-ds (contract: names, defaults, pattern specs, must-rules)
├── admin theme (Default Admin; Claro, Gin on sites that keep them) maps its tokens to the contract
│ └── project admin layer (rare)
└── frontend theme (Mercury clone, Olivero from contrib, custom; contract defaults where it implements nothing)
└── project frontend layer: token overrides, extra rules
- Each context is filled in the registry as one or more snapshots (§4). The registry picks the active snapshot for the context and merges contract defaults → adapter pre-fill → authored values. v0 uses its own ordered layer list and records where each value came from; the DTCG Resolver
resolutionOrderformat (withfromper value) is the target. - Contrib never asks for a specific theme; it asks for the effective design system here.
- Context comes from theme negotiation (admin vs frontend route) and, from v2, from regions within one page (Navigation bar on frontend pages, Canvas editing UI, a Media Library dialog opened from the frontend).
- DOM scoping uses
@layerand attribute selectors in v0; core's selector-scoped token overrides and@scopeare the target. - Tokens alone don't keep a theme's rules out of a region of another system; see Q17. A few values (stacking order, space taken by fixed UI, root font size) are shared by every system on a page; see Q18.
10. Two consumption tiers¶
Borrowed from Atlassian Forge.
- Strict tier: the module uses theme-provided realizations (SDC, native HTML, custom elements). Consistency is automatic.
- Free tier: the module uses any framework with contract tokens, and must pass the linter and the pattern's conformance tests.
11. Phasing¶
| Step | Scope | Proves |
|---|---|---|
| v0 | Small semantic token vocabulary with admin and frontend defaults, declared via core tokens.yml · component declarations with suggested mappings (Media Library) · file-based registry, context = theme, one snapshot per theme · CSS variables scoped to the context root · mapping for Default Admin (Claro and Gin next, for sites that keep them) · one contrib module (Media Library or smaller) styled only via contract tokens, working on admin routes and on a frontend page, in light, dark, RTL and forced-colors · stylelint rule · problem statement and success criteria (§3) |
One set of names that is right in the admin theme and acceptable on the frontend |
| v1 | One behaviour-heavy pattern, Dialog: spec (APG reference + keyboard table), realization in admin themes, conformance test · read-only registry browser (§5) | The pattern contract works end to end |
| v2 | More patterns; realizations as SDC / custom elements; DOM-region context (Navigation, Canvas); first frontend theme adapters; ContentPattern draft | Admin and frontend coexist on one page; authoring rules start |
| v3 | Authoring UI over the registry: project snapshots via UI, alternative and scheduled snapshots (e.g. a seasonal frontend pair); registry sources outside Drupal; MCP and DESIGN.md exports; external implementations | Full design-system experience |
Project structure on drupal.org¶
Two projects to start, about four over time. Placement rule:
- Adapters for core themes and core modules live in the main project as submodules — they follow core's release cycle.
- Adapters for contrib themes are separate projects — they follow that theme's releases, and the goal is for the theme to absorb the adapter and retire the project.
| When | Project | Contains |
|---|---|---|
| v0 | Design System API (design_system_api) |
Contract, engine, Default Admin adapter (Claro adapter for sites that keep contrib Claro, until it moves out under the placement rule), Media Library pilot |
| v0 | Design System API: Gin (design_system_api_gin) |
Gin adapter |
| v2 | Further contrib theme adapters as needed | Other admin or frontend themes |
| v3 | Authoring UI for the registry (snapshots, scheduling) | Submodule first; own project only if it grows |
The linter (stylelint config) is an npm package published from the main project's repository, not a separate Drupal project.
Design System API — design_system_api¶
Design System API gives Drupal a shared design contract that themes implement and modules build against. Modules use semantic design tokens and pattern specs instead of theme-specific CSS, so their UI looks and behaves consistently under any theme that implements the contract, and acceptably under themes that don't, thanks to built-in defaults.
Built on core's Design Tokens API (W3C DTCG format) and Single Directory Components. Framework-agnostic: works for Twig, vanilla JS and islands in any framework.
Includes: - the contract vocabulary of semantic tokens, with default values for admin and frontend contexts - structured design rules (must / should / may) with stable IDs, readable by people, linters and AI assistants - a way for core and contrib components to declare their design tokens, with suggested mappings so they work out of the box - a registry where site authors fill and override values per context (starting with the active theme), delivered as CSS custom properties - an adapter for Default Admin, Drupal's core admin theme (and contrib Claro 3, for sites that keep it)
Status: experimental. The vocabulary will change while it's being developed together with theme maintainers.
| Submodule | Purpose |
|---|---|
design_system_api_claro |
Maps contrib Claro 3's styles to the contract (Drupal 12). An exception to the placement rule: it moves to its own project or into Claro before an alpha (D19) |
design_system_api_default_admin |
Maps the --admin-* semantic tokens to the contract |
design_system_api_media_library |
Pilot: replaces the themes' Media Library CSS with contract-based CSS via hook_library_info_alter() (a module can't use libraries-override), without changing core or any theme |
Design System API: Gin — design_system_api_gin¶
Makes the Gin admin theme implement the Design System API contract. Any module built against the contract, such as the bundled Media Library pilot, picks up Gin's colors, spacing, typography, dark mode and accent color automatically. Nothing in Gin is modified.
The adapter maps the contract's semantic tokens to Gin's own CSS custom properties and loads the mapping only when Gin is the active theme.
This project is a bridge. The goal is for Gin to implement the contract directly, at which point this project will be retired.
How decorating works: an adapter declares contract token values as aliases of the theme's own variables (e.g. --ds-color-bg-surface: var(--gin-bg-layer)) and attaches the mapping only when that theme is active (page attachment or library alter). The theme itself is never patched. The core-theme submodules work the same way.
Getting started¶
- Create
design_system_api(sandbox or1.0.x-dev) with the contract YAML and admin and frontend defaults, the Default Admin adapter (easiest, since--admin-*exists), and the Media Library pilot. - Show the pilot on both sides: on admin routes under Default Admin, and in a frontend node form under a frontend theme that knows nothing about the contract. Both working = v0 success criteria demonstrable.
- Add the Claro adapter and create
design_system_api_ginfor sites that keep those themes. - Talk to the UI Suite initiative before tagging an alpha; they own core's Tokens and Style APIs, and the vocabulary should be agreed with them.
12. Open questions — options and leanings¶
Each question lists approaches with pros and cons, and a current leaning. Leanings are proposals for discussion, not decisions.
Q1. Does the design system cover content and authoring, or only UI?¶
The position (§1) says design systems are about authoring and the end goal, but tokens and UI patterns alone don't touch authoring.
| Option | Pros | Cons |
|---|---|---|
| A. UI only (tokens + UI patterns) | Smallest scope; matches what other design systems do | Contradicts our own position; leaves authors without guidance |
| B. Content rules attached to UI patterns (e.g. "card title max 60 characters") | Small step; reuses the Rule entity | Content rules belong to content, not to how it's displayed |
| C. ContentPattern as a first-class entity (e.g. "article = lede + body + related"), with rules, mapped onto Drupal's content model (field constraints, CKEditor styles, Canvas component choices) | Uses Drupal's real strength; makes the design system an authoring tool; AI generation of content can follow it too | Largest scope; overlaps with content modelling and recipes; needs new mapping work |
Leaning: C as the direction, B as the first step. The model reserves ContentPattern now (v2); v0 and v1 allow rules with applies_to: content:* without building the mapping.
Q2. What happens when a theme provides nothing?¶
Most existing themes will never implement the contract.
| Option | Pros | Cons |
|---|---|---|
A. No defaults; contrib must provide CSS fallbacks (var(--ds-x, #fff)) |
No central decision needed | Every module invents its own fallback values, which recreates the inconsistency |
| B. Contract ships default values (a neutral "base theme" of the contract) | Gradual adoption; contrib always looks acceptable; one place to tune | Someone has to own a neutral visual identity; defaults may look generic |
| C. Contract defaults derived from a reference theme (e.g. Default Admin for admin, Olivero for frontend) | Defaults are already designed and tested | Couples the contract to a specific theme's look; Olivero leaves core in Drupal 12, so there's no core frontend theme left to derive from |
Leaning: B. Admin defaults start from Default Admin's values (C) but are owned by the contract. Frontend defaults are the contract's own, deliberately plain and readable, since Drupal 12 core ships no dedicated frontend theme to borrow from. Defaults are per context (admin, frontend).
Q3. How do patterns relate to legacy theming (theme hooks, template overrides, Form API)?¶
Form elements and status messages are the largest consistency surface, and today they're themed through theme hooks and template overrides.
| Option | Pros | Cons |
|---|---|---|
A. Map patterns onto existing theme hooks (Pattern form-field ↔ form_element) |
No change for themes; immediate coverage of Form API | Inherits the override-anything model the concept wants to leave behind |
| B. Patterns only through SDC; render elements render SDC components | Clean, modern model; one way to do things | Requires core render elements to move to SDC; large core effort |
| C. Bridge: core render elements gradually delegate to contract patterns; theme-hook overrides of migrated elements get deprecated | Staged, matches §2; value appears per element | Two mechanisms coexist for a long time |
Leaning: C. This is where the modernization stance (§2) becomes concrete. Start with the elements Media Library needs.
Q4. Who owns the vocabulary, and how do we avoid a lowest common denominator?¶
The contract is only useful if themes agree on names, but frontend themes vary enormously.
| Option | Pros | Cons |
|---|---|---|
| A. Core committee defines everything up front | Coherent | Slow; risks designing in a vacuum |
B. Start from existing themes (Default Admin's --admin-* semantic tier, checked against Gin, Claro and Mercury) |
Grounded in reality; fast for admin | Reflects today's themes, including their accidents; risks an admin-shaped vocabulary that doesn't fit frontend themes |
| C. Small core + namespaced extensions (core owns ~40–60 semantic tokens; themes and design systems add namespaced ones) | Avoids lowest common denominator; contrib can rely on the core set | Contrib that uses extensions is tied to them |
Leaning: B to seed the admin vocabulary, C as the structure. Governance borrows GOV.UK's contribution criteria (useful, unique, usable, consistent) and lifecycle statuses. First conversation: the UI Suite initiative and the Default Admin maintainers.
Q5. Where does the contract live, and how is it versioned?¶
| Option | Pros | Cons |
|---|---|---|
| A. In core, versioned with core | One version to reason about; trusted | Changes wait for core releases |
| B. Separate package with its own SemVer, required via Composer | Can evolve faster; usable outside Drupal | Two version axes; conflicts if modules require different majors |
| C. Contrib first, core later (the usual Drupal path) | Fast iteration while the vocabulary settles | Adoption by core UI only happens after the move |
Leaning: C, with the contract data (DTCG files, rule YAML) kept in a framework-neutral format from day one so it can also be published as a standalone package. Deprecations surface through Drupal's existing deprecation mechanism. Breaking contract changes only in Drupal majors once in core.
Q6. Modes: dark, high contrast, forced colors, reduced motion, brands¶
| Option | Pros | Cons |
|---|---|---|
A. Modes resolved on the server (DTCG Resolver modifiers: mode: dark, contrast: more) |
Standard mechanism; one set of values per page | Server must know the user's preference (cookie); every mode multiplies cached page variants; can't follow an OS switch live |
B. CSS functions (light-dark() and the like) |
Compact output | light-dark() only takes colors and only two values; every new kind of variation needs a new function or workaround |
C. Modes as page-side contexts: every variant delivered in its own scoped block, selected by media query (prefers-color-scheme, prefers-contrast) or attribute (data-ds-mode) |
One mechanism for every mode and any token type; follows the OS setting live; works per element (a dark panel inside a light page); one cached page | The page carries every variant (only the values that differ); combinations (dark + high contrast) follow cascade order only on the same element; nested or OS-level combinations need a combined value set |
Leaning: C. Forced colors and reduced motion are must-level contract rules, not optional theme features. A combined context (mode = dark, contrast = more) is just one more value set of the same shape, authored when the cascade order isn't enough. Brands are a frontend concern and wait until v2; seasonal or campaign looks are snapshots, not modes.
Q7. Composition: can patterns contain patterns, and can a design system extend another?¶
| Option | Pros | Cons |
|---|---|---|
| A. Flat (no composition) | Simple | Unrealistic: a dialog contains buttons |
| B. Patterns reference patterns by ID in their anatomy; design systems extend via the layer stack | Matches SDC slots and the DTCG $extends idea |
Need rules for how a child pattern's rules apply inside a parent |
| C. Full inheritance between patterns (a "confirm dialog" extends "dialog") | Expressive | Complex; inheritance hierarchies are hard to change |
Leaning: B. Design-system extension (corporate → product) is just more layers in the stack. Pattern inheritance (C) waits for a real need.
Q8. Who customizes the frontend layer, and how?¶
| Option | Pros | Cons |
|---|---|---|
| A. Developers via files only | Version-controlled; reviewable; no security surface | Site builders can't adjust; contradicts the authoring focus |
B. Site builders via UI (stored as config through core's DesignToken entity) |
Matches WordPress Global Styles and UI Skins; good authoring experience | Needs value validation (CSS injection), permissions, and config-sync awareness |
| C. Both, with the UI limited to tokens marked overridable | Developers control what can be changed; site builders get safe knobs | Two edit paths to keep consistent |
Leaning: C, from v3. Values are validated by DTCG type before output, editing needs a dedicated permission, and UI changes land in config so they can be exported. A UI edit publishes a new snapshot rather than changing live values. Resolved CSS is cached by context and active snapshots, and aggregated like any other library.
Q9. First proving ground: admin or frontend?¶
Drupal 12 changes this question. With one core admin theme, an admin-only proof barely needs a contract: a module could target --admin-* directly and look right. The pain that remains is the same UI breaking when it renders on the frontend (§3).
| Option | Pros | Cons |
|---|---|---|
| A. Admin only | One core admin theme, --admin-* tokens already exist, Media Library lives here |
Proves little a module couldn't do with --admin-*; doesn't touch the frontend breakage |
| B. Frontend only | The real test of "any theme" | Unbounded theme variety; slower to show value; admin consistency unproven |
| C. One module, both contexts (admin values from Default Admin, frontend values from contract defaults; no frontend theme adapters yet) | Proves the actual value: one set of names, values per context; still bounded | Needs credible frontend defaults from day one |
Leaning: C for v0. Frontend theme adapters and DOM-region context (the Navigation bar on frontend pages) follow in v2.
Q10. Who reads the rules?¶
| Option | Pros | Cons |
|---|---|---|
| A. Developers and designers | Simple; matches most design systems | Misses authors |
| B. Also content authors in the CMS (rules shown in the editing UI, e.g. as field help or Canvas hints) | Makes the authoring focus real | Needs UI integration and plain-language rule text |
| C. Also AI assistants (rules as structured context) | Same data, no extra authoring | Requires rules to be precise and atomic, which is already the plan |
Leaning: all three, served from the same Rule data. Rules carry a plain-language statement for authors and structured fields for tools. B depends on Q1 and arrives with ContentPattern in v2.
Q11. How are patterns realized: Twig/SDC, web components, or native HTML?¶
Drupal values progressive enhancement and server-side rendering.
| Option | Pros | Cons |
|---|---|---|
| A. SDC (Twig) only | Server-rendered; works without JS; existing tooling | JS islands in other frameworks can't use them directly |
| B. Web components | Usable from Twig, Svelte, React and vanilla JS alike | Need JS; Shadow DOM complicates SSR unless Declarative Shadow DOM is used |
C. Native HTML first (dialog, popover, commandfor), SDC for markup, light-DOM custom elements only for enhancement |
Works without JS where the platform allows; framework-neutral by definition | Not every pattern has a native equivalent yet |
Leaning: C. A pattern spec names the preferred native element where one exists. Custom elements use light DOM (no Shadow DOM) by default so server-rendered markup stays styleable and works before JS loads.
Q12. RTL, breakpoints, and tokens in JavaScript¶
| Topic | Approach | Trade-off |
|---|---|---|
| RTL | Must-rule: module CSS uses logical properties only; spacing tokens carry no direction | Some legacy CSS needs rewriting |
| Breakpoints | Prefer container queries in patterns; keep viewport breakpoints in core's breakpoints.yml and expose them in the resolved DS |
CSS custom properties can't be used in media queries, so breakpoints stay a separate mechanism |
| Tokens in JS | Islands read computed CSS custom properties; the resolved DS is also available as JSON for tools and SSR | Reading computed styles has a small runtime cost; a JSON copy can drift if not generated from the same source |
Leaning: as in the table. These become must-level rules in v0 so they're enforced from the start.
Q13. How are pattern conformance tests run, and by whom?¶
| Option | Pros | Cons |
|---|---|---|
| A. Each module runs its own tests | No central infrastructure | Inconsistent; easy to skip |
| B. Shared test suite in drupal.org GitLab CI templates, run against each theme and module that declares conformance | Consistent; visible badges per project | Needs infrastructure and maintenance |
| C. Linter in v0, behaviour tests (Playwright + axe) from v1 | Matches phasing; cheap start | Behaviour drift possible until v1 |
Leaning: C, moving to B once the first pattern (Dialog) exists.
Q14. How are snapshots selected when more than one could apply?¶
The Christmas pair overlaps the default pair; two campaigns could overlap each other.
| Option | Pros | Cons |
|---|---|---|
| A. Schedule only (the snapshot whose window contains today wins; default has no window) | Simple to explain | Overlapping windows are ambiguous |
| B. Schedule + explicit priority | Overlaps resolve deterministically | One more field to manage |
| C. Rules engine (by role, path, language, A/B bucket) | Very flexible | Turns the registry into a personalization engine; breaks page caching |
Leaning: B. Windowed snapshots beat the default; among overlapping windows the higher priority wins, and the registry refuses to publish two with equal priority in overlapping windows. Selection by anything other than context and date (C) is out of scope.
Q15. Where does a component declare what it has?¶
Core is already building both halves: {provider}.tokens.yml in DTCG format (#3531854, committed 2026-09-07) and SDC component tokens under thirdPartySettings.design_tokens.tokens in component.yml (#3624211, proposed 2026-09-18), where a component token's value aliases a global one.
| Option | Pros | Cons |
|---|---|---|
A. SDC component.yml, as #3624211 proposes |
Lives next to the component; core is heading there | Only covers SDC; its shape isn't DTCG yet (value + fallback) |
B. The module's own {module}.tokens.yml (core's file) |
Covers non-SDC UI and JS islands; already in core | Separate from the component markup |
| C. Both, one schema | Each kind of UI declares where it lives; same fields either way | Two places to look |
Leaning: C, using core's mechanisms, not our own file. Media Library starts with B (it isn't SDC). The fields are DTCG, added in stages:
- v0:
$valueas an alias, which is the suggested mapping ("{color.border.primary}");$type(inferred from the alias target if omitted, needed to validate an author's literal);$description. - Later, as needed:
$deprecated, and Drupal-specific fields such as examples under$extensions.org.drupal.design_system. - No
lockedflag. Constraints ("focus ring stays visible", "3:1 contrast") aremustrules the registry checks when an author publishes, so authors keep control within limits.
# media_library.tokens.yml
media-card:
border:
$type: color
$value: "{color.border.primary}" # the suggested mapping
$description: Card outline in the media grid.
What we need from core, to raise in the issues: a color type (not in the initial set); aliases that stay references until resolved per context (#3590100 leans toward resolving at discovery, which would freeze a component token to its target's default and ignore an author's change to color.border.primary); and DTCG field names in #3624211.
Q16. Is authored design config or content?¶
A developer's base values for a theme belong in git and deploy like config. A Christmas look is created by an editor on the live site, a week before Christmas. Drupal's config sync would overwrite the second with the first.
| Option | Pros | Cons |
|---|---|---|
A. Config only (all value sets and snapshots as config entities, like core's DesignToken) |
Versioned in git; deploys with recipes; matches core | Editing on the live site fights config sync; scheduling and drafts need workarounds |
| B. Content only (revisionable entities with drafts and publishing, like any content with moderation) | Live editing, revisions, review workflows and scheduling come for free | Base values no longer travel with the code; harder to review in git |
| C. Split: the base snapshot per theme as config (from adapters, recipes and developers); authored overrides and scheduled snapshots as revisionable entities on the live site, exportable to DTCG or config | Each kind of change lives where it is naturally made | Two storage paths; the registry must show clearly which layer a value sits in |
Leaning: C. The registry layers them anyway (contract defaults → adapter pre-fill → base config → authored values), so storage follows the layers.
Q17. How is a module's UI kept apart from the theme around it?¶
Tokens decide which values a module's UI uses, not which theme rules reach it. The Media Library pilot only wins where it sets a property itself: a theme's .region-content div, a .form-item rule or an !important still gets through, and inherited fonts and line heights flow in from body. Raised by seanB in the Media Library review; open in core since 2014 (#2195695, see Builds on). It also raises a case the contract has no answer for yet: more than one design system on a page. The Navigation bar or the Media Library on a frontend page, editor chrome around a page preview in Canvas, and deeper nestings (a preview inside the Media Library, a widget inside that preview).
| Option | Pros | Cons |
|---|---|---|
| A. Tokens only (today) | Nothing new | Every theme rule the module doesn't override leaks in |
| B. iframe | Complete isolation | Ruled out for Media Library by design; links, forms, focus and sizing across the frame |
C. Reset fence at the module's root (all: revert on its descendants; core's off-canvas reset, Navigation's data-drupal-admin-styles reset, seanB's admin-section class) |
Works today; two precedents in core | Weak specificity loses to theme rules with a class and more; core's ID strength also beats the module's own CSS unless every rule is prefixed; !important gets through; an outer fence wipes a nested region of the other system, so it can't nest; two ID-strength fences on one page (ours and Navigation's) override each other |
| D. Shadow DOM | Real encapsulation | ARIA ID references don't cross the boundary; forms, Drupal behaviors and theming hooks would all change |
E. Scoped systems: a region declares its system (data-ds-system="admin") or fences itself while keeping the surrounding one (a boundary). Drupal puts that system's tokens and a layered base at the region root, and wraps each system's CSS in @scope (that system) to (other systems, boundaries) when it aggregates libraries |
Stops theme rules in both directions at any depth; !important included; module CSS unchanged; the boundary is generated, not hand-written per site |
Rules that start at an ancestor outside the region (.contextual-region .contextual a, .main-content div) stop matching; module UI loses the theme's form controls, buttons and dialog layering and must bring its own; libraries that style both sides (Layout Builder) need splitting; needs a font.family and line-height base in the contract; dialogs appended to <body> need their own marker; only with CSS aggregation; themes reach module UI only through tokens, a policy change |
Leaning: E, with C as the interim for the pilot. Measured on real Drupal 12 pages (the Media Library widget and dialog in a frontend node form, Navigation on a frontend page, Layout Builder, under Olivero and a deliberately hostile theme), with Drupal wrapping each system's CSS while it aggregates: E took the hostile theme's pages from 540 leaking elements to 1, and left 22–41 elsewhere, all from rules that start outside the region. C left 288–374 per page, and at ID strength it broke Navigation's own styling. Isolation is only half of it: once the theme is out, module UI shows browser-default form controls and loses the theme's dialog layering, so E needs contract patterns for forms, buttons and dialogs, not only tokens. This is the case for regions in §9, and it changes what a design system is: a set of values and the rules a region accepts. A visual check on the same pages confirms it: under E the Media Library dialog ends up under its own overlay and Layout Builder collapses, so E only passes once those patterns exist. Some values can't be scoped at all, because every system on the page has to agree on them (see Q18). To discuss with seanB before anything goes into the module.
Q18. Which tokens are shared by every design system on a page?¶
Q17 scopes each design system's tokens and CSS to its own regions. That works for appearance (colour, spacing, radius, fonts): a dark dialog on a light page is two systems with different values. Some values describe how regions relate to each other on one screen, and every system on the page has to agree on them:
- Stacking order. The theme's sticky header, Navigation, a dialog and its overlay compete on one page-wide scale.
- Space taken by fixed UI. Layouts and dialogs avoid Navigation's sidebar and top bar. Core already shares this page-wide:
displace.jssets--drupal-displace-offset-*on:root. - Root font size. Every
remin every system resolves against<html>;@scopecan't fence it.
Measured under Q17's option E: the Media Library dialog ends up under its own overlay. Core sets no stacking order for dialogs; each theme does (Olivero hard-codes .ui-widget-overlay 1259 and .ui-dialog 1260; Default Admin and Claro each define their own --jui-dialog-z-index). Once the dialog is marked as admin UI, Olivero's rule stops matching, and Default Admin's dialog CSS isn't on a frontend page. The dialog falls back to jQuery UI's .ui-front (100), while the overlay keeps the 1259 that jQuery UI set when the dialog opened.
| Option | Pros | Cons |
|---|---|---|
| A. Each design system sets them (today) | Nothing new | The result depends on which rule happens to match; scoping cuts a system's rules off from UI that relied on them (the dialog above) |
| B. Core sets fixed values | One source, no negotiation | Needs a core change; themes can't fit their own chrome into the scale |
C. Page tokens in the contract: each token is either region (the default) or page. The contract names the page tokens, fixes their order (overlay below dialog, dialog below toast) and gives defaults. The system that renders the page may set the values; systems nested in it only read them. Each pattern applies them in its own CSS (the dialog sets its own z-index from --ds-layer-dialog) |
Works with E; adapters map existing values (Olivero's 1259/1260, --jui-dialog-z-index, the displace offsets); the linter can reject a page token declared under a region selector |
A new kind of token; the order has to be specified, not just the names; the root font size may stay outside the contract, which means admin patterns avoid rem |
Leaning: C. A token alone isn't enough: the dialog didn't get a wrong value, it got none, because the rule that set it belonged to the theme. Page tokens only help together with patterns that apply them. First experiment, done: a layer scale (base, sticky, toolbar, overlay, dialog, toast) at :root, a system-neutral dialog rule that applies it, and Navigation's existing --drupal-admin-z-index mapped onto it. The dialog is usable again on every Media Library page, and nothing else moved; its fields and buttons are still browser defaults, and Layout Builder still collapses (patterns and a split library, not page tokens). Two details to carry forward: jQuery UI's generic .ui-front (100) loads after module CSS at the same specificity, so the dialog rule has to outrank it on purpose; and core's off-canvas dialog hard-codes 501 with an ID selector, a second value the scale should take over. Navigation already exposes its stacking order as --drupal-admin-z-index-* variables, which is a natural place for core to start.
13. Deferred on purpose¶
Kept as direction, intentionally not built early:
- An external Implementation catalogue and conformance aggregation for React/Vue libraries.
- Registry sources beyond files and config (git, design tools, remote).
- Capability negotiation manifests (
provides/requireswith optional patterns). A contract version requirement is enough until v2. - Elaborate layer governance (relaxing
shouldrules with recorded reasons, approval workflows). - MCP server. Rules stay structured and addressable so it can be generated later; a DESIGN.md export is a cheaper first experiment.
- Statecharts in pattern specs. APG reference + keyboard table first.
Edit note (2026-10-03): corrected statements that ran ahead of the v0 code, found in a pre-review check. §4 step 3 now shows the shipped sub-layer order (base, contrast, mode) and says nested contexts aren't handled yet. §4 notes that the media-card.border sample is an illustration. The ground rule on author values says contrast checks are still to come. The §5 v0 editor row is fixed. §7 scopes ComponentDeclaration to tokens in v0. §8 notes v0's simpler rules shape. §9 says the DTCG Resolver format and @scope are targets, not what v0 does. §11 describes the Claro adapter as contrib Claro 3 on Drupal 12. The Q6 option C cons now say combinations only cascade on one element.
Edit note (2026-10-04): corrected the Drupal 12 frontend theme statement. The Standard profile doesn't install "no frontend theme": it installs only Default Admin and uses it for the frontend too. Core has no dedicated frontend theme, which is what §3 and Q2 rely on. The ecosystem diagram now labels media_library.tokens.yml as illustrative (it isn't a core file) and nests the token key, since DTCG doesn't allow . in token names.
Edit note (2026-10-14): added Q17 (keeping a theme's rules out of a module's UI, and more than one design system on a page) after seanB's Media Library review, with a pointer from §9. Later the same day: Q17's options and leaning updated with measurements on real Drupal pages, replacing the simulated ones. Then Q17 noted the visual check, and Q18 added (tokens shared by every design system on a page: stacking order, space taken by fixed UI, root font size), with the result of its first experiment.