CSS Boundaries
CSS boundaries in the library
The one rule: a container places its children and publishes context; it never styles
their internals. This page is what that rule means when you write or change a stylesheet
here (BOU-2863 to BOU-2902, released in 2.10.0). The host-facing half, what a mounting app
may do to these styles, is the orchestrator's docs/gll/css-boundaries/ topic.
Ownership per tier
| Stylesheet dir | Owns | May set on a child |
|---|---|---|
panels/ |
the .panel section, its surface, .panel__header, the row/cell grid |
placement of cells only |
patterns/ |
the pattern's box and every element inside it | placement of the atoms it composes |
components/ |
the atom and all of its variants | nothing |
styles/ |
base: resets, forms, typography defaults, :root tokens |
nothing |
utilities/ |
mixins and functions; emits no CSS | nothing |
pages/ |
page-level composition (Lookbook docs, article page) | placement only |
Placement is grid-area, flex, order, the box you hand the child (width / height),
gap, margin between children, alignment. A panel that writes color, font-*,
padding or a .child__element selector for a pattern is reaching through. Move the rule
into the pattern and, if the panel needs the pattern to look different there, declare a
variant.
The cell
.panel__cell (panels/_panel.scss) is the container a pattern reads its width from:
display: grid; min-width: 0; container-type: inline-size. Cards stack their media at
@container (width < 300px). Every place that lays patterns out wraps each in a cell:
Panels::PatternComponent, Layout::GridComponent, Panels::ArticleListingComponent.
A new layout does the same; a pattern never assumes it is inside a .panel.
The surface contract
utilities/_mixins.scss: light-surface($background, $raised: null),
dark-surface($background, $raised: null), scrim-surface, raised-surface($fallback),
inherit-surface. They publish --surface-ink, --surface-headline, --text-link,
--surface-accent, --surface-tint and optionally --surface-raised; :root
(styles/_colours.scss) carries the light defaults so a pattern rendered bare still reads
something sane.
- Every painted surface calls a mixin: panels, footer, hero, the menu submenu, the language switcher, inputs, the combobox listbox, pagination, list backgrounds.
nullpublishes without painting, for a surface something else paints (a UA-painted control, a host-painted background).- Text over a photo is
scrim-surface: the dark contract with headline and links kept at--text-light. The hero's background media and the image-background card use it. A painted dark panel usesdark-surface, whose headline is the accent tint. - A card on any surface is
raised-surface($fallback): fills from--surface-raisedwhen the surface publishes one, else the fallback, and publishes a light surface inside. Cards call it; panels never do. - Children read tokens.
color: var(--text-dark)inside a pattern is a bug unless the element is a sibling of the surface it sits on (the combobox icons are the one known case; it is commented). - Retired for good:
--link,--swiperbullet, per-panel[data-bgc] .child { color }blocks.
Declare, don't infer
A variant is a kwarg, validated with declared(name, value, allowed) on
ApplicationComponent, emitted as data-*, read by the component's own file:
VARIANTS = %i[default bar stretch action menu compact attached].freeze@variant = declared(:variant, variant, VARIANTS) # → data-variant unless defaultDeclared attributes today: data-variant, data-icon, data-dropdown, data-fit,
data-leading, data-title-link, data-align, data-media-type, data-layout-style,
data-media-alignment, data-media-size. Editor alignment (BOU-2939) emits data-align
(left / center / right) on .panel__title and .panel__subtitle only when it overrides the
header, and on .panel__body and .panel__button. Use :has() only for state unknown at render time
(JS-toggled); never to detect what the Ruby already knows. BOU-2864 retired the
:has(.material-symbols-outlined) icon inference, and two hosts' hand-written icon links lost
their flex row because they declared nothing: an inferred rule hides who depends on it.
Variants belong to the component; containers name them
Two ways a container asks for a variant, both without touching the child's rules:
- It renders the child: pass the kwarg (
ButtonComponent.new(button:, variant: :bar)). - A host renders the child into the container's slot, so the container cannot pass a kwarg: the container includes the child's variant mixin on the slot.
.header__better-action-links .button { @include button-action; }The mixins live in a CSS-free module (components/_button-variants.scss) that both
_buttons.scss and the panel @use. Mixins, not %placeholders: @extend across modules
fails the build ("target selector was not found"). CSS-free, because a partial that @uses
a module with CSS re-emits that CSS inside the loading layer (pages/_component-ref.scss
once duplicated the whole base sheet in the pages layer this way).
Cascade layers
application.scss declares @layer vendor, base, components, patterns, panels, pages,
utilities and loads each directory with meta.load-css into its layer. Consequences:
- Later layers win regardless of specificity, so a panel's placement rule reliably beats a pattern's default. That is the feature.
- A generic element rule in a higher layer beats a class rule in a lower one:
.panel span { display: inline }beat.icon { display: block }and cost the footer toggle 6px. Higher tiers write no bare element rules for things a lower tier owns. Icon rules live incomponents/_icons.scssfor this reason. !importantis gone from the library and stays gone. Hosts are unlayered and beat us anyway; inside the library it only ever won a fight the ownership rule should have prevented.utilities/_index.scssforwards mixins and functions only. CSS added there lands in the top layer and outranks everything.
Media: the card owns the box, the media owns the fit
.media-card__media is the card's box (size, aspect, flex: none). Inside it the image,
video or iframe declares how it fills the box with fit: (auto | contain | cover |
overlay), read by components/_images.scss and friends. A video keeps its own ratio and the
box follows it. A card never sets object-fit on an img; an image never sets its own box.
Patterns.cell_options(type) is where a pattern type asks its cell for special treatment.
Composition over duplication
The media card renders a TextCardComponent with layout_style: "nested" for its text half
(BOU-2904, BOU-2909). One set of text-card rules, in the text card, keyed by the nested
variant plus leading: / meta: / title_link: knobs. When a pattern needs another
pattern's look, it composes that pattern; it never copies its rules.
Native controls
styles/_forms.scss resets <button> to a plain control (no background, border, padding,
margin or font of its own; inherits its text); the skin is .button only. Icon-only buttons
(burger, footer toggles, accordion headers) inherit their text and set only what they need.
Do not restore button, .button skins.
Public hooks
What a host may select, and therefore what a rename must announce in CHANGELOG.md under
"Markup / hooks": a component's root class, its declared data-* attributes, the surface and
palette tokens, the utilities mixins. Element classes (.button-content,
.media-card__media), DOM order and private mixins are internals. Every release entry leads
with the hooks that changed; hosts read that list before they read anything else.
Verifying a stylesheet change
Grepping the sheet is not evidence. scripts/css_verify/ (README there) dumps computed style
and geometry for every element on a URL list and diffs two dumps. Before and after, at 1300px
and 400px:
- Dump the Lookbook scenarios and the dummy's live pages. Diff. Expect zero change except differences you can name and justify in the PR body.
- Drive hidden states first: open the mobile menu, expand a footer group, open a dropdown.
display:nonehas no geometry on either side and diffs clean when broken. - When a change moves boxes, run the geometry probe alongside the style dump.
- Rebuild the sheet (
rails dartsass:buildinspec/dummy) before dumping; the running dummy hot-reloads components but not the compiled CSS. - The dummy does not render everything (no language switcher, no host-rendered action links). What it cannot show, a host must: cut a pre-release and ask each host for the same diff against its previous pin before the release proper.
Reviewing a stylesheet diff
Ask, for every added or moved rule:
- Which tier owns the element this selects? Is the rule in that tier's file?
- Does a container set anything on a child beyond placement?
- Is any colour or type value a literal, or a
--text-*token where a--surface-*token should be read? - Is a variant inferred from markup where the Ruby could declare it?
- Is there a bare element selector in
panels/orpages/? - Does a partial
@useanything other thanutilitiesor a CSS-free mixin module? - Is there an
!important, or a selector written to out-specify another library rule? - Did a public hook change, and is it in
CHANGELOG.md? - Did the PR body list the computed-style differences and justify each?