Dual-Output CSS Architecture

Proof of concept. This page demonstrates the dual-architecture POC, which still uses its own --dual-* variables and Dual- classes. iris-core will adopt this mechanism additively under the iris namespace (--iris-*, Iris- SUIT classes, <iris-*> tags) — no separate dual namespace ships. See research/README.md.

Live components rendered from the prototype SCSS. Same source compiles to both shadow-DOM (web component) and light-DOM (CSS classes) via a $namespace parameter.

21.6 KB
Light-DOM bundle
21.1 KB
Shadow-DOM bundle
437
--dual-* variables
96
selectors (each path)
6
components
dual
$namespace: '' | 'dual-'
0 JS
runtime (CSS-only)
per-file
dist/{light,shadow}-dom/*.css

§1 — Architecture Diagram

src/components/button/ _variables.scss + _styles.scss Single source of truth $namespace: '' $namespace: 'dual-' Shadow-DOM Output .button { ... } .button--primary { ... } Generic names (encapsulated) .dual-* Light-DOM Output .Dual-Button { ... } .Dual-Button--primary { ... } Namespaced (global-safe) Component Variables (identical in both) --dual-button-fill, --dual-button-content, ... Consumer override: --dual-button-fill: hotpink;

Config → output map (src/config.yaml)

The build is config-driven: build-css.mjs and build-wc.mjs read one src/config.yaml and emit only the enabled outputs. Global knobs — namespace (Dual-), variablePrefix (dual), style, sourcemap, modes, tokenSubset — apply across them. Each layer compiles once to its own granular file under dist/css/; presets then compose consumer load tiers under dist/preset/ from those layers.

cssCompiled entryProduces (granular)Notes
variablesdist/css/variables.cssCSS custom properties, 5 mode blocks + per-breakpoint @media — the variable layer alone
basedist/css/base.cssreset + base element typography (opinionated globals)
utilitiesdist/css/utilities.cssu-* classes, alone
griddist/css/grid.cssflexbox grid + CSS Grid + bento + 10 layout primitives, alone
Each layer writes to its own file: — config-gated, so a disabled layer emits nothing and drops out of every preset. Sections that share a file: target are still bundled into that one file.
components (include: false/"all"/[names])dist/<file> or dist/<dir>/{name}.cssbundled to file:, or split per-component when dir: is set; false compiles none
webComponents (bundle/dir/include)dist/components.js (+ optional dist/<dir>/{name}.js)shadow-DOM components (build-wc.mjs); bundle:true → one file, dir: → per-component
presets tierProduces (barrel)Composes
variables-onlydist/preset/variables-only.cssthe CSS-variable layer alone (~35 KB)
foundationdist/preset/foundation.cssvariables + base + utilities + grid, no components (~52 KB)
fulldist/preset/full.csseverything enabled — the everything-bundle these demos load; rule-equivalent to the old bundled base.css
A preset names an ordered list of the layers above; a layer that is disabled (or fails) is skipped, so the same cssCompiled toggles gate both the granular files and the barrels.

Pipeline stages — where config applies (iris-core side vs consumer side)

The config surface exists at two points: the iris-core build (what the package emits into dist/) and the consumer (what they load / override). Same declarative model, two owners.

StageOwnerConfig surfaceControls
1 · Token compileiris-coretokens/*.config.yaml + compile-tokens-scss.mjswhich Figma sources / reference tiers become generated SCSS
2 · CSS buildiris-coresrc/config.yaml → cssCompiled.{…}namespace, variablePrefix, modes, tokenSubset; which layers emit as granular files
3 · Preset assemblyiris-coresrc/config.yaml → presets.{…}which layers compose each load-tier barrel (variables-only / foundation / full)
4 · WC buildiris-corecssCompiled.webComponentswhich components ship as JS; bundle vs per-component
5 · Load-tier selectionconsumerwhich dist/preset/*.css or dist/css/*.css entry they importbytes on the wire — variables-only vs foundation vs full, or hand-picked layers
6 · Theme / overrideconsumersemantic + component --dual-* variables (see Consumer Theming)brand recolor at the semantic layer, per-component exceptions at the component layer

Stages 1–4 are the iris-core-side build (a future consumer iris.config + CLI, spec'd in P5, will let a consumer drive stages 2–5 declaratively too). Stages 5–6 are what every consumer controls today with zero tooling.

§2 — Shadow-DOM Use (Web Component Path)

$namespace: ''

When Stencil builds dual web components, the SCSS compiles with $namespace: '', producing generic class names (.button, .button--primary) that are safely encapsulated inside the shadow DOM. The component variables (--dual-button-*) still inherit through the shadow boundary, allowing consumer overrides.

Live Web Components

These use <dual-*> custom elements with shadow DOM — same compiled CSS, generic class names encapsulated.

<dual-button>

Primary Secondary Ghost Danger
Small Medium Large
Disabled

<dual-card>

6 components validated with dual-output SCSS. Details

<dual-checkbox>

<dual-radio>

<dual-text-field>

<dual-dropdown>

Stencil Import (Shadow-DOM SCSS)

// packages/core/src/components/button/button.scss
// (Stencil web component — shadow-DOM path)

@use '@siemens-dual/dual-core/src/components/button/styles' as btn;
@use '@siemens-dual/dual-core/src/mixins/shadow-dom/component' as base;

@include base.dual-component();
@include btn.styles($namespace: '');  // → .button (shadow-safe)

:host { display: inline-flex; }
:host([disabled]) { pointer-events: none; }

// Same variables (--dual-button-*) inherit through shadow boundary
// so consumer overrides work identically.
// The compiled shadow-DOM output contains:
.button {
  background: var(--dual-button-fill);
  color: var(--dual-button-content);
  border: 1px solid var(--dual-button-border-color);
}
.button:hover {
  background: var(--dual-button-fill-hover);
  color: var(--dual-button-content-hover);
}
.button--primary {
  --dual-button-fill: var(--dual-action-fill-1);
  --dual-button-content: var(--dual-action-content-1);
}
// Component tokens are color-only; dimensions bind semantic vars directly.
// No .dual- prefix — encapsulated in shadow DOM.

SCSS Source Flow (Shadow-DOM highlighted)

TIER 1: Primitives base-variables/ (colors, spacing, fonts) TIER 2: Semantic semantic-variables/ (663 --dual-*) TIER 3: Component Vars components/button/_variables.scss --dual-button-* defaults from T2 Component Styles components/button/_styles.scss Shadow-DOM (Stencil build) .button { background: var(--dual-button-fill) } Light-DOM (Sass compile) .Dual-Button { background: var(--dual-button-fill) } Consumer Override dual-button { --dual-button-fill: hotpink; } Vars inherit through shadow boundary

Shadow-DOM Compile Stats

ComponentSizeVariablesSelectors
button4.8 KB11420
card1.9 KB458
checkbox2.8 KB5017
dropdown4.9 KB9615
radio2.6 KB4717
text-field4.2 KB8519
Total21.1 KB43796

§3 — Light-DOM Use (CSS Class Path)

$namespace: 'dual-'

When the SCSS compiles with $namespace: 'dual-', the output uses namespaced class names (.Dual-Button, .Dual-Card) that are safe to use in global light DOM without collision. No JavaScript runtime needed — pure CSS.

Live Components (Light-DOM CSS Classes)

These render using compiled .dual-* classes from _all-components.css — no web component runtime, just CSS.

.Dual-Button

.Dual-Card

Component Status

6 components validated with dual-output SCSS.

.Dual-Checkbox

.Iris-Radio

.Dual-TextField

We'll never share your email.
Must be at least 8 characters.

.Iris-Dropdown

Button
Card
Checkbox
Radio
Text Field
Dropdown

Variable Override Demo

Component variables can be overridden at any scope — component tokens are color-only, so these buttons have a scoped fill/content override:

Scope: --dual-button-fill: #6d28d9; --dual-button-content: #fff; --dual-button-fill-hover: #5b21b6;

Consumer Code Examples

// Your app's SCSS — import dual component styles
@use 'dual/src/components/button/styles' as btn;
@use 'dual/src/components/card/styles' as card;

// Emit with default namespace for light-DOM use
@include btn.styles();     // → .Dual-Button, .Dual-Button--primary, ...
@include card.styles();    // → .Dual-Card, .Dual-Card-title, ...

// Override component color vars globally or per-scope:
:root {
  --dual-button-fill: var(--my-brand-primary);
}
.hero {
  --dual-button-fill: var(--my-brand-primary);
  --dual-button-content: #ffffff;
}
<!-- Load token layer + component CSS -->
<link rel="stylesheet" href="dual/css/components/button.css">

<!-- Use classes directly — no web component needed -->
<button class="Dual-Button Dual-Button--primary">
  Save changes
</button>

<button class="Dual-Button Dual-Button--ghost Dual-Button--small">
  Cancel
</button>

<!-- Override via scope (component tokens are color-only) -->
<section style="--dual-button-fill: #6d28d9;">
  <button class="Dual-Button Dual-Button--primary">Recolored</button>
</section>

SCSS Source Flow (Light-DOM highlighted)

TIER 1: Primitives base-variables/ (colors, spacing, fonts) TIER 2: Semantic semantic-variables/ (663 --dual-*) TIER 3: Component Vars components/button/_variables.scss --dual-button-* defaults from T2 Component Styles components/button/_styles.scss Shadow-DOM (Stencil build) .button { background: var(--dual-button-fill) } Light-DOM (Sass compile) .Dual-Button { background: var(--dual-button-fill) } Consumer Override .hero { --dual-button-fill: hotpink; } Scoped CSS custom properties

Light-DOM Compile Stats

ComponentSizeVariablesSelectors
button4.9 KB11420
card1.9 KB458
checkbox2.9 KB5017
dropdown4.9 KB9615
radio2.6 KB4717
text-field4.3 KB8519
Total21.6 KB43796

New capabilities — parameterized type · size scales · subtree modes

Both output paths (shadow-DOM components and light-DOM CSS) inherit these from the single SCSS source compiled into the granular dist/css/*.css layers and the dist/preset/full.css barrel.

Parameterized typography

display · medium
slab · body · medium

type($family,$group,$size,$weight); sans+slab emit per group; block mixins blockquote()/intro()/topline().

Size scales

content-small (55ch)
fluid-small clamp()

--dual-size-content-* / --dual-size-fluid-* + dual-space($n).

Subtree modes

light dark sand

theme() emits both .dual-theme-* and [data-dual-theme] — nestable per subtree.

Config-gated dist — granular layers → preset barrels

src/config.yaml compiles each layer once to its own file under dist/css/, then a presets: block composes three consumer load tiers under dist/preset/. A layer disabled in cssCompiled emits nothing and drops out of every barrel — so one toggle changes what a tier contains. Tiers below carry their real built sizes (this page loads preset/full.css).

variables-only · 35.1 KB

[variables]

The --dual-* CSS-variable layer alone (all 5 modes + per-breakpoint @media). Zero utility/grid/component rules — theme a non-SUIT app off the tokens.

foundation · 51.7 KB

[variables, base, utilities, grid]

Variables + reset/element type + u-* utilities + grid & layout primitives. No components — bring your own, or the shadow-DOM WCs.

full · 51.8 KB

[variables, base, utilities, grid, components]

Everything enabled. Under the default config components are off, so full is rule-equivalent to foundation (and to the former bundled base.css). Enable cssCompiled.components and full gains ~18 KB of .Dual-Button/Card/… rules while foundation stays untouched — that is the gate.

Granular layerSizeContains
dist/css/variables.css35.1 KB--dual-* custom properties (5 modes + breakpoints)
dist/css/base.css3.3 KBreset + base element typography
dist/css/utilities.css6.0 KBu-* utility rules
dist/css/grid.css7.5 KBFlexGrid + CssGrid + 10 layout primitives

See also: SUIT CSS Conventions — utilities, grid, and SUIT naming applied to this architecture.