Skip to content

Review brief: CSS

Author: Shibin Das (d34dman)Last updated

In review

Reviewers: open, join #3627983 on drupal.org

Try it yourself

Everything in this brief runs on the demo site: Drupal 11.5.0-beta1 with the module, its adapters, the Media Library pilot and demo content. You need DDEV 1.25 or newer:

git clone https://github.com/d34dman/design_system_api_demo.git
cd design_system_api_demo
ddev start && ddev install-demo   # prints a one-time login link

Why this reaches your area

Everything the contract delivers to the page is CSS: custom properties named --ds-*, grouped in a cascade layer, with blocks for dark mode and more contrast. Module CSS built on it must use those properties instead of literal colors, and logical properties instead of physical ones. Two stylelint rules catch literal colors and physical properties, though not every way around them yet (see Known issues). These are choices about how Drupal CSS should be written, so they need people who shape Drupal's CSS.

Questions for you

  1. Naming. Tokens become --ds-<group>-<element>-<role>-<state>, for example --ds-color-border-primary or --ds-media-card-border-selected. Is --ds- a prefix core would accept for a shared vocabulary, and does the grammar fit core's CSS conventions?
  2. Context blocks instead of light-dark(). Dark mode and more contrast are separate blocks (@media (prefers-color-scheme: dark) plus [data-ds-mode="dark"]) holding only the values that differ. We chose this over light-dark() because it also works for non-color tokens and for a single element marked dark inside a light page. That last part needs the theme's values in the contract: tokens an adapter maps to a theme's own variables (Default Admin) get no dark block, so such an element doesn't flip. Do you agree with the trade-off? → §4 step 3, Q6
  3. Cascade layers. The values sit in @layer ds.context; module and theme CSS stay unlayered. Should Drupal define a layer order for core, themes and modules, and where would this layer go in it?
  4. Delivery. The resolved values are one generated file per theme (public://design-system/<theme>-<hash>.css), added to every page with a <link> rather than through a library, so it isn't aggregated. Would you rather see it as a library, inline, or in the aggregate?
  5. Linting. tooling/stylelint has two rules: drupal-ds/color-tokens-only and drupal-ds/logical-properties. Is that the right minimum for contrib CSS, and should rules like these eventually live in core's stylelint config? → §8, Q12

What we built

  • Generated CSS (src/ContextCss.php), in one cascade layer:
@layer ds.context {
  @layer base, contrast, mode, mode-contrast;   /* on one element: later wins, whatever the specificity */
  @layer base {
    :root { --ds-color-border-primary: #1d4ed8; /* every token */ }
    [data-ds-mode="light"] { color-scheme: light; /* base values back inside a dark page */ }
  }
  @layer contrast {
    @media (prefers-contrast: more) { :root { /* only the values that differ */ } }
    [data-ds-contrast="more"] { /* … */ }
  }
  @layer mode {
    @media (prefers-color-scheme: dark) {      /* only if the theme has dark mode */
      :root:not([data-ds-mode="light"]) { /* only the values that differ */ }
    }
    [data-ds-mode="dark"] { color-scheme: dark; /* the same values, on demand */ }
  }
  @layer mode-contrast {
    /* dark + more contrast: on one element, nested either way, or one from the OS */
    @media (prefers-color-scheme: dark) and (prefers-contrast: more) { :root:not([data-ds-mode="light"]) { /* … */ } }
    [data-ds-mode="dark"][data-ds-contrast="more"],
    [data-ds-mode="dark"] [data-ds-contrast="more"],
    [data-ds-contrast="more"] [data-ds-mode="dark"] { /* … */ }
  }
}

The layer order only decides between blocks that match the same element. A nested region declares its own values, and those beat inherited values whatever their layer, so dark plus more contrast has its own combined context in the last sub-layer, matched however the two meet (a token without a combined value takes its dark value). A theme can name its own dark selector in place of prefers-color-scheme (Default Admin: :root.dark-mode). The sample in concept §4 step 3 is older than this code: it has no sub-layers and no combined context, lists contrast after mode, and mentions @scope, which isn't generated. The block above is what ships.

  • The Media Library pilot's CSS (modules/design_system_api_media_library/css/media-library.css, 467 lines) replaces about 1,700 lines of theme-specific Media Library CSS in Default Admin and Claro. It contains no hex values and no theme names, uses logical properties only, and has a forced-colors block; transitions run only under prefers-reduced-motion: no-preference. FunctionalJavascript tests cover the selection limit, the focus ring on a selected card and the grid layouts. Dialog chrome mostly uses two-class selectors, so it beats jQuery UI's base theme and ties with a theme's own two-class dialog rules, which load later and win. A one-class theme rule loses, such as Default Admin's .ui-dialog. Two rules use three classes on purpose, so the Media Library's own button-pane layout wins over a theme's dialog layout.
  • The linter's rules match rules in design_system_api.rules.yml, where each rule has a level, a summary and the check that enforces it. Allowed: transparent, currentColor, CSS-wide keywords, literal colors as var() fallbacks, and system colors inside @media (forced-colors: active).

See it

ddev drush ds:css --theme=olivero      # the generated CSS
ddev ds-lint                          # the pilot passes; a seeded file fails

Olivero in RTL, pilot on

Olivero in Arabic (RTL), pilot on: the same stylesheet, through logical properties.

Known issues

Found in our own check of the code. Items marked Fixed name the commit that fixed them. We track the open ones ourselves; if one matters for your review, say so in this brief's issue.

  • Fixed (bb2a125, 77237c5): Nested contexts. A contrast region inside a dark page (or the reverse) gets the combined dark + more-contrast values, and FunctionalJavascript ContextCascadeTest checks the nested cases in a browser. A [data-ds-mode="light"] region on an OS-dark page with OS more contrast keeps the contrast values.
  • Fixed (bb2a125): Dark + more-contrast context. New combined context mode=dark contrast=more with defaults for the contrast tokens; Kernel ContrastPairsTest checks every text (4.5:1) and UI (3:1) pair in every context. Adapter themes still keep their own values in every context.
  • Fixed (bb2a125): color-scheme. Explicit [data-ds-mode="dark"] and [data-ds-mode="light"] now set it, so native controls and light-dark() values follow.
  • Fixed (14a82bd): drupal-ds/color-tokens-only gaps. It used to pass any var(), not only --ds-* (var(--gin-primary)); a made-up --ds-* name with a literal fallback; custom property declarations, including --ds-color-fg-default: #f00; filter: drop-shadow(… red); deprecated system colors such as WindowText; and system colors under @media not (forced-colors: active), and rejected relative color syntax built from tokens. Each of these is now checked; drupal-ds/custom-properties covers var() names, and size, font and motion values have rules of their own.
  • Fixed (1e4e30e): drupal-ds/logical-properties gaps. Physical shorthands whose inline sides differ (margin: 0 1rem 0 2rem, inset, border-width, border-radius) and physical values (background-position: left, linear-gradient(to right …), overflow-x, translateX()) are rejected. width and height are still rejected outright; a deliberate physical box takes a one-line exception with a reason.
  • Fixed (bbadea9, ed82558): Not in the module's CI. The pipeline runs the plugin's tests and lints all module CSS with it; a violation fails the pipeline.
  • Fixed (b8ffdc0): Pilot CSS. The focus ring now shows on selected cards (selected styles in :where()) and only for keyboard focus (:has(:focus-visible)); disabled items take no clicks past the limit (pointer-events: none); the card transition runs only under prefers-reduced-motion: no-preference (new must rule a11y/reduced-motion).

Read only these sections

What we heard

Nothing yet.


Edit note (2026-10-03): Corrected the pilot CSS line count (443), the dialog specificity description (two rules use three classes; one-class theme rules lose), the layer-order comment (it holds on one element, not across nesting), the Q2 claim for adapter-aliased tokens, and "two stylelint rules enforce that", and noted that the concept §4 step 3 sample is out of date; added Known issues. All found in a pre-review check of the code.

Edit note (2026-10-03, later): Updated for bb2a125 (combined dark + more-contrast layer, color-scheme, nesting tests), b8ffdc0 (pilot focus, disabled items, reduced motion, tests; 467 lines) and e37d733 (Default Admin's dark selector); marked the fixed Known issues. Open known issues are now tracked by the maintainers instead of as separate issues.

Edit note (2026-10-18): Marked fixed: nested contexts (77237c5), the drupal-ds rule gaps (14a82bd, 1e4e30e) and the rules in CI (bbadea9, ed82558); the demo site now runs Drupal 11.5.0-beta1 (the module supports ^11.5 || ^12).