# CSS Patterns, Two-Block `@scope`, Variants, Modes, Tokens

Deep dive on AdiaUI's CSS architecture. Read when authoring a new component stylesheet, adding a variant that feels layout-shaped, or editing the token block of an existing component.

## The two-block `@scope` structure

Every component CSS file has exactly this shape:

```css
@scope (component-ui) {
  :where(:scope) {
    /* ── Tokens (zero-specificity declarations) ── */
    --component-bg:     var(--a-bg);
    --component-fg:     var(--a-fg);
    --component-border: 1px solid var(--a-border-subtle);
    --component-radius: var(--a-radius);
    /* ... */
  }

  :scope {
    /* ── Base styles, consume component tokens only ── */
    box-sizing: border-box;
    display: inline-flex;
    align-items: center;
    padding: var(--a-space-2) var(--a-space-3);
    background: var(--component-bg);
    color: var(--component-fg);
    border: var(--component-border);
    border-radius: var(--component-radius);
  }

  /* ── Variants / states (token-only overrides) ── */
  :scope[variant="outlined"] {
    --component-bg: transparent;
    --component-border: 1px solid var(--a-border);
  }

  :scope[disabled] {
    --component-bg: var(--component-bg-disabled);
    --component-fg: var(--component-fg-disabled);
    pointer-events: none;
  }
}
```

**Why two blocks?**

- `:where(:scope)` has specificity `(0,0,0)`. Consumer overrides, theme providers, and nested surface rules all beat it cleanly.
- `:scope` has specificity `(0,1,0)`, enough to beat `:where()` inside the same file, but low enough to compose across components without `!important`.
- The separation enforces the rule visually: tokens in the first block, styles in the second. A reviewer can spot a violation at a glance.

**Common mistake:** collapsing both into one `:scope` block.

```css
/* WRONG, tokens and styles interleaved */
:scope {
  --component-bg: var(--a-bg);
  background: var(--component-bg);
  display: flex;
}
```

When a parent tries to override `--component-bg`, specificity beats them. The zero-specificity layer is the contract.

## Region elements never self-style (2026-09-01, ADR-0105)

Native `<header>`/`<section>`/`<footer>` and `<header-ui>`/`<section-ui>`/`<footer-ui>` (a CSS-only slot-routing anatomy stub, no JS, no events, no `.css` file of its own) are **region elements**, not components in their own right. They carry zero layout CSS. Every grid, gap, sticky rule, and slot-vocabulary variant a region renders is written in the **closest host pattern's** `@scope (host-name)` block, `page-ui`, `card-ui`, `drawer-ui`, `modal-ui`, `anchor-bar`, the shells, matching the region by tag + attribute selector (`:scope > :where(header, header-ui)`, `:has(> [slot="action"])`, etc.), never in a region-owned stylesheet.

**If you're authoring a new host pattern that accepts region children, you MUST ship its `@scope` treatment for every region shape it accepts before it ships.** A host that accepts `<header-ui>` without a matching grid/sticky/slot-vocabulary block is not a lesser-but-valid host, the region element structurally cannot supply the missing layout itself, so the host renders wrong (usually unstyled block flow, or, the observed failure mode, a stale layout that packs content into the wrong shape). gh#2760 is the live example: `page-ui`'s header-band `@scope` block didn't reproduce the retired `admin-page-header`'s action-cluster grid for a multi-item `[slot="action"]` containing a block-level `<tabs-ui>`, so tabs overlapped buttons instead of getting their own row, same markup, incomplete host coverage.

**Bare `<header>` vs `<header-ui>`**, both reach a host's tag-matched rules identically (`:where(header, header-ui, …)` gives them equal zero specificity). They diverge only on rules keyed off a named-slot attribute selector (`:has(> [slot="icon"])`, etc.), and there the divergence isn't mechanical (`slot=` is decorative everywhere, ADR-0033), it's documentation: `header-ui`'s own yaml is the only place the icon/heading/description/action vocabulary is written down and steered toward by `a2ui.rules`. Use bare `<header>`/`<section>`/`<footer>` only for unstructured default-slot content; use the `-ui` stub whenever content decomposes into the slot vocabulary, that's the only path an author or the A2UI generator discovers it exists.

A region element acquiring its own `.css` file (layout rules scoped to `header-ui` itself, not the host) is the anti-pattern this section exists to name, see ADR-0105 for the full rationale and the corollary above.

Source: [ADR-0105](../../../../../../docs/ops/adr/adr-0105-region-elements-never-self-style.md).

## Variants vs modes, the decision tree

The rule: **variants change tokens; modes change layout.**

```text
Does your [attribute=value] need to change any of:
  padding, display, position, width, height, margin,
  gap, flex, grid, overflow, border-radius, flex-direction?

                    ├── YES → it's a MODE
                    │         Add it to the Sanctioned Mode Attributes
                    │         table in .claude/docs/specs/component-token-contract.md
                    │         with a one-line justification.
                    │
                    └── NO  → it's a VARIANT
                              Body may contain only --component-*: var(...) lines.
```

**Approved mode attributes** (as of the most recent contract update):

- `code-ui[inline]`, inline vs block display
- `divider-ui[vertical]`, flex-direction, width ↔ height swap
- `tabs-ui[orientation="vertical"]`, flex-direction swap
- `input-ui / textarea-ui / select-ui / slider-ui[data-direction="row"]`, grid rewrite
- `toast-ui[position="..."]`, fixed positioning corner
- `nav-ui / pane-ui / cot-ui[collapsed]`, width/height collapse
- `list-ui[divider]`, gap:0 for visual seam
- `description-list-ui[layout="inline"]`, grid-template change
- `chart-ui[type="sparkline"|"segments"]`, strip vs full layout
- `chat-ui[data-role="user"|"assistant"]`, bubble alignment
- `timeline-ui[orientation="horizontal"]`, flex-direction swap
- `timeline-ui[mode="steps"]`, flex-direction + step-counter layout
- `button-ui[block]`, block-fill with `display: flex; width: 100%`
- `pagination-ui[variant="button"]`, square 1:1 page buttons

This list is the single source of truth. If your mode isn't here, add it. If adding would feel weird, that's a signal the "mode" is actually a **sibling component**, prefer `code-inline-ui` over `code-ui[inline]` unless the attribute genuinely toggles one surface between two states of the same thing.

**Orthogonal boolean combinations need every combination defined, not left as an undefined hybrid.** Two independently-toggleable boolean attributes on the same component (e.g. stat-ui's `band` and `bleed`) form an N² space; shipping three of the four combinations and leaving the fourth undefined means a consumer who reaches it gets whatever the cascade happens to produce, not a designed layout. ADR-0083 gave stat-ui's `band bleed` combination a real contract, the band bleeds inline-start/inline-end/block-end via the `--card-inset` negative-extent technique, with `[slot="change"]` overlaid as a z-ordered, `pointer-events: none` chip anchored block-start/inline-end. When you add a second orthogonal boolean to an existing single-boolean variant, audit all four quadrants before shipping, an unstyled hybrid is a defect, not a follow-up.

## Font-family floor, text-bearing primitives must anchor to a token

A primitive that renders text must NOT rely on `font: inherit` / `font-family: inherit` alone. Those carry **no default**, the primitive inherits whatever the host page sets, so a consumer page with a broken or serif `font-family` (a dead token, a missing `--a-font-family`, a serif host document) makes the primitive's labels render in UA serif while token-anchored siblings (`text-ui`) stay correct. A confusing same-page split, the exact bug behind an embedded-app `<segmented-ui>` serif regression (25 primitives shared the flaw).

**Rule:** anchor `font-family` to a token, the way `text-ui` does:

```css
@scope (foo-ui) {
  :where(:scope) {
    --foo-font-family-default: var(--a-font-family-ui);  /* UI-control font */
  }
  :scope {
    font: inherit;                                        /* keep, resets style/variant/leading */
    font-family: var(--foo-font-family, var(--foo-font-family-default));
  }
}
```

- `font: inherit` may stay (it still resets `font-style` / `font-variant` / `line-height`); the `font-family` longhand AFTER it is the floor.
- Floor to `--a-font-family-ui` for chrome/controls (or `--a-body-family` for prose-like text, as `text-ui` does). The `var(--foo-font-family, …)` first arg is the per-component override hook.
- For composite controls (`select` / `combobox` / `tags-input` / `table`), floor the **host `:scope`**, internal fields/options/cells inherit it.
- **Exception:** a contextual editor (`inline-edit`) or an optional centered label (`spinner`) SHOULD inherit to match surrounding content, do NOT floor those (they're allowlisted in the audit).

**Enforced by** `npm run audit:font-family-floor:strict` (`scripts/dev/audit-font-family-floor.mjs`): flags any component CSS with `font: inherit` / `font-family: inherit` and no `font-family: var(…)` floor, plus dead `var(--a-font)` usage.

## Token consumption, L3 over L2

The token stack has four layers:

- **L1 (primitives):** raw scale values, `--a-blue-500`, `--a-gray-100`.
- **L2 (family semantics):** role tokens per family, `--a-primary`, `--a-danger`, `--a-success`, `--a-info`, `--a-warning`.
- **L3 (state × role matrix):** `--a-<family>-{bg,fg,border}-{rest,hover,active,selected,disabled,invalid}`, every family has a full matrix.
- **L4 (component tokens):** `--component-*`, defined in `:where(:scope)`.

**Rule:** L4 aliases L3, not L2.

```css
/* RIGHT, component token aliases from L3 */
:where(:scope) {
  --button-bg:         var(--a-primary-bg);
  --button-bg-hover:   var(--a-primary-bg-hover);
  --button-fg:         var(--a-primary-fg);
  --button-fg-hover:   var(--a-primary-fg-hover);
}

:scope[variant="danger"] {
  --button-bg:         var(--a-danger-bg);
  --button-bg-hover:   var(--a-danger-bg-hover);
}

/* WRONG, variant body consumes L2 directly */
:scope[variant="danger"]:not([disabled]):hover {
  --button-fg: var(--a-danger);      /* ← L2 */
  --button-border: var(--a-danger);  /* ← L2 */
}
```

**Why:** L3 exists so state wiring lives in ONE place. When you bypass L3, the state doesn't cascade through theme × scheme × contrast × density overrides correctly. A user enabling high-contrast mode won't see the hover state you skipped.

This was a real bug in `button.css` caught in a final audit pass.

## Raw values, what's allowed

- **Colors:** zero raw values in component CSS files.
  - No `#hex`, `rgb()`, `rgba()`, `oklch()`, `hsl()`, named colors (`red`, `white`). Every color goes through a token.
  - Exception: `styles/colors/semantics.css` and `styles/tokens.css`, those ARE the raw values.

- **px values:**
  - ≤ 2px: allowed for `stroke-width`, `border-width`, hairline details. Comment not required.
  - ≥ 3px: forbidden in component base styles. Use `var(--a-space-*)`.
  - Exception: component-intrinsic constants (e.g. a port-dot diameter, an icon size that must match a specific SVG coordinate). Each such literal needs a one-line comment justifying why.

- **Typography, leading, tracking, weight (ADR-0052, ratified 2026-08-15, gh#1298):** zero bare literals in `packages/web-components/components/*/*.css`; every value goes through the one scale per property in `styles/type/scale.css`.
  - **Leading:** `--a-font-leading-*` is the single canonical scale, `none: 1` · `tight: 1.05` · `snug: 1.2` · `normal: 1.35` · `relaxed: 1.5` · `loose: 1.6` (`scale.css:67-72`). The legacy flat `--a-leading-*` family (`none/tight/snug/normal` = 1/1.2/**1.3**/**1.5**) is **retired**, removed from `scale.css` with no value-preserving aliases, because `snug` and `normal` named different values in each family and an alias would silently restyle. Two families under shared step names was the root cause of the literal epidemic (55/131 component files hardcoding `line-height`): no token choice was obviously correct, so authors reached for a number.
  - **Mapping for the literals you will find in older code:** `1.3` and `1.4` both → `--a-font-leading-normal` (1.35); at the 12–14 px sizes where they occur, ±0.05 is ≤ 0.7 px, below visual significance, and no new step is minted to ratify drift. Escape valve: genuinely multi-line body copy may map up to `--a-font-leading-relaxed` (1.5), case-by-case. Legacy `--a-leading-snug` (1.3) → `--a-font-leading-normal`; legacy `--a-leading-normal` (1.5) → `--a-font-leading-relaxed`, value-nearest, not name-preserving.
  - **Tracking:** `0.05em` → `--a-font-tracking-wide` (0.04em); everything else was already on-scale (`--a-font-tracking-tight/snug/normal/wide/wider`, `scale.css:75-79`). **Weight:** numeric weights tokenize to `--a-weight-*` (`scale.css:47-52`).
  - **Gate:** `npm run check:typography-tokens` (`scripts/audit/check-typography-tokens.mjs --strict`, in the `npm run check` aggregate) fails any bare numeric `line-height` (except `0`, the icon line-box-collapse idiom), any `em`-literal `letter-spacing`, any numeric `font-weight`, **and (gh#1496) any numeric literal used as a `var()` fallback** on one of those three properties or on a custom property whose own name names the role (`--alert-line-height`, `--foo-weight`), `line-height: var(--slider-hint-lh, 1.4)` trips it exactly like a bare literal, because the fallback still computes to the raw number whenever the custom property is unset. When you author a fallback, fall back to a token (`var(--x, var(--a-font-leading-normal))`), not a number, the gate now enforces that rather than merely recommending it. Comments are stripped before scanning.
  - Shipped as a breaking change on the `0.8.40` wave (`--a-leading-*` were published stylesheet symbols; the ADR text names the wave `0.9.0`, it shipped as `0.8.40`), with its migration-guide entry in the same cycle. Source: ADR-0052.

Example carve-out:

```css
:where(:scope) {
  /* Component-intrinsic visual constant; no --a-space-* equivalent */
  --noodles-port-size: 10px;
}
```

## Component token naming

`--<tag-stem>-<prop>`. The stem is the custom-element tag with `-ui` removed.

- `button-ui` → `--button-bg`, `--button-fg`, `--button-radius`.
- `chat-input-ui` → `--chat-input-bg`, `--chat-input-gap`.
- `timeline-item-ui` → `--timeline-item-dot-size`.

**Files with multiple `@scope` blocks:** each scope uses its own stem.

```css
/* layout.css contains three components */

@scope (col-ui) {
  :where(:scope) { --col-gap: var(--a-gap-md); }
  :scope { gap: var(--col-gap); }
}

@scope (row-ui) {
  :where(:scope) { --row-gap: var(--a-gap-md); }
  :scope { gap: var(--row-gap); }
}

@scope (stack-ui) {
  :where(:scope) { --stack-gap: var(--a-gap-md); }
  :scope { gap: var(--stack-gap); }
}
```

A cursory check might flag `--col-*` as "wrong" because the file is named `layout.css`. It's not wrong, the **scope tag** determines the stem, not the filename.

**Cross-component token-fallback aliasing.** A new component's tokens can alias a sibling component's existing token ladder as their `var()` fallback instead of minting an independent scale, when the two components share a visual role closely enough that re-theming one should re-theme the other in the same stroke. ADR-0083's stat-ui `[slot="change"]` chip mints six stat-scoped tokens (`--stat-change-*`) that fall back to chart.css's existing `--chart-chip-*` rungs:

```css
:where(:scope) {
  --stat-change-bg: var(--chart-chip-bg);
  --stat-change-fg: var(--chart-chip-fg);
}
```

A consumer who re-themes chart chips re-themes the stat delta chip too, with no separate override required. Reach for this only when the aliasing component is a genuine visual sibling of the aliased one (same role, same page context), otherwise it's spooky action at a distance when the aliased component's tokens change for an unrelated reason.

## Concentric-corner radius, deriving an item's radius from its container

When a rounded container pads a rounded-corner item flush against its own edge, a popover listbox around `[role="option"]` rows, a menu popover around `menu-item-ui`, a flat item-radius token only reads as concentric with the container's own corner at the one padding value it happened to be tuned against. Change the density scale or the radius scale independently (a consumer re-theme, a `--a-density` step) and the two arcs drift apart.

**The formula.** Solve for the ITEM's radius; keep the container's padding fixed:

```
Ir = max(0, Cr − Cp)
```

- `Cr`, the container's own `border-radius` (an existing fixed radius-scale token, untouched).
- `Cp`, the container's own padding/inset (an existing fixed spacing-scale token, untouched, this pattern never derives padding).
- `Ir`, the item's `border-radius`, the only new derived value.

**Why solve for `Ir`, not `Cp`.** The tempting reverse direction, hold the item's radius fixed and derive the container's padding from it, needs a second term, `min(Ir, Ih/2)` (`Ih` = item height), to keep the derived padding from going negative once the item is short enough that its own radius would pill-clamp. That's the browser's own `border-radius` clamp (CSS Backgrounds §5.5, a radius past half an element's shorter side reduces to a pill) reimplemented by hand inside the padding formula. Solving for `Ir` instead gets that clamp for free: `max(0px, calc(Cr - Cp))` alone is correct at every item height, because the browser applies its pill-clamp to the declared `Ir` automatically, no `min()` term needed anywhere in the CSS.

**Companion rule: the `min-height` floor is load-bearing, not optional.** The browser's pill-clamp cuts both ways: if the item is shorter than `2 × Ir`, the *effective* rendered radius clamps down even though the *declared* `Ir` is correct, the item silently stops reading as concentric, with no error and no visual-eval regression to catch it (a live-browser-only defect class, the same shape as this file's `display:contents` entry below). Pair every derived radius with a matching height floor:

```css
min-height: calc(2 * <item-radius-var>);
```

Ruled (Kim, 2026-08-24): enforce this floor rather than let it silently degrade, items get taller at large radius/density scales as the accepted tradeoff for exact concentricity, not a bug to route around.

**Worked example** (`select-ui`'s listbox, the reported surface, gh#1956):

```css
/* Container, Cr and Cp declared as LOCAL custom properties on the
   popover's own top-layer rule. A top-layer popover usually can't inherit
   the host element's @scope'd component tokens (a different, often
   detached DOM subtree once popover-open), declaring them locally here
   sidesteps that; a local declaration DOES inherit down to a genuine DOM
   child. */
select-ui [slot="listbox"] {
  --select-listbox-padding: var(--a-space-1);   /* Cp, fixed */
  --select-listbox-radius: var(--a-radius);     /* Cr, fixed */
  padding: var(--select-listbox-padding);
  border-radius: var(--select-listbox-radius);
}

/* Item, Ir derived; Cr/Cp inherited from the listbox above because
   [role="option"] is a genuine DOM child of it (an appended option row). */
select-ui [slot="listbox"] [role="option"] {
  --select-option-radius:
    max(0px, calc(var(--select-listbox-radius) - var(--select-listbox-padding)));
  border-radius: var(--select-option-radius);
  min-height: calc(2 * var(--select-option-radius));
}
```

(`packages/web-components/components/select/select.css:330-343` and `:378-386`.)

Express `Ir` as a live `calc()`/`max()` referencing the radius/space custom properties, never a baked pixel value, so it holds across every `--a-radius-k` / `--a-density` scale change.

**Shipped in** (gh#1956, PR #1959, the reference implementation for this pattern):

- `select.css:330-343,378-386`, `[slot="listbox"]` vs. `[role="option"]`.
- `combobox.css:209-221,254-264`, `[data-listbox]`.
- `tags-input.css:166-184,200-209`, `[data-suggestions]`.
- `menu.css:51-65,90-92`, `[data-menu-popover]` vs. `menu-item-ui`.
- `context-menu.css:26-36,56-58`, `[data-context-menu-surface]`, which shares `menu-item-ui` rows with `menu.css`.
- `nav-group.css:328-340,358-365`, `[slot="popover"]` vs. `[role="option"]`. Previously gave option rows the container's own flat radius, a design inconsistency next to every sibling popover's distinct smaller item radius. Deriving `Ir` resolves it with no special-casing: under this direction (unlike the reverse "solve for `Cp`" direction, which floors padding to a hard 0px here and was reverted) the row's radius just comes out smaller than the container's automatically.
- `calendar-picker.css:87` / `date-range-picker.css:267`, `calendar-grid-ui`'s day cells (gh#1966), a variant shape: the item primitive is a *shared substrate* consumed by two different popover containers with different `Cr`/`Cp`, not a single component owning both container and item, so `Ir` is derived once per consumer (not once in the shared `calendar-grid.css`) via a `--calendar-grid-day-radius` override cascaded down. `date-range-picker`'s popover padding is asymmetric (`--date-range-picker-px` ≠ `-py`), a single circular radius can't be exactly concentric on both axes, so it derives from `min(px, py)`, the tighter constraint; harmless on the corner (bottom-left, with a preset rail present) that isn't actually flush. Also the first shipped case to pair the derived radius with the `min-height: calc(2 * Ir)` companion floor (`calendar-grid.css:199`), per the ruling above.

Audited, not applicable: `command-ui`, `drilldown-ui`, item list sits inside a padded region behind a header, not flush against the container's own rounded corner, or the host carries no radius at all.

## Slot styling without `::slotted()`

AdiaUI is light-DOM. Slotted children are just children. Style them with attribute selectors:

```css
/* RIGHT */
:scope > [slot="icon"] {
  margin-inline-end: var(--component-gap);
}

:scope > header > [slot="heading"] {
  font-weight: var(--a-weight-semibold);
}

/* WRONG, ::slotted() is for shadow DOM */
::slotted([slot="icon"]) { ... }
```

## `@property` for animated custom properties

When a custom property needs to participate in CSS transitions or animations, declare it with `@property` so the browser can interpolate it:

```css
@property --_card-loading-angle {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}

@scope (card-ui) {
  /* ... */
  @keyframes card-loading-spin {
    to { --_card-loading-angle: 360deg; }
  }
}
```

The `--_` prefix marks it as internal to the component. `@property` is at the top of the file, outside the `@scope` block.

## Nested surface layering

When a component can contain itself (cards inside cards), step the background up one canvas level per nesting depth:

```css
:scope card-ui                        { --card-bg: var(--a-canvas-2); }
:scope card-ui card-ui                 { --card-bg: var(--a-canvas-3); }
:scope card-ui card-ui card-ui          { --card-bg: var(--a-canvas-4); }
```

The `:scope card-ui` specificity `(0,1,1)` beats the inner scope's `:where(:scope)` initializer `(0,0,0)`, so the nested card picks up the bumped canvas. No JavaScript required.

## `:has()`, constrain to direct children when gating on slots

When a component uses `:has([slot="X"])` to toggle a layout (e.g. activate a grid when a slotted child is present), the selector matches any descendant. That collides with composite children like `<avatar-ui>` which owns an internal `<icon-ui slot="icon">`, dropping an avatar into a header you intended to render without an icon column will falsely activate it.

**Rule:** gate layout on `:has(> [slot="X"])`, not `:has([slot="X"])`.

```css
/* WRONG, matches nested <icon-ui slot="icon"> inside an <avatar-ui> */
> header:has([slot="icon"]) { grid-template-columns: max-content 1fr; }

/* RIGHT, only activates for a direct-child [slot="icon"] */
> header:has(> [slot="icon"]) { grid-template-columns: max-content 1fr; }
```

This applies everywhere layout flips on slot presence:

```css
/* card-ui / drawer-ui header grid, all :has() clauses are direct-child */
> header:has(> [slot="icon"]):has(> :is([slot="action"], [slot="close"])) {
  grid-template-columns: max-content 1fr max-content;
}
> header:has(> [slot="icon"]):not(:has(> :is([slot="action"], [slot="close"]))) {
  grid-template-columns: max-content 1fr;
}
```

**Recognition:** if a container uses `:has(…)` to decide whether a layout column exists, and any sibling component may own an internal slotted descendant with the same name, the selector is wrong. Tighten to `:has(> …)`.

**Real fix:** card-ui and drawer-ui both had un-scoped `:has()` selectors; a `<avatar-ui slot="icon">` inside a header triggered the grid twice (once for the avatar, once for the icon-ui inside it), collapsing the content column to zero. Tightened in both files to `:has(> [slot="…"])`.

## Conditional-render parts defeat `:scope >` (the `display:contents` wrapper)

The template engine wraps every **conditional render branch**, a `${cond ? … : null}` (or `?` / `.map()`) expression, in a `<span style="display:contents">`. The span generates no box (invisible in layout) but is a real DOM node, so a conditionally-rendered part is a **grandchild** of `:scope`, not a direct child. A `:scope > [data-part="X"]` rule on that part silently matches nothing, no error, passes `components --verify`, and renders un-styled only in a live browser (happy-dom won't catch it).

**Rule:** use a **descendant** combinator for any conditionally-rendered part; keep `:scope >` only for parts that render unconditionally.

```css
/* WRONG, empty-state lives behind a `${isEmpty ? … : null}` branch,
   so it's wrapped in <span style="display:contents"> and never matched */
:scope > [data-part="empty"] { display: grid; place-items: center; }

/* RIGHT, descendant combinator survives the display:contents wrapper */
:scope [data-part="empty"] { display: grid; place-items: center; }

/* static parts (always rendered) stay direct children */
:scope > [data-part="header"] { … }
```

Recurring class (integrations-page empty-state, onboarding-checklist complete CTA, bug-51 / bug-53). Full failure entry + recognition heuristic: [anti-patterns.md](anti-patterns.md) AP-S6.

## Sticky header/footer inside a flex-column scroll container

When a card-like container (drawer-ui, pane-ui, full-height cards) needs a header + scrolling body + footer where header and footer pin to the top/ bottom during scroll, use `position: sticky` on the pinned children rather than splitting the container into separate scroll regions:

```css
> [slot="panel"] {
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  overflow-y: auto;              /* one scroll region */
  background: var(--component-bg);
}

[slot="panel"] > [slot="header"] {
  position: sticky;
  top: 0;
  background: var(--component-bg);  /* opaque so content scrolls under */
  z-index: 1;
  flex-shrink: 0;
}

[slot="panel"] > [slot="footer"] {
  position: sticky;
  bottom: 0;
  background: var(--component-bg);
  z-index: 1;
  flex-shrink: 0;
}

[slot="panel"] > [slot="body"]:last-of-type {
  flex: 1 0 auto;  /* last body takes slack so footer hugs bottom */
}
```

**Why this over a dedicated scroll wrapper:**

- No extra DOM element required.
- Multiple `[slot="body"]` siblings stack naturally, the author can put dividers between sections, include sub-headers, etc.
- Sticky works inside `display: flex` flex-columns in all modern browsers.

**Gotcha:** the sticky background must be opaque. If the header is transparent, content scrolls visibly underneath. Match the sticky element's `background` to the panel's `--*-bg` token.

## Cascade layers, precedence is declared once, never fought per-rule

`styles/index.css` declares the single ordered list, before any layered rule:

```css
@layer reset, tokens, elements, components, utilities, context, overrides;
```

The precedence law: a later layer beats an earlier layer **regardless of selector specificity**; within a layer, normal specificity applies. Component `@scope` rules live inside `@layer components`, `@scope` sets proximity *inside* the layer, `@layer` sets inter-group order; they compose, they are not substitutes.

Consequences for component authors:

- **Consumer overrides always win**, unlayered consumer CSS, or `@layer overrides`, beats everything by layer order. Never add `:where()` wraps, matched-specificity selectors, or `!important` to "let the consumer win"; the layer order already guarantees it.
- **Utilities beat component defaults by layer order.** The global attribute API (`api/*`) sits in `utilities`, so a component cannot out-specific it, a component repurposing a global attribute (e.g. `color=` on a filled control) must opt out explicitly (see the `[color]`/`[weight]` trap below).
- **`@layer` governs rule precedence only.** The `var()` token chains (token indirection), genuine element-default `:where()`s, and allowlisted a11y `!important`s serve other mechanisms and are not retired by layers.
- `npm run check:cascade-layers` gates the model: canonical order declared once, `!important` ≤ allowlist, no precedence-`:where()` in layered files.

Source: ADR-0038.

## Raw-CSS traps (mined from incident history)

One line each; every entry is a shipped bug.

- **`background: <color>` shorthand silently resets `background-clip`/`origin`/`position`/`size`.** For a state that changes only the color, use the `background-color` longhand, a base `background-clip: content-box` otherwise flips to `border-box` and the fill balloons (swiper-ui dots grew 6px → 16px pills).
- **Modern `translate` / `scale` / `rotate` are independent properties, NOT `transform` aliases.** Writing `style.translate` and reading `getComputedStyle().transform` (or vice-versa) silently no-ops, they are computed separately (spring-animate wrote translate, read the transform matrix, saw 0, never animated).
- **An OFFSETTING ancestor transform (`translate(-50%,-50%)`) breaks CSS anchor-positioning for top-layer popovers**; an identity `translateX(0)` does not.
- **A `@media` override with an equal-specificity selector must come AFTER its base rule in source order**, earlier placement is silently ignored.
- **One un-suffixed component token per property (`--card-bg`), read directly (`var(--card-bg)`).** The `-default` token-shadowing layer was reverted, do not declare `--card-bg-default` fallback chains, and do not rely on ancestor surfaces overriding component-named tokens (that inheritance no longer works).
- **Global `[color]`/`[weight]` presentational utilities override component color by `@layer` order**, a filled control repurposing `color=` must opt out; conversely the global `[weight]` attribute does NOT override component-scoped font-weight (use a variant).
- **A grid `auto`/`max-content` track collapses to ~1px around a flex wrapper whose child carries the explicit width**, the child's width doesn't propagate through the wrapper's intrinsic size; set the width on the wrapper (CSS, or JS-mirrored via ResizeObserver).
- **Square/1:1 cells inheriting `--a-radius-md` render as circles**, use `--a-radius-sm` for small square cells.

## Anti-patterns specific to CSS

- **BEM class syntax**, `.component--variant__element`. Not allowed. Slot attribute selectors replace this pattern.
- **`::part()` / `::slotted()`**, shadow DOM syntax. AdiaUI is light DOM.
- **Global selectors inside `@scope`**, `body { ... }`, `html { ... }`, `* { ... }`. The scope is the component; don't reach outside.
- **`!important`**, ever. If you need it, the specificity layering is wrong. Fix the layering.
- **Setting tokens at `:root`**, tokens scoped to a component belong in `:where(:scope)`. Only cross-component semantic tokens live in `:root` / `styles/colors/semantics.css`.
