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 {
name: string
initials: string
role: string
joinedAt?: string
} 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.
Alan White
AdminWritten by AW Alan White
The separation of interface and shape is the key insight here.
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.
The separation of interface and shape is the key insight. Themes are just colour overlays.
The separation of interface and shape is the key insight. Themes are just colour overlays.
Themes set custom properties. Shapes read them.
Themes set custom properties. Shapes read them.
Themes set custom properties. Shapes read them.
Themes set custom properties. Shapes read them.
@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.
"Blog post with comments"
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: 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.
Shapes don't import each other. The page decides what goes in each slot.
Alan White
AdminShapes 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.
The key insight is that shapes never import each other. Composition happens at the page level.
This is exactly how I think about it. Interfaces define the data contract, shapes are just HTML.
Does this work with Web Components too?
<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.
The key insight is that shapes never import each other.
This is exactly how I think about it.
Does this work with Web Components too?
The key insight is that shapes never import each other.
This is exactly how I think about it.
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.