DEV Community

Cover image for Parent Owns Layout — A New CSS Architecture for the AI Era — Drift-Resistant by Design
zetsubo
zetsubo

Posted on • Edited on

Parent Owns Layout — A New CSS Architecture for the AI Era — Drift-Resistant by Design

Parts 2–3 established the framework of "invariants" and "feedback loops." Part 3 focused on feedback message design; this part defines the concrete rule system those messages enforce — the principles and structure of SpiraCSS.

Single Principle: Parents Handle Layout, Children Handle Only Internals

Every rule in SpiraCSS derives from one principle:

The parent decides the child's layout; the child only writes its own internals.

CSS layout is fundamentally a "parent arranges children" mechanism. You set display: flex on the parent and align-self on the child. Since a child's margin also affects the parent's layout, it should be controlled by the parent.

All structural rules are derived from this principle.

Block and Element — Two Structural Units

SpiraCSS classifies all classes into Blocks and Elements.

Type Naming Convention Example Role
Block Two-word kebab-case .feature-card, .card-header Independent component unit
Element Single-word .title, .body, .icon Constituent part within a Block

This naming convention corresponds to BEM's "Block__Element," but with a crucial difference: the naming pattern itself expresses the structure. Two words mean Block, one word means Element — no room for interpretation. (This naming rule is the default; case conventions can be customized in spiracss.config.js.)

The allowed parent-child relationships are also explicit:

Parent Child Allowed
Block Block
Block Element
Element Block
Element Element ✅ (with depth limit)

Placing a Block under an Element is forbidden. Element-to-Element nesting is allowed but has a depth ceiling (default 4 levels, configurable via elementDepth). The fundamental structure is "Blocks own children." This doesn't restrict DOM nesting itself; it constrains only responsibility-bearing class relationships. This constraint automatically determines component boundaries.

"What if Element nesting gets too deep?" The answer is straightforward: if a unit has independent responsibility, promote it to a Block (e.g., .title.title-box). Decorative elements are handled with tag selectors or pseudo-elements. Tag selectors are always scoped within a Block (e.g., .feature-card > .title > span), so there's no external leakage. This decision is also mechanical: if you need independent responsibility, promote to Block; otherwise, use Element or tag selectors.

Property Responsibility Separation

The "parent handles layout, child handles internals" principle directly maps to CSS property placement rules.

// Parent Block
.card-list {
  display: flex;           // Container property → on itself
  gap: 24px;               // Container property → on itself

  > .feature-card {
    margin-top: 16px;      // Item property → specified from parent
    flex: 1;               // Item property → specified from parent
  }
}

// Child Block (separate file)
.feature-card {
  padding: 16px;           // Internal property → on itself
  text-align: center;      // Internal property → on itself

  > .title {
    font-size: 18px;       // Internal property → on itself
  }
}
Enter fullscreen mode Exit fullscreen mode

Properties fall into three categories:

  • Container properties (display, gap, justify-content, etc.) → Write on the Block itself
  • Item properties (margin, flex, order, align-self, etc.) → Write in the parent Block's > .child selector
  • Internal properties (padding, font-size, color, etc.) → Write on the selector itself

This three-way classification reflects how CSS layout models (Flexbox, Grid) work. Even before naming or file organization, this classification is the starting point of CSS architecture.

Writing margin-top on the child Block itself triggers a lint error. The error message says: "Specify margin-top from the parent Block using the > .child-name selector." No need to memorize — just write and the lint will tell you.

This rule meets the three criteria for invariants from Part 2. Binary evaluation: Whether margin-top is in the parent's > .child or on the child itself can be unambiguously determined. Syntax-verifiable: Determined by analyzing SCSS selector structure alone; no runtime needed. Locally verifiable: Only the target SCSS file needs to be examined. That's why automated lint verification works.

1 Block = 1 File

SpiraCSS separates files by Block.

feature-card/
├── feature-card.scss      ← Root Block
└── scss/
    ├── card-header.scss   ← Child Block
    ├── card-body.scss     ← Child Block
    └── index.scss         ← @use aggregation
Enter fullscreen mode Exit fullscreen mode

The root Block file loads child Blocks:

@use "sass:meta";

.feature-card {
  @include meta.load-css("scss");

  > .card-header {
    // @rel/scss/card-header.scss
    margin-bottom: 16px;
  }

  > .card-body {
    // @rel/scss/card-body.scss
  }
}
Enter fullscreen mode Exit fullscreen mode

This structure has three advantages:

  1. Physical prevention of class name collisions: You can't create two files with the same name in the same folder. This is not just a rule; the structure makes collisions impossible
  2. Forced responsibility separation: Child Blocks write only their own internals in their own files. There's no way to interfere with the parent's layout
  3. Navigation without searching: @rel comments link between files, enabling one-click navigation to related files

Variant and State Separation

BEM modifiers used a single mechanism to express both visual variations and dynamic states. SpiraCSS separates these into two:

Type Purpose HTML SCSS
Variant Static variations (size, color scheme) data-variant="primary" &[data-variant="primary"]
State Dynamic states (open/close, selection, loading) data-state="active" &[data-state="active"]
.feature-card {
  padding: 16px;
  background: white;

  &[data-variant="highlight"] {
    background: #f0f8ff;
  }

  // --interaction
  @at-root & {
    transition: box-shadow 0.3s ease;

    &[data-state="disabled"] {
      opacity: 0.5;
      pointer-events: none;
    }

    &:hover {
      box-shadow: 0 2px 8px rgba(0,0,0,0.1);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Variants go in the base structure section; States go in the --interaction section. Stylelint also enforces this placement.

SCSS Section Structure

The internal file structure is also defined. A single Block file consists of up to three sections:

  1. Base structure: Block layout, child placement, Variants
  2. --shared (optional): Styles shared among descendants within the Block
  3. --interaction (optional): States, hover, focus, ARIA, transitions, animations

"Where do I write hover?" "Where does transition go?" — these commonly ambiguous decisions in practice are resolved unambiguously by the section structure.

The Big Picture So Far

SpiraCSS's design can be summarized as follows:

Single principle (parent handles layout, child handles internals)
  ├── Naming: Block (two-word) / Element (single-word)
  ├── Structure: Element > Block forbidden, Element depth limit
  ├── Properties: Container / Item / Internal — 3 categories
  ├── Files: 1 Block = 1 file
  ├── State: Variant / State separation
  └── Sections: Base / --shared / --interaction
Enter fullscreen mode Exit fullscreen mode

Every rule derives from the single principle, and every rule is verifiable by Stylelint. No need to memorize conventions — just write and the lint will tell you.

The next part covers the tools and procedures for adopting this design in practice. How do you integrate it into a real project, and how do humans and AI coexist?


SpiraCSS's design specs, tools, and source code are all open source.

Top comments (0)