Composing rich layouts
Patterns for image + text + interactive option surfaces, without the "every piece is a card with a border" stacking-frames problem.
The four v0.1.0 layout pieces (Group, MediaCard, OptionGroup, ChoiceList) are designed to compose: pick the right outer shape, drop in the right inner items, and you get an e-commerce-style recommendation surface in 20 lines of JSON.
The composition rules
- One envelope per surface. A
Card(or the placement's own framing —subtitlealready has a bar,palettealready has a panel) is the visual container. Don't nest a second Card inside. Groupfor transparent stacking. Inside the envelope, useGroupto cluster related children. It has no border / shadow / background — children render edge-to-edge against the surrounding envelope.MediaCardis content, not container. Its own background is transparent. Drop it insideCardfor a single hero, or insideOptionGroupfor one-of-many.- Selection state lives in
data. All input pieces (OptionGroup,ChoiceList,Picker, etc.) bind to adata.<key>path. Read it back from the same path aftertrigger('choose', …)fires.
Pattern 1 — Image + text intro, options below
A common proactive recommendation flow: explain what you're recommending, show 3 picks, let the user choose.
{
root: {
kind: 'Card',
children: [
{
kind: 'MediaCard',
orientation: 'left',
image: { src: '/recommend.png', alt: '', aspectRatio: '1' },
title: 'Based on your recent reads',
description: 'Three picks we think you\'ll like — pick one or skip.',
},
{ kind: 'Divider' },
{
kind: 'OptionGroup',
bind: 'pick',
layout: 'column',
options: [
{ value: 'b1', title: 'Book A', description: 'Genre · 320 pp', image: { src: '/b1.jpg' } },
{ value: 'b2', title: 'Book B', description: 'Genre · 192 pp', image: { src: '/b2.jpg' } },
{ value: 'b3', title: 'Book C', description: 'Genre · 410 pp', image: { src: '/b3.jpg' } },
],
},
],
},
}
Layout: outer Card is the only framed container; MediaCard is the intro (left-aligned image + prose); Divider separates intro from picker; OptionGroup is the picker. No nested borders.
Pattern 2 — 3-up horizontal recommendation grid
Same data but for a wider surface (modal or dock):
{
root: {
kind: 'Card',
children: [
{ kind: 'Heading', text: 'Pick your plan', level: 3 },
{
kind: 'OptionGroup',
bind: 'plan',
layout: 'row',
columns: 3,
options: [
{ value: 'starter', title: 'Starter', description: 'For solo devs', meta: ['US$10/mo'], image: { src: '/p-starter.png' } },
{ value: 'pro', title: 'Pro', description: 'For small teams', meta: ['US$49/mo'], image: { src: '/p-pro.png' } },
{ value: 'team', title: 'Team', description: 'For growing orgs', meta: ['US$199/mo'], image: { src: '/p-team.png' } },
],
},
],
},
}
layout: 'row' + columns: 3 → CSS grid repeat(3, 1fr). Each option is a vertical tile with image on top, text below.
Pattern 3 — Text-only quick pick
When images are overkill:
{
root: {
kind: 'ChoiceList',
bind: 'route',
orientation: 'column',
options: [
{ value: 'continue', label: 'Continue here', description: 'Keep reading on this page' },
{ value: 'docs', label: 'See the docs', description: 'Open the reference manual' },
{ value: 'demo', label: 'Try the demo', description: 'Open the live playground' },
],
},
}
No outer Card needed — the subtitle bar / palette body is the envelope.
Pattern 4 — Group within a Card (no nested borders)
Two clusters of related fields, visually separated without two Card frames:
{
root: {
kind: 'Card',
children: [
{ kind: 'Heading', text: 'Account settings', level: 4 },
{
kind: 'Group',
children: [
{ kind: 'Text', text: 'Profile' },
{ kind: 'TextField', bind: 'name', placeholder: 'Display name' },
{ kind: 'TextField', bind: 'email', placeholder: 'Email' },
],
},
{ kind: 'Divider' },
{
kind: 'Group',
children: [
{ kind: 'Text', text: 'Notifications' },
{ kind: 'Switch', bind: 'emailNotify', label: 'Email me about replies' },
{ kind: 'Switch', bind: 'digestWeekly', label: 'Weekly digest' },
],
},
],
},
}
Keyboard navigation
OptionGroup and ChoiceList both ship with built-in arrow-key navigation, Enter / Space confirm, and roving tabindex (only the selected tile is tab-able; arrow keys move within the group, Tab moves out of the group entirely).
Per layout:
layout: 'row'/orientation: 'row'→ ←/→ moves focus, ↑/↓ also workslayout: 'column'/orientation: 'column'→ ↑/↓ moves focus, ←/→ also works
Confirm is always Enter OR Space, fires ctx.trigger('choose', { value, index }).
Where these surfaces appear
- Proactive prompts — set
surface.pieceson aPromptDefinition(see proactive overview). Replaces the default yes/no Card. - WebAgent — opt in with
WebAgentConfig.allowPresent: true, then the agent can call thepresent_surfaceaction with a piece tree. See present-surface.md. - Skills / palette —
dddk.surfaces.render(surface, { placement })mounts any piece tree at any placement.
What NOT to do
| ❌ Anti-pattern | ✅ Fix |
|---|---|
Card inside Card |
outer Card only; inner Group for clustering |
OptionGroup inside Card and the Card has its own border |
drop the outer Card — OptionGroup items already have selection borders |
Each tile is itself a Card |
use MediaCard (transparent) instead |
Mixing Picker (native select) with ChoiceList (custom) for similar choices |
pick one; ChoiceList if you want descriptions / wider hit targets, Picker if you want a compact dropdown |
Trigger an action from MediaCard directly |
wrap in OptionGroup with one option, or use Button next to it |