Skip to content

refactor(prompt-field): use swc-action-button for composer and artifact buttons - #6630

Open
rubencarvalho wants to merge 98 commits into
mainfrom
ruben/refactor-prompt-field-swc-buttons
Open

refactor(prompt-field): use swc-action-button for composer and artifact buttons#6630
rubencarvalho wants to merge 98 commits into
mainfrom
ruben/refactor-prompt-field-swc-buttons

Conversation

@rubencarvalho

@rubencarvalho rubencarvalho commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Description

Replaces the five hand-rolled <button>s in the prompt-field pattern with swc-action-button: upload, send, stop, the two artifact scroll chevrons, and the upload-artifact close button. Icons move to the icon slot, aria-labelaccessible-label, and the bespoke button CSS is dropped in favor of the component's, re-themed only where the AI treatment needs it.

Based on main (the AI brand treatment has since merged); net −81 lines.

What changed

Button appearance

  • send / stop: circle (border-radius: full), neutral / gray-900 fill, gray-25 icon
  • upload: quiet; static content/hover colors via light-dark() (black on the light card, white on the dark card) so the icon matches the themed surface, including the generating status indicator
  • send disabled: auto-contrast tint instead of the flat Spectrum disabled fill
  • chevrons: default action-button circle; roving focus managed via aria-disabled + tabindex
  • dismiss: 20px round circle, self-sized from a single --swc-upload-artifact-dismiss-visual-size (icon-only, so edge-to-visual-only is pinned so width tracks height and it stays square)

Focus

  • All composer focus rings (buttons, artifact tiles, and dismiss buttons) are theme-adaptive black on light / white on dark via light-dark(), instead of the default accent, scoped to the prompt field; standalone upload-artifact keeps its own default ring
  • When keyboard paging reaches an end and the focused chevron becomes aria-disabled (and hidden), focus moves to the strip's active tile rather than being stranded on the now-hidden control
  • Restored the strip's Tab / Shift+Tab navigation for the new delegated-focus buttons (the deep active element is the action-button's internal <button>, so it is mapped back to the host before the Tab comparisons)

Layout and scrolling

  • Button-adjacent layout (text-group padding, collapsed row height, artifact fade plateau, scroll-padding) reads component-height-100 directly instead of the old --swc-prompt-field-*-size custom properties, which the buttons stopped honoring once they became fixed-size action-buttons
  • Artifact chevron paging scrolls the snap-aligned page-start tile into view (edge-clamped) with scroll-snap-type: inline proximity, incorporating the fix from the closed fix(prompt-field): scroll artifact paging to the snap-aligned target tile #6616

Verification

Rendered and measured every button in light + dark mode (fills, icon colors, shapes, dismiss sizing on card + media); the static colors and focus rings flip with the card. Chevrons coexist with the strip's edge-fade mask. All prompt-field and upload-artifact play-function tests pass; VRT forced-state coverage now targets the action-button's internal element so hover/focus rows are meaningful again.

Accessibility testing checklist

  • Keyboard: Tab through upload → textarea → send/stop; with artifacts, Tab into the strip (chevrons, thumbnails, close buttons), Arrow-key the roving thumbnail; scroll to an end with a chevron and confirm focus lands on a visible tile (not the hidden chevron); black/white focus rings visible throughout in both themes.
  • Screen reader: each button announces its accessible-label (Add attachment, Send, Stop generating, Show previous/more attachments, Remove attachment); no duplicate or missing names.

rubencarvalho and others added 30 commits August 6, 2026 15:28
…ition

Adds an `expanded` boolean property to `swc-prompt-field`, matching the
Figma design's collapsed/expanded variant naming. Defaults to the
single-line collapsed pill (send button inline with the textarea);
`expanded` renders the taller layout with a separate action bar
(upload button + send/stop button on their own row).

Also fixes a rendering bug uncovered while implementing this: the
collapsed pill's border-radius used `corner-radius-full` (50%), which
renders an ellipse on a non-square box instead of a stadium shape.
Radius is now derived from half of the pill's own fixed block-size,
mirroring the existing fix in linear-progress-base.css.

The toggle between layouts is animated: box shape (border-radius,
padding, gap) transitions directly, and the action bar's height
animates via the calc-size() progressive-enhancement pattern already
used in accordion-item.css. The send/stop button crossfades between
its inline and action-bar positions; the inactive copy is marked
inert + aria-hidden, following the same pattern as AccordionItem's
collapsed content.
…duplicated+hidden

Replaces the duplicate-button crossfade (one inline copy, one action-bar
copy, inactive one marked inert/aria-hidden) with a single conditionally
rendered button per layout. Simpler, and removes the class of bugs the
duplication caused: ambiguous .swc-PromptField-send/-stop queries,
inert-vs-focus interactions, and the compensating negative-margin hack
needed to cancel the row's gap around the crossfading copy.

The box shape and action bar height still animate on expand/collapse;
only the button itself now appears/disappears instantly rather than
fading, since there's no second instance to fade into.
…om property

Adds --swc-prompt-field-max-block-size (default 40vh) to the textarea's
max-block-size, combined with the existing max-rows cap via min() so
whichever is smaller wins. Guards against max-rows worth of text
overflowing the viewport on small/embedded surfaces; textareas already
scroll internally once content exceeds this, so no extra overflow rule
is needed.
Flips the boolean property from expanded (opt-in) to collapsed
(opt-in): the taller multiline card with the action bar is now the
default, unmarked layout, and the single-line row is the opt-in
collapsed layout via the new collapsed attribute.

All CSS state selectors, template conditionals, and animations are
inverted accordingly, along with tests, stories, and docs. Verified
live: the default (no attribute) now renders the expanded tree/layout,
collapsed opts into the single-line row, and the animated transition
still works in both directions.

BREAKING CHANGE: the expanded attribute is removed; consumers relying
on the previous expanded-opt-in, collapsed-by-default behavior must
add the collapsed attribute to keep the single-line layout.
…t verbosity

Extracts the action-bar block into _renderActionBar(), matching the
existing _renderStatusIcon() pattern, with a one-line comment
explaining why it stays always-mounted (calc-size height animation)
instead of conditionally rendered like the send/stop button.

Also trims several multi-line JSDoc/CSS comments down to one line
each; the removed detail was better suited to prose than inline code
comments.
Matches the Figma reference, which uses the same gap in both the
collapsed and expanded states; the input row previously widened to
16px by default, a leftover from an earlier iteration.
…ing API

Restructure controls into a shared icon+text-group wrapper used by both
collapsed and expanded layouts for animation consistency, fix collapsed
mode to scroll horizontally like a single-line input instead of clipping
wrapped lines, bump textarea type scale to font-size-200/line-height-200,
and drop the min-rows/max-rows API in favor of the 40vh max-height cap,
matching the reference implementation's sizing model.
…d-expanded' into ruben/feat-prompt-field-collapsed-expanded
…radius

The box-height and upload-button-width transitions animated independently
of the discrete single-line/two-row layout switch, which is not
interpolatable without flicker. The result was a visible desync: content
snapped to its final position instantly while the box/button sizes
lagged behind, producing an empty gap or a missing button mid-transition.
Drop the transitions so the layout switch is instant and consistent, and
use a fixed 24px radius (no matching token exists) for both layouts.
…tion

Explore a CSS-only alternative to the FLIP branch. calc-size(auto, size)
does not smoothly animate a flex-wrap-driven auto height in this engine
(verified in isolation with a minimal repro outside the component), so
switch .swc-PromptField-controls from flex-wrap to a 2-row CSS grid and
animate grid-template-rows between 0fr and 1fr instead, which does
interpolate smoothly. The box itself needs no height transition: it's a
plain flex column sized to the grid's content, so it naturally follows
every frame.

This also lets the send/stop button span both grid rows and center
itself on their combined height, so it re-centers automatically as row 2
grows/shrinks instead of needing a discrete row reassignment or a
JS-driven position correction. Removes the now-unnecessary line-break
marker element and the max-inline-size-based hiding on the upload
button, since the row's own height animation handles both.

Gotcha found along the way: padding-block-start on the row-2 grid item
(an overflow: hidden flex/grid item) still counts toward its own minimum
size and prevents the track from ever reaching a true 0fr; moving that
spacing to a margin on the child fixed it.
…transition

Row 1's min-block-size (24px) was shorter than the send/stop button (32px)
spanning into it, forcing the grid to reserve a few invisible px in row 2
to satisfy that minimum even when collapsed, pushing row 1's content off
its own vertical center. Match row 1's min-block-size to the button's own
height instead, and move the row-1/row-2 gap from a margin on the upload
button to .controls's own row-gap so it collapses to 0 in sync with the
grid-template-rows animation rather than leaving a fixed leftover gap.
Also switch the send/stop button from centering across the full span to
aligning with its bottom edge, matching the upload button's own position
when expanded instead of sitting visibly higher. Bumps the shared spike
transition duration from 160ms to 240ms; 160ms read as too quick.
Row 1 was auto-sized; an auto grid track adjacent to one animating via
grid-template-rows fr briefly overshoots its resolved size mid-transition
in this engine, confirmed via an isolated repro completely independent of
the send/stop button's grid placement (ruled that out first, since it was
the earlier suspect). The overshoot stretched row 1's content taller than
its final 32px and back, which read as the icon/text bobbing down and
back up. A fixed 32px track has nothing to recompute mid-transition.
…ments

Replace the custom --swc-prompt-field-spike-duration property with
token("animation-duration-500") (250ms, closest match to the 240ms this
was tuned to), and tie row 1's fixed height and the send/stop button's
size together via the existing --swc-prompt-field-send-block-size custom
property instead of two separate hardcoded 32px literals. No matching
token exists for the cubic-bezier(0.8, 0, 0.2, 1) easing (a deliberate,
steeper curve than animation-ease-in-out), so it stays a literal. Also
condense several multi-line comments down to one line each.
…edent

animation-duration-500 (250ms) doesn't match any existing block-size/
height reveal transition in the codebase: accordion-item.css uses
animation-duration-100 (130ms) and message-sources.css/response-status.css
use animation-duration-200 (160ms) for the same kind of animation. Switch
to animation-duration-200 to match the closer, pattern-level precedent.
- Re-add min-rows/max-rows as optional properties (no default): unset,
  only --swc-prompt-field-max-block-size caps textarea growth.
- max-block-size now resolves against a sized ancestor's container query
  block size (40cqb) when one exists (e.g. a chat panel with a fixed
  height), falling back to 40vh otherwise. Fixes growth capping against
  an unrelated viewport (verified against the Storybook preview iframe).
- Fix a regression from the collapse/expand animation work: text-group
  was locked to a fixed height to stop icon/text jitter during the
  transition, which also silently clipped multi-line textarea growth
  instead of growing the card. Restructured so text-group stays in
  plain flex flow (grows freely) while only leading-actions' own reveal
  animates, in an isolated single-row grid; the send/stop button is
  anchored via absolute positioning instead of spanning grid rows.
- Fix leading-actions' 0fr grid row not collapsing to a true 0 height:
  overflow/min-block-size need to live on the grid item (a new
  leading-actions-row wrapper), not the grid container itself.
- Pin the status icon to the top of text-group so it doesn't drift
  toward the vertical center as the textarea grows to multiple lines.
- Swap the placeholder three-dots icon for swc-ui-icon's asterisk,
  which reads more like an actual AI indicator; remove the now-unused
  ThreeDotsIcon export and its orphaned three-dots.svg reference asset.
Clarify that 40cqb resolves against a consumer-provided ancestor with
container-type: size, not the prompt field's own box (which would be a
circular reference), and show the wrapper needed to opt in.
Pair leading-actions' existing height reveal with an opacity transition
on the same timing, so the upload button fades out/in alongside the
height collapse instead of just being clipped away.
Give opacity a shorter duration than grid-template-rows, so it reaches
0 before the height finishes collapsing instead of exactly matching it.
Collapsed no longer clamps to a single line with horizontal scroll;
it now wraps and grows like the expanded layout, just starting more
compact. Also clarifies why the container-query max-height fallback
is safe (plain size query, not a style() query).
…ument 40cqb via custom property

The @container block only flipped the max-block-size default to 40cqb
when a consumer wrapped the field in a container-type: size ancestor.
Nothing in the repo triggered it, and it duplicated the
--swc-prompt-field-max-block-size escape hatch that already lets a
consumer set 40cqb themselves. Remove the block and document the
preferred container-relative cap via the custom property.
…-variant colors

Adds a variant property plus the outer ring, hue-sweep, gloss, and inset-shadow
layers behind the card, with real brand-derived OKLCH colors wired for
variant="subtle" across idle/hover/focus/generating states. Balanced/prominent
remain placeholder colors pending their own real-color pass.
…ants

Mirrors the other components' forced-colors coverage: captures the
opaque card boundary the treatment falls back to when forced-colors
drops the gradients.
The VRT meta sets `component` but supplies no argTypes, so Storybook's
default extractor generates controls from the CEM and, unlike
getStorybookHelpers, does not drop private members. That surfaced the
component's private @State in the dev-build Controls tab. VRT stories are
snapshot-only, so disable the controls panel.
The leading-actions row uses overflow to clip the collapse animation, and
Firefox clips the upload button's focus ring along with it. Switch to
overflow: clip with an overflow-clip-margin sized to the focus ring so the
ring paints in full; the collapsed state is already opacity: 0, so nothing
bleeds during the animation.
The card hard-sets cursor: text for the click-to-focus affordance, so a
disabled field still showed the text cursor over the card area. Override it
to default when disabled, matching native disabled input behavior.
@changeset-bot

changeset-bot Bot commented Aug 17, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: c59579f

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

📚 Branch Preview Links

🔍 Gen1 Visual Regression Test Results

When a visual regression test fails (or has previously failed while working on this branch), its results can be found in the following URLs:

Deployed to Azure Blob Storage: pr-6630

If the changes are expected, update the current_golden_images_cache hash in the circleci config to accept the new images. Instructions are included in that file.
If the changes are unexpected, you can investigate the cause of the differences and update the code accordingly.

Hovering a disabled field still animated the outer ring, since the hover
rules are not gated on disabled. Set pointer-events: none on the card so no
hover treatment fires, and move the default cursor to the host (the card no
longer receives pointer input). Matches native disabled input behavior. The
legal footer sits outside the card, so its links stay interactive.
Drives coverage through createPermutations grouped into rows (per the VRT
skill). One Permutations story now covers, in light/ltr and dark/rtl:
variant x generating, text length x expanded/collapsed layout, disabled, and
the card/media artifact strips including an overflowing set that exercises
the scroll chevrons. Fields render at a roomy width, with long prompts and
the overflowing strip constrained so wrapping and scrolling still trigger.

Adds two forced-state rows (hover, focus-visible) that put every action
button, upload, send, and both artifact-scroll chevrons, into the same state
at once via a forcePseudoState play, so all their colors can be compared
ahead of the swc-button swap. The strip overflows so the chevrons render, and
the play waits for them before forcing.
The card wash, outer ring, and ambient hue-sweep are anchored to the physical
bottom-right, so in RTL the glow stays pinned while the composer layout flips.
Hoist the horizontal anchor of the wash and ring into custom properties and
flip them (plus scaleX(-1) on the decorative hue-sweep ::before) under
:host(:dir(rtl)), so the glow follows the mirrored layout.
… solid overlay

The edge fades painted a background-layer-2-color gradient over the tiles, which
read as a grey smudge on the branded card wash. Mask the scroll container so the
tiles fade to transparent at each scrollable edge and the wash shows through,
adapting to any background for free.

The fade stays fully transparent across the chevron zone (a plateau, so no tile
peeks under the button), then softens to opaque over the remaining distance.

- Drop the two overlay fade <div>s, the fade-solid var, and their LTR/RTL rules
- Drop the chevrons' opaque ::before mask (tiles now fade before reaching them)
- Edges driven by the existing has-scroll-prev/next classes; RTL flips the
  physical mask direction
…tifact buttons

Replace the five hand-rolled <button>s with swc-action-button: upload, send,
stop, the artifact scroll chevrons, and the upload-artifact close button. Icons
move to the icon slot, aria-label becomes accessible-label, and the bespoke
button appearance is dropped in favor of the component's, re-theming only what
the design needs via its custom properties:

- send/stop: full corner radius (circle), neutral / gray-900 fill, white icon
- upload: quiet default with the AI auto-contrast tint on hover/focus/down
- send disabled: auto-contrast tint instead of the flat disabled fill
- chevrons: default gray fill, kept aria-disabled + tabindex roving focus
- dismiss: 20px round gray circle sized via min-block-size + padding

Chevron focus-management querySelector types change from HTMLButtonElement to
HTMLElement since the elements are now custom elements.
@rubencarvalho
rubencarvalho force-pushed the ruben/refactor-prompt-field-swc-buttons branch from da29dcd to 858fd70 Compare August 18, 2026 10:35
Set the quiet upload button's content/hover colors and the status-icon color
with light-dark() so they flip black/white with the themed card, keeping the
upload icon and status indicator visually matched.
Base automatically changed from ruben/feat-prompt-field-ai-branding-v3 to main August 19, 2026 09:16
…-field-swc-buttons

# Conflicts:
#	2nd-gen/packages/swc/patterns/conversational-ai/prompt-field/PromptField.ts
#	2nd-gen/packages/swc/patterns/conversational-ai/prompt-field/prompt-field.css
The dismiss button is an icon-only swc-action-button, so its width comes from
edge-to-visual-only, not edge-to-text; set it equal to the vertical inset so
the button stays square (was 22x20). Update the size-override test to drive the
real --swc-upload-artifact-dismiss-visual-size prop and assert a square, and
fix the dismiss assertions to the action-button API (accessible-label,
delegated focus).
The dismiss and scroll chevrons are now swc-action-buttons that delegate focus
to an internal button, so getActiveElement() returns that button, not the host.
Map it back to the action-button host before the strip's Tab comparisons so
Shift+Tab/Tab from the Close button and chevrons is intercepted again. Update
the focus tests to resolve the same delegation and assert the chevron name via
accessible-label.
…ght token

Send/stop and scroll chevrons now render at swc-action-button's default size and
no longer read the old --swc-prompt-field-send-*/--swc-prompt-field-artifact-scroll-button-*
custom properties. The surrounding layout (text-group padding, collapsed row
min-block-size, artifact fade plateau, scroll-padding) still read them, so a
consumer override would reserve space around a button that never grew. Read
token("component-height-100") directly instead: value-for-value identical to the
action-button default, without pretending to be a live customization point.
…tile

Port of the fix from the closed PR #6616: page by scrolling the computed
page-start tile into view (snap-aligned, edge-clamped) instead of a raw
scrollBy, and use proximity snap so changing the chevrons' scroll-padding
doesn't force a re-snap yank.
…ernal element

The composer/scroll buttons are swc-action-buttons that style hover/focus on
their internal .swc-ActionButton, so the VRT forced-state pass must target that
element; forcing on the prompt-field host was a no-op after the refactor, so the
Buttons hover/focus rows rendered default state. Also refresh the a11y/scroll
docs to describe accessible-label and drop the stale explicit chevron sizing.
…ed chevron

Give the composer buttons (upload, send, stop, scroll chevrons) a theme-adaptive
focus ring via light-dark(static-black, static-white) instead of the blue accent.

When keyboard paging reaches an end and the focused chevron becomes aria-disabled
(and opacity 0), move focus to the strip's active tile instead of stranding it on
the now-hidden control; update the a11y docs and focus-order test to match.
Route the dismiss button's focus ring through the tile's
--swc-upload-artifact-focus-indicator-color hook so both share one control, then
override that hook on the slotted artifacts from the prompt field with
light-dark(static-black, static-white). Every focus ring inside the composer
(buttons, tiles, dismiss) is now the themed black/white; standalone
upload-artifact keeps its own default.
@rubencarvalho rubencarvalho added Status:Ready for review PR ready for review or re-review. gen2 These issues or PRs map to our 2nd generation work to modernizing infrastructure. High priority PR review PR is a high priority and should be reviewed ASAP run_vrt Triggers the Chromatic VRT run for 2nd-gen Component:ConversationalAI labels Aug 19, 2026
@rubencarvalho
rubencarvalho marked this pull request as ready for review August 19, 2026 12:23
@rubencarvalho
rubencarvalho requested a review from a team as a code owner August 19, 2026 12:23
…gnostic

A single page-back lands exactly at scrollLeft 0 in Chromium but slightly short
in Firefox/WebKit, so prev's disabled state isn't deterministic; assert only the
engine-independent outcome (focus ends up on a tile in the newly displayed set),
Tab-ing off prev first when it is still enabled. Also reword the VRT forced-state
comment to stand on its own.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Component:ConversationalAI gen2 These issues or PRs map to our 2nd generation work to modernizing infrastructure. High priority PR review PR is a high priority and should be reviewed ASAP run_vrt Triggers the Chromatic VRT run for 2nd-gen Status:Ready for review PR ready for review or re-review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant