← All proposals

Suggestion Card

A card for use inside an agent `ChatMessage`:

Net-new componentOpen in Storybook

proposals/SuggestionCard/decisions.md69 lines · View on GitHub

Decisions

  • Its own component, not a variant of the agentic panel. The other design in the same set is a full-width investigation report on a page (Panel with an agentic border: title, summary, sections, a code diff, Discard / Approve). This is a decision prompt in the message stream: title, one-line summary, changes, Accept / Refine / Dismiss. Their anatomy and lifecycle differ; the card outlives the decision in the history and has resolved states. They share only small parts (Buttons, check lines, a file-diff row), not a component.
  • Built on the PDS Panel (default variant, condensed padding) for the border, radius and surface, so no container is redrawn. Everything sits in one inner wrapper so the Panel's own first-child heading rules don't resize the title.
  • Props: title (required), description, children (the changes), actions (slot), state, statusLabel, headingLevel. No text is hardcoded; the buttons and labels all come from the screen.
  • state: pending | accepted. pending shows actions. accepted replaces them with a result line (success-colored check plus statusLabel), inside a role='status' region that is always in the DOM, so the change is announced. The changes stay visible. Erika's call: there is an accepted state; its final look is to be refined later.
  • Dismissed is designed (see the dismissed entry at the end). Still open: an expired or superseded state, for a pending card the conversation moved on from.
  • Open: Refine. The card only holds the button (in actionsStart). What it does (focus the chat input, open a field) is the screen's.
  • SuggestionChange: a field label over a before and after pair, as a p plus a dl (dt for the heading, dd for the value). beforeLabel and afterLabel are required strings because the words are translatable. An empty value is passed as a dash by the consumer. Before is in --pds-color-status-critical-foreground, after in --pds-color-status-success-foreground (semibold), headings uppercased with CSS (--pds-typography-ls-l). The text labels carry the meaning, not only color. Columns are 1fr / 2fr (a guess from the screenshot).
  • Open: Accept style. Accept is a plain primary button here; the panel's Approve has the agentic gradient. Gradient only when applying something to the site?
  • Open: many changes. No height cap or scroll; the chat scrolls.
  • Open: text-only changes. A code-diff change (the panel's file row with CodeBlock) is not built here.
  • Heading is a h3 by default; headingLevel changes it. The card is meant to sit inside an agent ChatMessage; the Chat Panel's With Sample Content story shows it in a conversation (the With Changes and In Chat stories were removed on 2026-10-19).
  • Storybook: Proposals/Suggestion Card, new-pattern. Uses the local Icon wrapper (for check) and, in stories, the local Button and ChatMessage.
  • The content area is a slot, shown as a placeholder in Storybook (the same dashed children box the PDS stories use, via the story-only proposals/_story-helpers/SlotPlaceholder; its class has no pds- prefix so Annotate ignores it). Erika's call: what goes in the card is the screen's. SuggestionChange stays as an optional helper, shown in the WithChanges story; drop it if the before and after rows are not part of this proposal. Open: should the card still own spacing between its children, or only the frame?
  • padding: the same options as Panel (none, condensed, standard, spacious), passed straight to the Panel. Default is condensed (Panel's own default is standard) because the card sits in a chat column. The type comes from Panel's props, so the port follows if Panel's options change.
  • Footer wrapper for the live region. The empty role='status' region has to stay in the DOM so the accepted message is announced. As a direct child of the flex column body it added an invisible gap below the actions (extra bottom space). The actions and the status region now share one __footer block, so there is one gap, not two.
  • Fills its container, like a Panel. It is a Panel, so it is a block at full width and sets no width of its own; the chat column decides. The stories no longer cap the width, except InChat, which uses a narrow column (22rem) to imitate a chat panel.
  • Space above the buttons is --pds-spacing-m (16px), larger than the --pds-spacing-s (12px) between the other sections, so the decision row reads as its own area. Set with component variables --suggestion-card-gap and --suggestion-card-actions-gap; the footer margin makes up the difference. Tried on Erika's request; revert by setting the actions gap to s.
  • buttonSize: xs | s (default s) for the whole button set. The buttons are consumer content in a slot, so the card sets size on every Button in actions (looking inside fragments) with cloneElement, overriding any size on them; other elements are left alone. The stories no longer set sizes on their buttons. Heights: xs 28px, s 32px. Trade-off: the card inspects its slot, which is unusual for PDS; the port can instead pass the size down through context. Open: is m ever needed?
  • Title is Heading M: the PDS pds-heading-m utility class (16px, semibold, compact heading font, 130% line height) on the heading element, not custom font rules. Erika's call; it was 14px before. headingLevel still only changes the tag.
  • Description is Copy S: the PDS pds-copy-s utility class (14px, regular, default font, 150% line height) on the description, kept in the secondary text color. Erika's call. Before, it used the same values written out by hand.
  • In a chat, the card sits 20px (--pds-spacing-l) below the agent's text, the same as the gap between turns. ChatMessage doesn't space the blocks inside an agent message (the markdown renderer does), so the InChat story sets it with a FlexContainer.
  • buttonSize defaults to xs (Erika's call; it was s). s (32px) is the option for a roomier card; xs is 28px. It is a prop with a control in Storybook, so there is no separate story for it.
  • headingSize: m | l (default m), the PDS heading classes pds-heading-m (16px) and pds-heading-l (18px). Separate from headingLevel (the tag). PDS has no heading class smaller than M, and xl and up are too large for a card in a chat, so only these two.

Semantic type classes instead of font primitives (2026-10-19)

  • Decision: the root now takes the PDS semantic pds-copy-* class in its markup instead of declaring font-family, font-size and line-height from the typography primitives. Colors were already semantic tokens (foreground, surface, border, status, interactive).

  • Why: The designer asked for semantic tokens over primitives where they exist. The package has no semantic font token; the semantic layer is the pds-copy-* utility classes. Computed values are unchanged (Inter, 14px/21px at s).

  • Port impact: the pds-copy-* classes are !important, so a CSS rule cannot override size, weight or line height on that element. Components that size text through a CSS variable or override weight on the same element (ChainOfThought, Timeline, TimelineItem, LabeledDivider, ChatInput, IdentityBlock) keep the primitives for now.

  • Title to description gap is --pds-spacing-xs (2026-10-19). The title and description are in one __heading group with their own gap; the sections of the card (group, changes, footer) keep the --pds-spacing-s body gap. Title is Heading M and the description Copy S in the secondary color, as already decided (confirmed from the designer's spec). Port impact: adds the __heading wrapper div.

  • Optional feedback slot in the accepted state (2026-10-19, superseded by actionsEnd above). Shown at the end of the result line ("Changes applied" on the left, the slot on the right) only while state is accepted; it takes a FeedbackBar but any node. The card holds no feedback logic, as with actions. The accepted moment is when a person has seen the result, so it is where the question is most useful. The Feedback Bar is only the bare thumbs (the bordered prompt bar was dropped). Port impact: adds the feedback prop and a __feedback part; the footer is now a wrapping flex row.

  • actions and feedback became actionsStart and actionsEnd (2026-10-19). Two optional footer slots, named like Chat Input's: actionsStart at the left (the decision buttons, replacing actions) and actionsEnd at the right (replacing the accepted-only feedback). Both are optional; with neither, the footer takes no space (the empty status region stays in the DOM so the result is still announced). actionsStart shows while pending and is replaced by the result line once accepted or dismissed. actionsEnd shows in every state, so the screen decides what goes there in each (the Chat with an agent recipe shows the Feedback Bar there after Accept and Undo after Dismiss). buttonSize applies to the buttons in actionsStart only. The footer wraps on a narrow card. The designer's call: a button group at the left and, for example, a Feedback Bar at the right, without the card knowing what the right slot is. Port impact: the props are actionsStart, actionsEnd and buttonSize; there is no actions or feedback.

  • A dismissed state (2026-10-19). state is pending, accepted or dismissed. Dismissed hides the changes, so the card shrinks to a quiet record: the title and description stay, the title goes secondary, and actionsStart is replaced by the result line (statusLabel, for example "Dismissed") with a circleMinus in the secondary color (it was an xmark; changed at the designer's request), where accepted has a check in the success color. The screen puts an Undo button in actionsEnd to bring the card back. Removing the card would erase what the agent proposed, and a fully expanded card with live-looking buttons would be wrong after a dismiss; Undo beats a confirmation dialog for a low-risk action. One statusLabel carries the text for both finished states. The card does not know about Undo or focus: when the Dismiss button disappears, the screen moves focus to Undo, and back to Dismiss after an Undo (the stories use autoFocus). Undo stays available, with no time limit. Port impact: adds 'dismissed' to the state type, the --dismissed modifier and the circleMinus icon; Undo and any follow-up (such as an agent reply offering another approach) are the screen's.

  • No Feedback Bar on a dismissed card (2026-10-19). A dismiss is not "not good" (it can be right but not now, or already done by hand), it is already a signal the screen can record as an event, and after a dismiss nothing was applied, so there is no outcome to rate. Undo is the control for that moment. An explicit "bad suggestion" can still be collected while the card is pending, since actionsEnd shows in every state. If the team wants to know why people dismiss, a short reason follow-up ("Not relevant", "Wrong change", "Not now") is better than thumbs, and is a separate piece to design once the signal's use is known.

  • Open: a pending card the conversation moved on from. What happens when a newer suggestion replaces it without a decision? A "superseded" state may be needed.

Multiple choice type (2026-10-21)

  • Decision: type prop, changes (default, unchanged) or choice. The choice card has no overall padding: a padded header (title and description), a PDS PanelList of rows (the children), and a padded footer with the same actionsStart / actionsEnd slots and states, divided by lines edge to edge, from the designer's wireframe. A new SuggestionChoice helper is one row (a PanelRow): a native radio or checkbox (selectionType) with a label and optional description, the whole row being the label. Radios share a name; the screen owns what is selected and what the footer button does. The list has role='group' labelled by the title. A dismissed choice card hides the rows like the changes card.
  • Why: the designer wants a multiple choice suggestion, built on a panel list, with a radio or checkbox per row. One component with a type keeps the header, footer and states shared. Assumptions to confirm: row content is a slot (so the screen can put anything in a row; SuggestionChoice is a helper), and type on the card rather than a separate component.
  • Port impact: adds type, the --choice modifier and SuggestionChoice; uses the package's PanelList and PanelRow. Checkbox rows use the PDS Checkbox (so the box and check match PDS). Radio rows are a native input in a label carrying the package's pds-radio-group__option class, which styles the radio, because PDS RadioGroup renders a whole group and cannot be one row; the port may want a standalone PDS Radio. padding does not apply to choice.
  • Open: whether selecting should also report to the card (for example to disable the confirm button until something is picked), or stay the screen's.
  • The choice row's description is Copy XS (pds-copy-xs, 12px), the label stays Copy S medium (2026-10-21, designer's call).
  • The choice card's title to description gap stays --pds-spacing-xs (10px), the same as the default card (2026-10-21; 6px was tried and put back at the designer's request).

Before and after as a stacked diff (2026-10-21)

  • Decision: SuggestionChange is now the field name over one bordered block with two lines, like a text diff: the old value on a critical-tinted line with a minus icon, the new value on a success-tinted line with a plus icon (medium weight). The words "Before" and "After" (beforeLabel, afterLabel) stay as required props but are visually hidden, so screen readers still hear them and the meaning no longer depends on color alone (the minus and plus carry it too). It replaces the two columns with uppercase headings, which the designer found ugly and which wasted width on short values.
  • Why: a stacked diff reads top to bottom, gives long text the full width, and matches the diff look used elsewhere (code diffs, version control).
  • Port impact: replaces the __values / __value* classes with __diff, __line*, __marker, __text; uses the minus and plus icons and the visually-hidden class. The two label props are now screen-reader text only.

An "Other" option with a text field (2026-10-21)

  • Decision: SuggestionChoice takes children, shown under the label and lined up with it (an indent of one control plus its gap, from tokens). An "Other" option is a normal row whose child is a PDS TextInput (label hidden, still required for screen readers). The card knows nothing about it: the screen decides whether the field is enabled only when Other is selected, and the stories select Other when the field is focused.
  • Why: the designer's reference shows a radio labelled Other with an input under it. Keeping it a slot means the same works for a checkbox row.
  • Port impact: adds the children prop and __extra. The indent assumes the radio or checkbox is 16px (--pds-spacing-m) wide.

Regenerate and browse the sets: a recipe (2026-10-21)

  • Decision: the retry flow (a Regenerate button in actionsStart, the PDS Compact Pagination in actionsEnd to browse earlier sets) is the recipe Recipes/Regenerate a suggestion (proposals/RegenerateSuggestion/), not a story here. The card needed no change for it.

The diff block is the Content Diff component (2026-10-21)

  • Decision: the stacked before and after moved out of SuggestionChange into its own component, ContentDiff. SuggestionChange is now the field name above a ContentDiff, with the same props (afterLabel and beforeLabel are still the screen-reader names).
  • Port impact: SuggestionCard imports ContentDiff, so it ports after it.