Composition

Shapes are immutable HTML. They don't import each other, don't know about themes, and don't generate markup. An interface defines the data. A shape decides which fields to show and how to arrange them.

This page demonstrates two interfaces, five shapes, scoped theming, LLM assembly, and slot-based composition — all without a single shape knowing about any other.

Interface — UserProfile
interface UserProfile {
  name: string
  initials: string
  role: string
  joinedAt?: string
}
Interface — Comment
interface Comment {
  body: string
  timestamp: string
}

All 5 shapes — no theme wrapper

These shapes have no data-theme ancestor. Custom properties like var(--surface) resolve to nothing. The HTML structure is complete — but there's no colour.

profile-horizontal
AW

Alan White

Admin
profile-vertical
AW

Alan White

Admin
profile-inline

Written by AW Alan White

comment-thread
AW Alan White

The separation of interface and shape is the key insight here.

comment-compact
SK Sarah Kim

Agreed, this changes how I think about components.

The shape is the HTML. Fixed structure, fixed slots. The interface defines what data exists. The shape decides which fields to show and how to arrange them. Without a theme, the structure is complete but colourless — custom properties resolve to nothing.

Scoped CSS + themes

Wrap a shape in data-theme and it comes alive. @scope contains the CSS so it never leaks. var(--surface) reads from the nearest data-theme ancestor. Themes are purely colour — shapes own spacing, layout, and radii.

Same shape, swap the wrapper

The comment-thread shape below is identical in both cases. Only the data-theme attribute changes.

data-theme="light"
AW Alan White

The separation of interface and shape is the key insight. Themes are just colour overlays.

data-theme="dark"
AW Alan White

The separation of interface and shape is the key insight. Themes are just colour overlays.

light
AW Alan White

Themes set custom properties. Shapes read them.

dark
AW Alan White

Themes set custom properties. Shapes read them.

brand
AW Alan White

Themes set custom properties. Shapes read them.

elevated
AW Alan White

Themes set custom properties. Shapes read them.

@scope — comment-thread shape
@scope ([data-shape="comment-thread"]) {
  :scope {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    padding: 1rem;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 12px;
  }

  [data-slot="author"] {
    margin-bottom: 0.125rem;
  }

  p { color: var(--on-surface); }
  /* timestamp owns its own appearance via
     @scope([data-primitive="timestamp"]) */
}

@scope contains CSS so it never leaks. Each shape's styles are scoped to its data-shape root. var(--surface) reads from the nearest data-theme ancestor. Two completely independent axes: the shape axis (structure) and the theme axis (colour).

LLM assembly

The LLM never writes HTML from scratch. It selects from a finite menu of shapes and assembles them. Selection, not generation.

1
Page intent

"Blog post with comments"

2
Regions
header article-body comments
3
Interfaces per region
header → UserProfile comments → Comment[]
4
Shape per interface
header → profile-horizontal comments → comment-thread comment author → profile-inline
5
Theme per region
header → light comments → dark

Assembled output

This is what the LLM produces. Every tag uses data-shape and data-slot. No free-form HTML. No class names.

LLM output — assembled HTML
<!-- LLM output: selection, not generation -->
<div data-theme="light">
  <article data-shape="profile-horizontal">
    <span data-primitive="avatar" data-size="md">AW</span>
    <div>
      <h3>Alan White</h3>
      <span>Admin</span>
    </div>
  </article>
</div>

<div data-theme="dark">
  <article data-shape="comment-thread">
    <div data-slot="author">
      <span data-shape="profile-inline">
        <span data-primitive="avatar" data-size="sm">SK</span><span>Sarah Kim</span>
      </span>
    </div>
    <p>Great post, really clarified the model.</p>
    <time data-primitive="timestamp" data-size="sm">2 hours ago</time>
  </article>
</div>

Selection, not generation. The LLM picks from a finite menu: which interface fits this region, which shape suits the context, which theme matches the surrounding design. It assembles known, tested HTML fragments. It never invents markup.

Composition via slots

data-slot is a named region inside a shape where another shape mounts. The parent shape provides the slot. The page fills it. Shapes don't know about each other — composition is structural placement, not component coupling.

Filling the author slot

The comment-thread shape has an author slot. Different shapes can fill the same slot — the comment doesn't care which one.

author slot → profile-inline
AW Alan White

Shapes don't import each other. The page decides what goes in each slot.

author slot → profile-horizontal
AW

Alan White

Admin

Shapes don't import each other. The page decides what goes in each slot.

Realistic composed block

Three comments, each with an inline author, inside a dark theme. This is what a comment sidebar looks like when assembled from shapes.

Rendered
Comments
AW Alan White

The key insight is that shapes never import each other. Composition happens at the page level.

SK Sarah Kim

This is exactly how I think about it. Interfaces define the data contract, shapes are just HTML.

MJ Marcus Jones

Does this work with Web Components too?

What the LLM outputs
Assembled HTML
<div data-theme="dark">

  <article data-shape="comment-thread">
    <div data-slot="author">
      <span data-shape="profile-inline">
        <span data-primitive="avatar" data-size="sm">AW</span><span>Alan White</span>
      </span>
    </div>
    <p>The key insight is that shapes never import...</p>
    <time data-primitive="timestamp" data-size="sm">3 hours ago</time>
  </article>

  <article data-shape="comment-thread">
    <div data-slot="author">
      <span data-shape="profile-inline">
        <span data-primitive="avatar" data-size="sm">SK</span><span>Sarah Kim</span>
      </span>
    </div>
    <p>This is exactly how I think about it.</p>
    <time data-primitive="timestamp" data-size="sm">2 hours ago</time>
  </article>

  <article data-shape="comment-thread">
    <div data-slot="author">
      <span data-shape="profile-inline">
        <span data-primitive="avatar" data-size="sm">MJ</span><span>Marcus Jones</span>
      </span>
    </div>
    <p>Does this work with Web Components too?</p>
    <time data-primitive="timestamp" data-size="sm">1 hour ago</time>
  </article>

</div>

Same data, different shape

The same comments rendered with comment-compact instead of comment-thread. Different shape, same interface, same slot pattern.

comment-compact + light
AW Alan White

The key insight is that shapes never import each other.

SK Sarah Kim

This is exactly how I think about it.

MJ Marcus Jones

Does this work with Web Components too?

comment-compact + dark
AW Alan White

The key insight is that shapes never import each other.

SK Sarah Kim

This is exactly how I think about it.

MJ Marcus Jones

Does this work with Web Components too?

Shapes don't know about each other. Parent @scope never crosses into child @scope. The comment-thread shape styles the slot container, not the slot content. profile-inline styles itself. Composition is structural placement — not component coupling.