CSS is Awesome

Composition — the cia decision tree

cia is mixin-first: you author your own selectors and @include the system. This page is the decision tree from design intent to cia code — covering every value you might need to express and where to reach in cia. Read it once; the rest of the docs make sense after.

Written equally for two audiences: humans authoring components and AI agents generating cia code via the MCP server. The decision tree is identical for both.

The decision tree

You need to express a value. Walk this tree:

Need a value in your component →

├─ Is it a color, type, radius, shadow, motion timing?
│   → Use the themed token:
│     m.color() / m.font-size() / m.radius() / m.shadow() / m.duration()
│     (varies per theme — consumers can re-tune)
│
├─ Is it a themeable space (margin, padding, gap)?
│   → m.space(n)
│     (themed spacing scale — consumers can re-tune)
│
├─ Is it an explicit geometric size on the 4px grid?
│   → m.grid(n)
│     (NOT themed — geometric truth, shared with Figma)
│
├─ Is it an off-grid pixel value (rare)?
│   → m.px(value)
│     (raw rem conversion — escape hatch, not the default)
│
└─ Is it a component or pattern?
    ├─ Does a cia mixin match? → m.btn() / m.modal() / m.card-base() / etc.
    │       Customize via parameters: cia.btn(primary, $bg: brand-accent, $r: full)
    │       Extend via @content: cia.btn(primary) { letter-spacing: 0.05em; }
    │
    ├─ Does a recipe show the pattern? → follow scss/recipes/<name>.md
    │       (dialog, combobox, mobile-nav, bottom-nav, print-to-pdf)
    │
    └─ Otherwise: compose from cia primitives
            (m.flex / m.stack / m.cluster / m.btn-base / m.focus-ring / m.transition)

Page-level structure — shells, named grid areas, the mobile toolkit — has its own decision path: m.page-layout() / m.layout(), covered in the mobile playbook.

Branch 1 — Themed tokens (color, type, radius, shadow, motion)

These are design choices. They vary per theme. Use the typed token function; the value is whatever the active theme declares.

// theme provides --action-primary-default; m.color() returns var(--action-primary-default)
.my-cta {
  background: m.color(action-primary-default);
  color: m.color(text-inverse);
  font-size: m.font-size(3);
  border-radius: m.radius(md);
  box-shadow: m.shadow(2);
  transition-duration: m.duration(normal);
}

Branch 2 — Themeable spacing

Margin, padding, gap. cia ships a numbered space scale (19) with t-shirt aliases (xs through 4xl). The scale itself IS themeable — consumers can re-tune the spacing per theme.

.feed {
  padding: m.space(4) m.space(6);
  gap: m.space(3);
}

Branch 3 — Geometric grid (NOT themed)

Explicit sizes on cia's 4px coordinate system. Use for icon widths, control heights, fixed dimensions that must align with the design grid. NOT themed — consumers don't tune the 4px grid; it's geometric truth.

Why distinct from m.space()? m.space() is themeable (varies per theme); m.grid() is geometric (fixed across all themes). Same math, different semantics — use m.space() for margins and gaps, m.grid() for icon widths and control heights.

.icon-sm     { width: m.grid(4);  }  // 16px
.control-md  { height: m.grid(10); } // 40px
.avatar-xl   { inline-size: m.grid(20); }// 80px

Branch 4 — Off-grid pixel values (rare)

For the rare case where a value doesn't fit any cia scale. Returns rem so user zoom still works. Prefer m.grid() / m.space() / typed tokens when they fitm.px() is the escape hatch, not the default.

.hero    { margin-block-start: m.px(33); }// 2.0625rem
.badge   { margin-inline-end: m.px(17);  }// 1.0625rem

Branch 5a — Component mixin + parameter-power

cia ships component mixins for the patterns most consumers need: m.btn(), m.modal(), m.card-base(), m.input-base(), etc. Every cia mixin accepts overrides — this is parameter-power. If the default doesn't fit, override the parameter.

// the default
.checkout-cta { @include cia.btn(primary); }

// custom background (hover + active derive automatically from $bg)
.brand-cta { @include cia.btn(primary, $bg: brand-accent); }

// custom radius + padding + size
.pill-cta { @include cia.btn(primary, $px: 6, $r: full, $font-size: 3); }

// AND extend via @content for anything the params don't cover
.tracked-cta {
  @include cia.btn(primary) {
    letter-spacing: 0.05em;
    text-transform: uppercase;
  }
}

The MCP server's get_mixin tool returns the full parameter list for any cia mixin. AI agents: when you need a component, fetch the mixin's signature first and override parameters rather than reaching for a literal value.

Branch 5b — Recipes for higher-level patterns

When a single mixin isn't enough, cia ships recipes — full framework-agnostic patterns: dialog, combobox, print-to-PDF, mobile-nav, bottom-nav. Each recipe is a markdown file at scss/recipes/<name>.md with structure, styling, a11y, and framework examples.

Recipes assemble cia primitives — they don't replace them. Read the recipe; copy the structure; let your selector names own the markup.

Branch 5c — Composing from primitives

When no mixin and no recipe matches your component, compose from cia primitives directly. The system gives you the building blocks (m.flex, m.stack, m.cluster, m.btn-base, m.focus-ring, m.transition, the typed token functions) — assemble them into your bespoke component.

// A bespoke pricing-tier card — no single cia mixin matches; compose
.pricing-tier {
  @include m.card-base($shadow: 2);
  @include m.stack($gap: 4);
  padding: m.space(6);
  border: 1px solid m.color(border-default);

  &[data-featured="true"] {
    border-color: m.color(action-primary-default);
    box-shadow: m.shadow(3);
  }

  .price {
    font-size: m.font-size(6);
    font-weight: m.font-weight(bold);
  }

  .cta { @include m.btn(primary); }
}

The system disappears at this point — you're writing your own component, with cia's tokens and primitives feeding it. The result is bespoke but coherent with the design system.

A bespoke component — three ways

Build the same multi-step pricing-tier card three ways. Compare and see why cia's composition story is the path of least friction.

The Tailwind way (utility soup)

<div class="bg-white dark:bg-slate-900 rounded-lg shadow-md p-6
  border border-slate-200 dark:border-slate-700 flex flex-col gap-4
  data-[featured=true]:border-blue-600 data-[featured=true]:shadow-lg
  data-[featured=true]:dark:border-blue-400">
  <h3 class="text-base font-medium text-slate-700 dark:text-slate-200">
    Pro
  </h3>
  <div class="text-5xl font-bold text-slate-900 dark:text-white">
    $24
  </div>
  <button class="bg-blue-600 hover:bg-blue-700 active:bg-blue-800
    dark:bg-blue-500 dark:hover:bg-blue-400 dark:active:bg-blue-300
    text-white font-medium px-4 py-2 rounded-md transition-colors
    focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2">
    Get Pro
  </button>
</div>

Forty-plus utility classes scattered across the markup. Color values repeated in light + dark variants. Hover / active / focus states spelled out per element. Changing the brand color = find-and-replace across the file.

The Bootstrap way (override fight)

<div class="card p-6 my-pricing-tier">
  <h5 class="card-title">Pro</h5>
  <div class="display-4">$24</div>
  <button class="btn btn-primary my-cta">Get Pro</button>
</div>

<style>
  /* override Bootstrap's defaults — fighting specificity */
  .my-pricing-tier { gap: 1rem !important; display: flex !important;
    flex-direction: column !important; }
  .my-pricing-tier .card-title { font-size: 1rem !important; }
  .my-pricing-tier[data-featured="true"] {
    border-color: var(--bs-primary) !important;
  }
  .my-pricing-tier .my-cta { padding: 0.5rem 1rem !important; }
</style>

Bootstrap classes for structure, then !important wars for every customization. Dark mode? You're editing CSS vars or adding another stylesheet. Change the brand? Edit _variables.scss and rebuild.

The cia way (composition)

// Markup uses YOUR class names; cia mixins do the work
.pricing-tier {
  @include m.card-base($shadow: 2);
  @include m.stack($gap: 4);
  padding: m.space(6);
  border: 1px solid m.color(border-default);

  &[data-featured="true"] {
    border-color: m.color(action-primary-default);
    box-shadow: m.shadow(3);
  }

  .price { font-size: m.font-size(6); font-weight: m.font-weight(bold); }
  .cta   { @include m.btn(primary); }
}

Fourteen lines of SCSS. Dark mode? Already handled — the tokens emit light-dark() per the active theme. Change the brand? Edit one theme file. Different size buttons across the app? Pass $px / $r to btn() per use. Same vocabulary across every component in your app.

For AI agents — the contract

If you're generating cia code (via MCP, Figma → Code, or any agent workflow), the contract is:

  1. Resolve the value via the decision tree above. Never write a literal rem / px / hex that doesn't come from a cia function or token.
  2. For size values from a design tool (e.g. Figma gives 24px): call the MCP resolve_size tool → returns step 6 → emit m.grid(6).
  3. For component patterns: call MCP get_mixin with the component name → receive the full parameter list → use parameter-power. Reach for @content only when no parameter matches.
  4. For higher-level patterns: call MCP get_recipe → receive the recipe's structure + a11y checklist + framework examples → adapt to the consumer's framework.
  5. When nothing matches: compose from cia primitives per the "Composing from primitives" section above.

This contract is what makes cia AI-comprehensible: every value has a deterministic source, every component has an overridable mixin or recipe, and every fallback is documented.

Theme