DDI

<No title set>

Preflights and utility classes

DDI (Designer–Developer Interface) exists to close the gap between a designer’s intent and a developer’s markup. A designer thinks in terms of a design system — spacing scales, semantic colors, type levels, layout patterns. A developer, left to their own devices, reaches for one-off utility classes and inline styles that drift from that system a little more with every page. DDI’s bet is that most of a page’s styling should come for free from what the HTML already is, not from what classes get bolted onto it. Utility classes are the escape hatch for the last 10%, not the tool you build a page with.

The sections below outline the principles that follow from that bet. They outrank convenience — if a shortcut in this guide conflicts with one of these, the shortcut is wrong.

Preflights style the page first

Most of a page’s visual styling already exists before you write a single class. It’s generated as preflight CSS from your project’s ddi.config.ts and targets bare semantic HTML and composition classes directly.

A plain button, unstyled by you
<div class="x-flow">
    <button>Primary</button>
    <button class="secondary">Secondary</button>
</div>

Neither button above has been given any styling by the page author — the button element is fully styled by the preflight layer, and .secondary is a composition-level modifier, not a utility class. An <a> needs .button only because it isn’t a <button> to begin with:

<a class="button" href="/contact">Contact us</a>

Before reaching for a utility class, ask whether semantic HTML plus a composition class already gets you there. Utility classes exist for one-off adjustments — a nudge in spacing, a one-time color override — not for constructing a page’s primary layout or typography. If you find yourself writing four or five utility classes to make a div look like a heading, the real fix is usually to use the heading.

Prose goes inside .body-text, not hand-styled

Headings and paragraphs are never styled element-by-element with utility classes like font-size:value or bold. Instead, prose is wrapped in .body-text, and its direct children — h1h4, p, ul, ol, pre, img — are styled automatically based on their semantic level.

Prose styled by .body-text alone

Category label

Prose, not utilities

No element here has a font-size:value or bold class on it. The heading, the subtitle, and this paragraph all get their styling from being direct children of .body-text.

<div class="body-text">
    <p class="pre-heading">Category label</p>
    <h2 id="prose-not-utilities">Prose, not utilities</h2>
    <p>No element here has a <code>font-size:value</code> or <code>bold</code> class on it. The
        heading, the subtitle, and this paragraph all get their styling
        from being direct children of <code>.body-text</code>.</p>
</div>

Compare the two ways of producing the same heading:

<!-- Avoid: hand-styled heading -->
<p class="font-size:2xl font-weight:bold mbe:s">Section title</p>

<!-- Prefer: real heading inside .body-text -->
<div class="body-text">
    <h2>Section title</h2>
</div>

The second version stays correct automatically if the type scale changes later — the first one is frozen the moment it’s written.

Composition classes already do their own spacing

.flow and .x-flow space their children using a --space custom property and margins, not gap. Adding a gap:value utility class alongside one of them doesn’t replace that spacing — it stacks on top of it, and now two systems are fighting over the same visual gap.

<!-- Avoid: gap stacks on top of .flow's own margin-based spacing -->
<div class="flow gap:m">
    <p>First</p>
    <p>Second</p>
</div>

<!-- Prefer: let .flow own the spacing -->
<div class="flow">
    <p>First</p>
    <p>Second</p>
</div>

If you need real gap-based spacing instead, reach for .stack or .x-stack — the flex-container equivalents that don’t apply any spacing of their own — and add gap:value to those.

Pick one container strategy, don’t layer them

.content and .wrapper both center content with a max-width, but they solve different problems: .content centers a page-level section at the page’s content width, .wrapper centers a block of reading content at the narrower prose measure width. They’re alternatives for the same job at different scales, not layers meant to be nested.

<!-- Avoid: two competing max-width strategies on the same content -->
<div class="content">
    <div class="wrapper body-text">...</div>
</div>

<!-- Prefer: pick the one that matches what this container is -->
<div class="wrapper body-text">...</div>

A page-level section (a hero, a full-width band) is .content. A block of reading content (an article body) is .wrapper. If you’re not sure which, ask what would happen if the type scale’s measure and the layout’s content width diverged — whichever one the container should track is the right pick.

Reach for scopedRules, not utility-class complexity

Utility classes are single-condition and unconditional — a class either applies or it doesn’t. There’s no hover:/md:-style variant syntax in this system, and that’s a deliberate boundary, not a gap waiting to be filled. When something genuinely needs a @media query, a :hover state, or another compound/conditional selector, reach for scopedRules() + rule() (@dynamic-type/ddi/server) and write real, scoped CSS — not a pile of utility classes straining to simulate conditional logic.

This is safe to nest without extra bookkeeping: rule()’s third argument accepts further rules that get emitted verbatim via native CSS nesting, and scopedRules() only hashes each top-level rule’s own selector — so anything nested inside it (&:hover, @media (...), @keyframes) inherits that rule’s scoping for free. This is the same mechanism already used for &[open] in the Drawer component; the example below is the scopedRules equivalent of StickyCTA’s hand-written @media (max-width: 768px) mobile/desktop split.

A layout that stacks in a column on mobile and switches to a row on desktop — resize the browser window (not just this panel) to see it change, since @media matches viewport width:

Resize the browser window — column on mobile, row on desktop
Sidebar
Main content
Aside
---
import { rule, $c, $s, scopedRules, Style } from "@dynamic-type/ddi/server";

const [{ layout, item }, rules] = scopedRules([
    rule(
        ".layout",
        {
            display: "flex",
            "flex-direction": "column",
            gap: $s("s"),
        },
        [
            rule("@media (min-width: 768px)", {
                "flex-direction": "row",
            }),
        ],
    ),
    rule(".item", {
        flex: "1",
        padding: $s("padding"),
        background: $c("bg-alt"),
        "border-radius": $s("border-radius"),
    }),
]);
---

<div class={layout}>
    <div class={item}>Sidebar</div>
    <div class={item}>Main content</div>
    <div class={item}>Aside</div>
</div>

<Style rules={rules} />

See Scoped CSS Rules for the full scopedRules() API.

Why this ordering matters

These principles are listed in the order they should be applied, and llms.txt — the reference an LLM working in a DDI-based project reads first — states them the same way for a reason: getting them backwards is the most common source of non-idiomatic DDI markup. A page built utility-class-first will still render, but it stops benefiting from the design system the moment ddi.config.ts changes — every hand-picked font-size:value and gap:value is a small fork of the system that someone has to find and reconcile later. A page built semantic-HTML-first, with utility classes reserved for genuine one-offs, moves with the system instead of against it.