← All proposals

Suggestion Card

A card for use inside an agent `ChatMessage`:

Net-new componentOpen in Storybook

proposals/SuggestionCard/HANDOFF.md107 lines · View on GitHub

Handoff: SuggestionCard

What and why

An agent's proposed changes in a conversation, with the buttons to decide. When an agent proposes a change in a chat (for example edits to page copy), the user needs to see what would change and accept it, ask for something different, or dismiss it. PDS has no component for this, and it is not a variant of the agentic Panel (that is a full-width report on a page; this is a decision prompt in the message stream). Built on the PDS Panel. Includes SuggestionChange, an optional before/after row. A type='choice' option (added 2026-10-21) makes it a multiple choice card: header, a PDS panel list of radio or checkbox rows (SuggestionChoice), and the footer. Figma: Suggestion card page (the designer's hand-built component set; it replaced the first capture). Context: context.md.

Exploration and decisions

  • Before and after as a stacked diff (2026-10-21), now the Content Diff component: the two uppercase columns were replaced by one bordered block under the field name: the old value on a red line with a minus, the new value on a green line with a plus. Long text gets the full width, and the "Before" and "After" words are hidden text for screen readers.
  • An Other option (2026-10-21): SuggestionChoice takes children under its label, used for a text field. The card does not know about it; the screen enables or selects it (the stories select Other when the field is focused).
  • Retry and browse is a recipe, Regenerate a suggestion, not part of the card: it fills the existing footer slots.
  • The Interactive story shows all three kinds working (changes, single choice, multiple choice). After Accept the Feedback Bar shows and, once a thumb is pressed, "Thanks for the feedback" stays (the screen owns the rating).
  • Own component, not a variant of the agentic Panel: different anatomy and lifecycle (the card outlives the decision in the history).
  • Built on Panel (default variant, condensed padding) for border, radius and surface; one inner wrapper so Panel's first-child heading rules do not resize the title.
  • The content area is a slot (children); SuggestionChange is an optional helper. Two optional footer slots: actionsStart (decision buttons) and actionsEnd (for example a Feedback Bar, shown in every state).
  • Multiple choice (type='choice', 2026-10-21): the same header, footer slots and states, but the card has no padding of its own: padded header and footer sections divided from a PDS PanelList by lines edge to edge. Rows are SuggestionChoice (a PanelRow): a PDS Checkbox or a PDS-styled radio with a label and a description. The screen owns the selection and what the footer button does. One component with a type prop rather than a second one, so the header, footer and states are shared.
  • Three states: pending, accepted and dismissed. A finished state replaces actionsStart with a result line in a role='status' region that is always in the DOM, so it is announced. Dismissed also hides the changes and leaves room for an Undo in actionsEnd.
  • buttonSize (xs default, s) sizes the whole button set by cloning size onto every Button in actionsStart.
  • Title is the PDS pds-heading-m (or l) class and the description pds-copy-s, secondary color; headingLevel only sets the tag.
  • No Feedback Bar on a dismissed card in the design (a dismiss is already a signal; nothing was applied to rate).

Full list and reasons: decisions.md.

API

SuggestionCard (a Panel, so a div; extends div props except title):

Prop Type Default Purpose
actionsEnd ReactNode none Right end of the footer, shown in every state (for example a Feedback Bar).
actionsStart ReactNode none Decision buttons at the left of the footer; shown while pending, replaced by statusLabel once accepted or dismissed.
buttonSize 'xs' | 's' 'xs' Size of every Button in actionsStart, as one set.
children ReactNode none The changes (hidden when dismissed).
className string none Additional class names.
description ReactNode none Short summary of what is suggested.
headingLevel 'h2'-'h6' 'h3' Tag of the title, to fit the page outline.
headingSize 'm' | 'l' 'm' Size of the title text (16px / 18px).
padding Panel's padding 'condensed' Same options as Panel: none, condensed, standard, spacious.
state 'pending' | 'accepted' | 'dismissed' 'pending' Where the suggestion is.
statusLabel string none Result text for accepted and dismissed; announced when it appears.
title string none (required) What is suggested (for example "2 changes").
type 'changes' | 'choice' 'changes' choice is multiple choice: the children are SuggestionChoice rows in a PDS panel list. padding does not apply to it.

SuggestionChange (a div: the field name above a ContentDiff): after, afterLabel, before, beforeLabel and label (all required), plus className. afterLabel and beforeLabel are screen-reader text only, not shown.

SuggestionChoice (one row of a choice card): id and label (required), selectionType ('radio' or 'checkbox', required), children (shown under the label, for example a text field for an Other option), description, name (shared by the radios of one question), checked / defaultChecked, disabled, onChange, value, className.

Variants and states: type changes / choice; pending, accepted, dismissed; padding and heading options. Stories: Default, Accepted, Dismissed, Choice Slots, Choice Radio, Choice Checkbox, Interactive (the changes card and both choice cards, working).

Tokens used

  • --pds-border-width-default
  • --pds-color-border-default
  • --pds-color-foreground-default-secondary
  • --pds-color-status-critical-foreground
  • --pds-color-status-success-foreground
  • --pds-spacing-2xs
  • --pds-spacing-5xs
  • --pds-spacing-l
  • --pds-spacing-m
  • --pds-spacing-s
  • --pds-spacing-xs
  • --pds-typography-fw-bold
  • --pds-typography-fw-medium
  • --pds-typography-fw-semibold
  • --pds-typography-ls-l
  • --pds-typography-size-2xs
  • --pds-typography-size-xs

Impact

From impact.md: no existing PDS component is reached and no --pds-* token is redefined. No other proposal imports or uses SuggestionCard by code (the Chat with an agent recipe and the Chat Panel's stories show it in a conversation).

Reused vs net-new

  • Reused: PDS Panel, PanelList and PanelRow (the choice rows), the PDS Checkbox (checkbox rows), the package's .pds-radio-group__option class (to style the radio rows), PDS Button (through the local Button, for agentic styling in stories), the local Icon wrapper (check, circleMinus), the semantic pds-heading-* and pds-copy-s classes. FeedbackBar is composed in the stories (in actionsEnd), not imported by the component.
  • Net-new: .pds-suggestion-card (--accepted, --dismissed, __actions, __actions-end, __body, __changes, __description, __footer, __footer--empty, __heading, __result, __result-icon, __title; for choice: --choice, __choices, __section, __section--header, __section--footer) and .pds-suggestion-choice (__control, __description, __label, __name, __text) and .pds-suggestion-change (__field; the diff itself is the Content Diff); all props; custom CSS variables --suggestion-card-gap and --suggestion-card-actions-gap. No raw values kept. Details in custom.md.

Accessibility

  • The result line sits in a role='status' region that stays in the DOM while empty, so "Changes applied" or "Dismissed" is announced when it appears.
  • Choice rows: each row is a labelled native input (the PDS Checkbox, or a radio in a label), with the description linked by aria-describedby. The list is role='group' labelled by the title. Radios in one question share a name, so arrow keys move between them. The screen owns which is selected.
  • The title is a real heading; set headingLevel to fit the outline. Before and after are a dl with visually hidden dt names ("Before", "After"), and the minus and plus icons carry the meaning too, so it does not depend on color alone.
  • Focus: the card does not manage it. When Dismiss disappears the screen should move focus to Undo, and back to Dismiss after Undo (the stories use autoFocus).
  • The a11y panel has not been run, and light and dark were not checked by eye in a session.

Decisions and open questions

For the engineer or designer to settle:

  • Feedback Bar is composed in the stories (in actionsEnd); it is ready for dev and in the port order before this card. The card works without it, since actionsEnd is a slot.
  • Choice rows: PDS has no standalone Radio (RadioGroup renders a whole group), so the radio rows borrow the package's radio styling through its class; the port may want a standalone PDS Radio. Only the label (not the empty space in the row) is the click target. Should the card know the selection (for example to disable the confirm button until something is picked), or stay the screen's?
  • A pending card the conversation moved on from: is a "superseded" or "expired" state needed?
  • Refine: what it does is the screen's (focus the chat input, open a field).
  • Accept style: a plain primary button here; the report panel's Approve is agentic. Gradient only when applying something to the site?
  • Many changes: no height cap or scroll; the chat scrolls. Code-diff changes are not built here.
  • buttonSize clones size onto the Buttons in the slot, which is unusual for PDS; the port can pass the size down through context instead.
  • Is m ever needed for buttonSize? Should the card own spacing between its children, or only the frame?

Port notes

Would live in src/components/ (a chat or conversation category folder; generator npm run generate:component), built on Panel. Not yet matching v2: the local mergeClasses stand-in (use @libs/components/utils); the local Button and Icon wrappers (use the ported Button and Icon); pds-copy-s and pds-heading-* are !important, so a rule on the same element cannot override size or weight.

Preview

npm run storybook, story Component Proposals / AI Chat / Suggestion Card. Figma: https://www.figma.com/design/AMGWJbraJoi2PKWnxo29Ah/PDS-Proposals?node-id=300-7510, page ↳ Suggestion card (the designer's component set, not a capture).