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

  1. One envelope per surface. A Card (or the placement's own framing — subtitle already has a bar, palette already has a panel) is the visual container. Don't nest a second Card inside.
  2. Group for transparent stacking. Inside the envelope, use Group to cluster related children. It has no border / shadow / background — children render edge-to-edge against the surrounding envelope.
  3. MediaCard is content, not container. Its own background is transparent. Drop it inside Card for a single hero, or inside OptionGroup for one-of-many.
  4. Selection state lives in data. All input pieces (OptionGroup, ChoiceList, Picker, etc.) bind to a data.<key> path. Read it back from the same path after trigger('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 works
  • layout: '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.pieces on a PromptDefinition (see proactive overview). Replaces the default yes/no Card.
  • WebAgent — opt in with WebAgentConfig.allowPresent: true, then the agent can call the present_surface action with a piece tree. See present-surface.md.
  • Skills / palettedddk.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