diff --git a/2nd-gen/packages/swc/.storybook/preview.ts b/2nd-gen/packages/swc/.storybook/preview.ts
index 73e4673e0ab..37e79dcd670 100644
--- a/2nd-gen/packages/swc/.storybook/preview.ts
+++ b/2nd-gen/packages/swc/.storybook/preview.ts
@@ -330,7 +330,10 @@ const preview = {
'Rendering and styling migration analysis',
],
'Action button',
- ['Rendering and styling migration analysis'],
+ [
+ 'Accessibility migration analysis',
+ 'Rendering and styling migration analysis',
+ ],
'Action group',
['Rendering and styling migration analysis'],
'Action menu',
@@ -360,9 +363,14 @@ const preview = {
'Rendering and styling migration analysis',
],
'Button group',
- ['Rendering and styling migration analysis'],
+ [
+ 'Accessibility migration analysis',
+ 'Rendering and styling migration analysis',
+ ],
'Checkbox',
['Rendering and styling migration analysis'],
+ 'Close button',
+ ['Accessibility migration analysis'],
'Color field',
['Rendering and styling migration analysis'],
'Color loupe',
@@ -397,6 +405,7 @@ const preview = {
'Link',
[
'Accessibility migration analysis',
+ 'Migration plan',
'Rendering and styling migration analysis',
],
'Menu',
diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/multi-artifact-upload-rfc.md b/2nd-gen/packages/swc/patterns/conversational-ai/multi-artifact-upload-rfc.md
new file mode 100644
index 00000000000..e0e5f46482e
--- /dev/null
+++ b/2nd-gen/packages/swc/patterns/conversational-ai/multi-artifact-upload-rfc.md
@@ -0,0 +1,178 @@
+# Conversational AI multi-artifact plan (working RFC)
+
+## What this is
+
+Working plan for multi-artifact behavior in Conversational AI patterns.
+
+This doc is intentionally practical:
+
+- first half = overview + plan
+- second half = concrete TODOs by component/track
+
+## Scope
+
+In scope now:
+
+- composer/prompt-field artifacts (pre-submit)
+- user-message/thread artifacts (post-submit)
+- component API updates needed for both
+- accessibility behavior definition for both
+
+Out of scope now:
+
+- drag-and-drop behavior (follow-up)
+- backend upload/storage/validation logic
+- file processing and metadata editing
+
+## Current snapshot (short)
+
+- `swc-prompt-field` already supports multiple slotted artifacts and mixed types.
+- `swc-upload-artifact` already supports `card` and `media` plus dismiss event.
+- current system does not model per-artifact upload states yet.
+- `swc-user-message` supports only single-mode rendering via `type="copy|card|media"`.
+- current `swc-user-message` API cannot represent mixed multi-artifact + text composition in one message.
+
+## User-message API direction (intentional breaking change)
+
+Current `swc-user-message` is mode-based (`copy|card|media`) and assumes one visual message shape at a time.
+
+That is not a scalable fit for target behavior, where one submitted user message can include:
+
+- multiple artifacts
+- mixed artifact types
+- optional text in the same message
+
+Decision for this RFC track:
+
+- we will treat `swc-user-message` redesign as an intentional breaking API change
+- move from mode-based API to composition-based API
+
+Target shape:
+
+- `swc-user-message` is a message container, not a single-type switch
+- message can render:
+ - artifact region (0..n)
+ - text region (0..1)
+- ordering/layout rules are defined by the component contract
+
+Migration direction:
+
+- replace `type="copy|card|media"` usage with explicit content composition
+- document before/after examples in stories and migration notes
+- keep visual parity where possible while removing mode coupling
+
+## Ownership model
+
+`swc-prompt-field`:
+
+- composer layout, actions, and artifact region behavior
+- upload trigger event surface
+- optional max-artifact UI constraints
+
+`swc-upload-artifact`:
+
+- artifact visuals/variants
+- artifact state rendering (loading/success/error)
+- per-artifact actions and state semantics
+
+`swc-user-message`:
+
+- post-submit artifact + text presentation contract
+- message-level artifact layout/overflow/a11y behavior
+
+Consumer app:
+
+- file acquisition (picker now, drag/drop later)
+- file type and size validation
+- upload transport and persistence
+- mapping real upload status into component props/slots
+
+## Delivery plan
+
+1. Lock contracts
+
+- finalize API names and behavior for prompt-field, upload-artifact, user-message touchpoints
+
+2. Define interaction and accessibility behavior
+
+- keyboard movement model
+- screen-reader announcements
+- state transitions and announcements
+
+3. Implement component updates
+
+- add non-breaking API additions
+- add stories for new states/layouts
+- add tests for behavior and a11y
+
+4. Follow-up
+
+- drag-and-drop support with same ownership split
+
+---
+
+## TODOs: `swc-prompt-field`
+
+- [ ] Add `max-artifacts` API (name can still change if team wants)
+- [ ] Define behavior when limit is reached:
+ - [ ] upload affordance disabled/hidden rule
+ - [ ] event emitted when user tries to add at limit (name TBD)
+- [ ] Define artifact region keyboard behavior:
+ - [ ] how focus enters artifact region
+ - [ ] arrow key movement rules
+ - [ ] how focus exits to next control
+- [ ] Confirm overflow strategy for composer artifact strip:
+ - [ ] wrap only
+ - [ ] or capped row + overflow summary
+- [ ] Update stories for all intended composer artifact combinations
+- [ ] Add/extend tests for keyboard + a11y expectations
+
+## TODOs: `swc-upload-artifact`
+
+- [ ] Add `state` API:
+ - [ ] `idle`
+ - [ ] `uploading`
+ - [ ] `success`
+ - [ ] `error`
+- [ ] Add `error-reason` API shape (enum/string decision)
+- [ ] Add new additive variant for thumbnail + tag (name TBD)
+- [ ] Define optional retry interaction for error state:
+ - [ ] slot vs event vs both
+- [ ] Define accessible naming requirements per variant
+- [ ] Add screen-reader state messaging behavior
+- [ ] Add stories for each state x variant matrix we commit to
+- [ ] Add tests for dismiss/retry/state/a11y behavior
+
+## TODOs: `swc-user-message` (post-submit artifacts)
+
+- [ ] Replace mode-based API (`type="copy|card|media"`) with composition-based API
+- [ ] Define API contract for submitted user message content:
+ - [ ] artifact region contract (0..n)
+ - [ ] text region contract (0..1)
+ - [ ] any props/events needed for layout or state
+- [ ] Define artifact + text composition rules in one message bubble
+- [ ] Define layout rules for multiple and mixed artifact types
+- [ ] Define overflow behavior for message-level artifact groups
+- [ ] Define keyboard and screen-reader behavior for message artifacts
+- [ ] Define state handoff expectations from composer to submitted message
+- [ ] Add stories for key message layouts and states
+- [ ] Add tests for layout behavior and a11y behavior
+- [ ] Add migration notes from old `type` API to new composition API
+
+## TODOs: cross-cutting decisions
+
+- [ ] Finalize naming:
+ - [ ] `max-artifacts`
+ - [ ] artifact variant names
+ - [ ] any new events
+- [ ] Finalize what counts as component-owned vs consumer-owned behavior
+- [ ] Document explicit non-goals in spike PR description
+- [ ] Ensure docs clearly state: drag-and-drop is follow-up only
+
+## PR notes (spike)
+
+Must explicitly say:
+
+- this spike includes composer + user-message artifact planning work
+- drag-and-drop is intentionally deferred
+- upload transport/validation remains consumer-owned
diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/prompt-field/prompt-field.css b/2nd-gen/packages/swc/patterns/conversational-ai/prompt-field/prompt-field.css
index 177c1932ed2..1b82125b083 100644
--- a/2nd-gen/packages/swc/patterns/conversational-ai/prompt-field/prompt-field.css
+++ b/2nd-gen/packages/swc/patterns/conversational-ai/prompt-field/prompt-field.css
@@ -106,10 +106,10 @@
/* Prompt-field media artifact uses 68×68 preview tile. */
.swc-PromptField-artifacts > slot::slotted([type="media"]) {
- inline-size: var(--swc-prompt-field-artifact-media-inline-size, 68px);
- min-inline-size: var(--swc-prompt-field-artifact-media-min-inline-size, 68px);
- block-size: var(--swc-prompt-field-artifact-media-block-size, 68px);
- min-block-size: var(--swc-prompt-field-artifact-media-min-block-size, 68px);
+ inline-size: var(--swc-prompt-field-artifact-media-inline-size, 64px);
+ min-inline-size: var(--swc-prompt-field-artifact-media-min-inline-size, 64px);
+ block-size: var(--swc-prompt-field-artifact-media-block-size, 64px);
+ min-block-size: var(--swc-prompt-field-artifact-media-min-block-size, 64px);
aspect-ratio: 1 / 1;
}
@@ -185,8 +185,8 @@
display: flex;
align-items: center;
justify-content: center;
- inline-size: var(--swc-prompt-field-upload-inline-size, 32px);
- block-size: var(--swc-prompt-field-upload-block-size, 32px);
+ inline-size: var(--swc-prompt-field-upload-inline-size, 44px);
+ block-size: var(--swc-prompt-field-upload-block-size, 44px);
padding: 0;
color: token("gray-800");
background: transparent;
@@ -215,8 +215,8 @@
display: flex;
align-items: center;
justify-content: center;
- inline-size: var(--swc-prompt-field-send-inline-size, 32px);
- block-size: var(--swc-prompt-field-send-block-size, 32px);
+ inline-size: var(--swc-prompt-field-send-inline-size, 44px);
+ block-size: var(--swc-prompt-field-send-block-size, 44px);
padding: 0;
color: token("gray-25");
background: token("neutral-background-color-default");
@@ -229,8 +229,8 @@
display: flex;
align-items: center;
justify-content: center;
- inline-size: var(--swc-prompt-field-stop-inline-size, 32px);
- block-size: var(--swc-prompt-field-stop-block-size, 32px);
+ inline-size: var(--swc-prompt-field-stop-inline-size, 44px);
+ block-size: var(--swc-prompt-field-stop-block-size, 44px);
padding: 0;
color: token("gray-25");
background: token("gray-900");
diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/prompt-field/stories/multi-artifact-demo.stories.ts b/2nd-gen/packages/swc/patterns/conversational-ai/prompt-field/stories/multi-artifact-demo.stories.ts
new file mode 100644
index 00000000000..5f16df6470f
--- /dev/null
+++ b/2nd-gen/packages/swc/patterns/conversational-ai/prompt-field/stories/multi-artifact-demo.stories.ts
@@ -0,0 +1,1971 @@
+/**
+ * Copyright 2026 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+
+import { html, LitElement, nothing, TemplateResult } from 'lit';
+import { customElement, query, state } from 'lit/decorators.js';
+import type { Meta, StoryObj as Story } from '@storybook/web-components';
+
+import '@adobe/spectrum-wc/components/icon/swc-icon.js';
+import '@adobe/spectrum-wc/components/illustrated-message/swc-illustrated-message.js';
+import '../../upload-artifact/index.js';
+import '../index.js';
+
+import {
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ ChevronUpIcon,
+ PlusIcon,
+} from '../../utils/icons/index.js';
+
+const defaultPlaceholder =
+ 'Ready to get started? Ask a question, share an idea, or add a task.';
+const demoPrompt =
+ 'Review all attached materials and generate a product launch strategy with timeline, risks, and budget allocation.';
+
+const artifactTileSizePx = 64;
+const artifactMaxVisibleRows = 2;
+
+const gradients = [
+ 'linear-gradient(135deg,#818cf8,#f43f5e)',
+ 'linear-gradient(135deg,#6366f1,#ec4899)',
+ 'linear-gradient(135deg,#0ea5e9,#22c55e)',
+ 'linear-gradient(135deg,#a855f7,#ef4444)',
+ 'linear-gradient(135deg,#14b8a6,#f97316)',
+ 'linear-gradient(135deg,#f472b6,#8b5cf6)',
+];
+
+/** Spectrum dropzone upload illustration (from 1st-gen dropzone stories). */
+const dropzoneUploadIllustration = html`
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+interface DemoArtifact {
+ id: string;
+ fileName: string;
+ kind: 'image' | 'document';
+ badge?: string;
+ thumbnailUrl?: string;
+ gradient: string;
+}
+
+const createSeedArtifacts = (count: number): DemoArtifact[] =>
+ Array.from({ length: count }, (_, index) => {
+ const isDocument = index % 5 === 4;
+ return {
+ id: `seed-${index}`,
+ fileName: isDocument
+ ? `Launch brief budget v${index + 1}.pdf`
+ : `Campaign still ${index + 1}.png`,
+ kind: isDocument ? 'document' : 'image',
+ badge: isDocument ? (index % 10 === 4 ? 'PDF' : 'Excel') : undefined,
+ gradient: gradients[index % gradients.length]!,
+ };
+ });
+
+const revokeArtifactUrls = (artifacts: DemoArtifact[]): void => {
+ for (const artifact of artifacts) {
+ if (artifact.thumbnailUrl) {
+ URL.revokeObjectURL(artifact.thumbnailUrl);
+ }
+ }
+};
+
+const createArtifactsFromFiles = (
+ files: File[],
+ startIndex: number
+): DemoArtifact[] =>
+ files.map((file, index) => {
+ const isImage =
+ file.type.startsWith('image/') ||
+ /\.(png|jpe?g|gif|webp|bmp|svg|avif)$/i.test(file.name);
+ const extension = file.name.split('.').pop()?.toUpperCase();
+ return {
+ id: `${crypto.randomUUID()}-${index}`,
+ fileName: file.name || 'Attachment',
+ kind: isImage ? 'image' : 'document',
+ badge: isImage ? undefined : extension?.slice(0, 4) || 'File',
+ thumbnailUrl: isImage ? URL.createObjectURL(file) : undefined,
+ gradient: gradients[(startIndex + index) % gradients.length]!,
+ } satisfies DemoArtifact;
+ });
+
+const getDraggedFiles = (event: DragEvent): File[] => {
+ const dataTransfer = event.dataTransfer;
+ if (!dataTransfer) {
+ return [];
+ }
+
+ return Array.from(dataTransfer.files ?? []);
+};
+
+const getDismissedArtifactId = (event: Event): string | null => {
+ const customEvent = event as CustomEvent<{ artifact?: HTMLElement }>;
+ const artifactHost =
+ customEvent.detail?.artifact ??
+ (event.target as HTMLElement | null)?.closest('[data-artifact-id]');
+
+ return artifactHost?.getAttribute('data-artifact-id') ?? null;
+};
+
+const renderArtifactTile = (artifact: DemoArtifact): TemplateResult => html`
+
+ ${artifact.thumbnailUrl
+ ? html`
+
+ `
+ : html`
+
+ `}
+ ${artifact.badge
+ ? html`
+ ${artifact.badge}
+ `
+ : nothing}
+
+`;
+
+const demoStyles = html`
+
+`;
+
+@customElement('swc-multi-artifact-scroll-demo')
+class MultiArtifactScrollDemo extends LitElement {
+ @state()
+ private artifacts: DemoArtifact[] = createSeedArtifacts(18);
+
+ @state()
+ private value = demoPrompt;
+
+ @state()
+ private readout =
+ 'Scroll with arrows or drag the strip. Use + to add files and dismiss tiles to remove them.';
+
+ @state()
+ private canScrollBack = false;
+
+ @state()
+ private canScrollForward = true;
+
+ @state()
+ private hasOverflow = false;
+
+ @state()
+ private thumbWidthPx = 0;
+
+ @state()
+ private thumbLeftPx = 3;
+
+ @query('.MultiArtifactDemo-scroll-track--horizontal')
+ private _scrollTrack!: HTMLElement;
+
+ @query('.MultiArtifactDemo-scrollbar--horizontal')
+ private _scrollbar!: HTMLElement;
+
+ @query('[data-file-input]')
+ private _fileInput!: HTMLInputElement;
+
+ protected override createRenderRoot(): this {
+ return this;
+ }
+
+ public override disconnectedCallback(): void {
+ revokeArtifactUrls(this.artifacts);
+ super.disconnectedCallback?.();
+ }
+
+ public override firstUpdated(): void {
+ this._syncScrollState();
+
+ const track = this._scrollTrack;
+ if (!track || typeof ResizeObserver === 'undefined') {
+ return;
+ }
+
+ const observer = new ResizeObserver(() => this._syncScrollState());
+ observer.observe(track);
+ }
+
+ private _handleArtifactDismiss(event: Event): void {
+ const artifactId = getDismissedArtifactId(event);
+ if (!artifactId) {
+ return;
+ }
+
+ const removed = this.artifacts.find(
+ (artifact) => artifact.id === artifactId
+ );
+ if (removed?.thumbnailUrl) {
+ URL.revokeObjectURL(removed.thumbnailUrl);
+ }
+
+ this.artifacts = this.artifacts.filter(
+ (artifact) => artifact.id !== artifactId
+ );
+ this.readout = `Removed ${removed?.fileName ?? 'artifact'}. ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'} remaining.`;
+ this.updateComplete.then(() => {
+ this._clampScrollPosition();
+ this._syncScrollState();
+ });
+ }
+
+ private _clampScrollPosition(): void {
+ const track = this._scrollTrack;
+ if (!track) {
+ return;
+ }
+
+ const maxScroll = Math.max(0, track.scrollWidth - track.clientWidth);
+ if (track.scrollLeft > maxScroll) {
+ track.scrollLeft = maxScroll;
+ }
+ }
+
+ private _handleUploadClick(event: Event): void {
+ event.preventDefault();
+ this._fileInput?.click();
+ }
+
+ private _handleFileChange(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ const files = Array.from(input.files ?? []);
+ if (!files.length) {
+ return;
+ }
+
+ const nextArtifacts = createArtifactsFromFiles(
+ files,
+ this.artifacts.length
+ );
+ this.artifacts = [...this.artifacts, ...nextArtifacts];
+ this.readout = `Added ${files.length} file${files.length === 1 ? '' : 's'}. ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'} total.`;
+ input.value = '';
+ this.updateComplete.then(() => {
+ this._scrollTrack?.scrollTo({
+ left: this._scrollTrack.scrollWidth,
+ behavior: 'smooth',
+ });
+ this._syncScrollState();
+ });
+ }
+
+ private _syncScrollState(): void {
+ const track = this._scrollTrack;
+ const scrollbar = this._scrollbar;
+ if (!track || !scrollbar) {
+ return;
+ }
+
+ const maxScroll = Math.max(0, track.scrollWidth - track.clientWidth);
+ this.hasOverflow = maxScroll > 0;
+ this.canScrollBack = track.scrollLeft > 1;
+ this.canScrollForward = track.scrollLeft < maxScroll - 1;
+
+ if (!this.hasOverflow) {
+ this.thumbWidthPx = 0;
+ this.thumbLeftPx = 3;
+ return;
+ }
+
+ const trackWidth = scrollbar.clientWidth;
+ const inset = 3;
+ const minThumbWidth = 48;
+ this.thumbWidthPx = Math.max(
+ minThumbWidth,
+ (track.clientWidth / track.scrollWidth) * trackWidth
+ );
+
+ const maxThumbLeft = Math.max(
+ inset,
+ trackWidth - this.thumbWidthPx - inset
+ );
+ this.thumbLeftPx =
+ maxScroll === 0
+ ? inset
+ : inset + (track.scrollLeft / maxScroll) * (maxThumbLeft - inset);
+ }
+
+ private _scrollBy(direction: -1 | 1): void {
+ const track = this._scrollTrack;
+ if (!track) {
+ return;
+ }
+
+ const tileStep = artifactTileSizePx + 8;
+ const visibleTiles = Math.max(1, Math.floor(track.clientWidth / tileStep));
+ const delta = direction * visibleTiles * tileStep;
+ const maxScroll = Math.max(0, track.scrollWidth - track.clientWidth);
+ const nextLeft = Math.min(maxScroll, Math.max(0, track.scrollLeft + delta));
+
+ track.scrollTo({ left: nextLeft, behavior: 'smooth' });
+ }
+
+ private _handleSubmit(): void {
+ this.readout = `Submitted prompt with ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'}.`;
+ }
+
+ protected override render(): TemplateResult {
+ return html`
+ ${demoStyles}
+
+
+
+
+
+
+
+ ${PlusIcon()}
+
+
+ ${ChevronUpIcon()}
+
+
+
+
+
+ Responses are generated using AI, and may be inaccurate. Check
+ before using.
+
+ AI User Guidelines
+
+
+
+
${this.readout}
+
+
+ `;
+ }
+}
+
+@customElement('swc-multi-artifact-vertical-scroll-demo')
+class MultiArtifactVerticalScrollDemo extends LitElement {
+ @state()
+ private artifacts: DemoArtifact[] = createSeedArtifacts(18);
+
+ @state()
+ private value = demoPrompt;
+
+ @state()
+ private readout =
+ 'Tiles wrap to two rows, then scroll vertically. Use + to add files and dismiss tiles to remove them.';
+
+ @state()
+ private canScrollBack = false;
+
+ @state()
+ private canScrollForward = true;
+
+ @state()
+ private hasOverflow = false;
+
+ @state()
+ private thumbHeightPx = 0;
+
+ @state()
+ private thumbTopPx = 3;
+
+ @query('.MultiArtifactDemo-scroll-track--vertical')
+ private _scrollTrack!: HTMLElement;
+
+ @query('.MultiArtifactDemo-scrollbar--vertical')
+ private _scrollbar!: HTMLElement;
+
+ @query('[data-file-input]')
+ private _fileInput!: HTMLInputElement;
+
+ protected override createRenderRoot(): this {
+ return this;
+ }
+
+ public override disconnectedCallback(): void {
+ revokeArtifactUrls(this.artifacts);
+ super.disconnectedCallback?.();
+ }
+
+ public override firstUpdated(): void {
+ this._syncScrollState();
+
+ const track = this._scrollTrack;
+ if (!track || typeof ResizeObserver === 'undefined') {
+ return;
+ }
+
+ const observer = new ResizeObserver(() => this._syncScrollState());
+ observer.observe(track);
+ }
+
+ private _handleArtifactDismiss(event: Event): void {
+ const artifactId = getDismissedArtifactId(event);
+ if (!artifactId) {
+ return;
+ }
+
+ const removed = this.artifacts.find(
+ (artifact) => artifact.id === artifactId
+ );
+ if (removed?.thumbnailUrl) {
+ URL.revokeObjectURL(removed.thumbnailUrl);
+ }
+
+ this.artifacts = this.artifacts.filter(
+ (artifact) => artifact.id !== artifactId
+ );
+ this.readout = `Removed ${removed?.fileName ?? 'artifact'}. ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'} remaining.`;
+ this.updateComplete.then(() => {
+ this._clampScrollPosition();
+ this._syncScrollState();
+ });
+ }
+
+ private _clampScrollPosition(): void {
+ const track = this._scrollTrack;
+ if (!track) {
+ return;
+ }
+
+ const maxScroll = Math.max(0, track.scrollHeight - track.clientHeight);
+ if (track.scrollTop > maxScroll) {
+ track.scrollTop = maxScroll;
+ }
+ }
+
+ private _handleUploadClick(event: Event): void {
+ event.preventDefault();
+ this._fileInput?.click();
+ }
+
+ private _handleFileChange(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ const files = Array.from(input.files ?? []);
+ if (!files.length) {
+ return;
+ }
+
+ const nextArtifacts = createArtifactsFromFiles(
+ files,
+ this.artifacts.length
+ );
+ this.artifacts = [...this.artifacts, ...nextArtifacts];
+ this.readout = `Added ${files.length} file${files.length === 1 ? '' : 's'}. ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'} total.`;
+ input.value = '';
+ this.updateComplete.then(() => {
+ this._scrollTrack?.scrollTo({
+ top: this._scrollTrack.scrollHeight,
+ behavior: 'smooth',
+ });
+ this._syncScrollState();
+ });
+ }
+
+ private _syncScrollState(): void {
+ const track = this._scrollTrack;
+ const scrollbar = this._scrollbar;
+ if (!track || !scrollbar) {
+ return;
+ }
+
+ const maxScroll = Math.max(0, track.scrollHeight - track.clientHeight);
+ this.hasOverflow = maxScroll > 0;
+ this.canScrollBack = track.scrollTop > 1;
+ this.canScrollForward = track.scrollTop < maxScroll - 1;
+
+ if (!this.hasOverflow) {
+ this.thumbHeightPx = 0;
+ this.thumbTopPx = 3;
+ return;
+ }
+
+ const scrollbarHeight = scrollbar.clientHeight;
+ const inset = 3;
+ const minThumbHeight = 48;
+ this.thumbHeightPx = Math.max(
+ minThumbHeight,
+ (track.clientHeight / track.scrollHeight) * scrollbarHeight
+ );
+
+ const maxThumbTop = Math.max(
+ inset,
+ scrollbarHeight - this.thumbHeightPx - inset
+ );
+ this.thumbTopPx =
+ maxScroll === 0
+ ? inset
+ : inset + (track.scrollTop / maxScroll) * (maxThumbTop - inset);
+ }
+
+ private _handleSubmit(): void {
+ this.readout = `Submitted prompt with ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'}.`;
+ }
+
+ protected override render(): TemplateResult {
+ return html`
+ ${demoStyles}
+
+
+
+
+
+
+
+ ${PlusIcon()}
+
+
+ ${ChevronUpIcon()}
+
+
+
+
+
+ Responses are generated using AI, and may be inaccurate. Check
+ before using.
+
+ AI User Guidelines
+
+
+
+
${this.readout}
+
+
+ `;
+ }
+}
+
+@customElement('swc-multi-artifact-view-more-demo')
+class MultiArtifactViewMoreDemo extends LitElement {
+ private static visibleCount = 8;
+
+ @state()
+ private artifacts: DemoArtifact[] = createSeedArtifacts(18);
+
+ @state()
+ private value = demoPrompt;
+
+ @state()
+ private readout =
+ 'Shows eight inline tiles, then View more for the rest. Use + to add files.';
+
+ @state()
+ private dialogOpen = false;
+
+ @query('.MultiArtifactDemo-view-more-dialog')
+ private _dialog!: HTMLDialogElement;
+
+ @query('.MultiArtifactDemo-view-more')
+ private _viewMoreButton!: HTMLButtonElement;
+
+ @query('[data-file-input]')
+ private _fileInput!: HTMLInputElement;
+
+ protected override createRenderRoot(): this {
+ return this;
+ }
+
+ public override disconnectedCallback(): void {
+ document.removeEventListener(
+ 'pointerdown',
+ this._backdropPointerDown,
+ true
+ );
+ if (this._dialog?.open) {
+ this._dialog.close();
+ }
+ revokeArtifactUrls(this.artifacts);
+ super.disconnectedCallback?.();
+ }
+
+ private _handleArtifactDismiss(event: Event): void {
+ const artifactId = getDismissedArtifactId(event);
+ if (!artifactId) {
+ return;
+ }
+
+ const removed = this.artifacts.find(
+ (artifact) => artifact.id === artifactId
+ );
+ if (removed?.thumbnailUrl) {
+ URL.revokeObjectURL(removed.thumbnailUrl);
+ }
+
+ this.artifacts = this.artifacts.filter(
+ (artifact) => artifact.id !== artifactId
+ );
+ this.readout = `Removed ${removed?.fileName ?? 'artifact'}. ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'} remaining.`;
+
+ if (this.artifacts.length <= MultiArtifactViewMoreDemo.visibleCount) {
+ this._closeViewMoreDialog();
+ } else if (this._dialog?.open) {
+ this.updateComplete.then(() => this._positionViewMoreDialog());
+ }
+ }
+
+ private _handleUploadClick(event: Event): void {
+ event.preventDefault();
+ this._fileInput?.click();
+ }
+
+ private _handleFileChange(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ const files = Array.from(input.files ?? []);
+ if (!files.length) {
+ return;
+ }
+
+ const nextArtifacts = createArtifactsFromFiles(
+ files,
+ this.artifacts.length
+ );
+ this.artifacts = [...this.artifacts, ...nextArtifacts];
+ this.readout = `Added ${files.length} file${files.length === 1 ? '' : 's'}. ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'} total.`;
+ input.value = '';
+
+ if (this._dialog?.open) {
+ this.updateComplete.then(() => this._positionViewMoreDialog());
+ }
+ }
+
+ private _backdropPointerDown = (event: PointerEvent): void => {
+ const target = event.target as Node;
+ const panel = this._dialog?.querySelector(
+ '.MultiArtifactDemo-view-more-dialog-panel'
+ );
+
+ if (
+ panel?.contains(target) ||
+ this._viewMoreButton?.contains(target) ||
+ target === this._viewMoreButton
+ ) {
+ return;
+ }
+
+ this._closeViewMoreDialog();
+ };
+
+ private _openViewMoreDialog(): void {
+ const dialog = this._dialog;
+ if (!dialog || dialog.open) {
+ return;
+ }
+
+ dialog.showModal();
+ this.dialogOpen = true;
+ document.addEventListener('pointerdown', this._backdropPointerDown, true);
+ this.readout = `Opened view-more dialog with ${this._overflowArtifacts.length} additional artifact${this._overflowArtifacts.length === 1 ? '' : 's'}.`;
+ this.updateComplete.then(() => this._positionViewMoreDialog());
+ }
+
+ private _closeViewMoreDialog(): void {
+ if (this._dialog?.open) {
+ this._dialog.close();
+ }
+ }
+
+ private _handleViewMoreClick(): void {
+ if (this._dialog?.open) {
+ this._closeViewMoreDialog();
+ return;
+ }
+
+ this._openViewMoreDialog();
+ }
+
+ private _handleDialogClose(): void {
+ document.removeEventListener(
+ 'pointerdown',
+ this._backdropPointerDown,
+ true
+ );
+ this.dialogOpen = false;
+ this.readout = 'Closed view-more dialog.';
+ this._viewMoreButton?.focus();
+ }
+
+ private _positionViewMoreDialog(): void {
+ const button = this._viewMoreButton;
+ const dialog = this._dialog;
+ if (!button || !dialog) {
+ return;
+ }
+
+ const rect = button.getBoundingClientRect();
+ const gap = 8;
+ const top = rect.top - dialog.offsetHeight - gap;
+ const left = rect.right - dialog.offsetWidth;
+
+ dialog.style.top = `${Math.max(8, top)}px`;
+ dialog.style.left = `${Math.max(8, left)}px`;
+ }
+
+ private get _visibleArtifacts(): DemoArtifact[] {
+ return this.artifacts.slice(0, MultiArtifactViewMoreDemo.visibleCount);
+ }
+
+ private get _overflowArtifacts(): DemoArtifact[] {
+ return this.artifacts.slice(MultiArtifactViewMoreDemo.visibleCount);
+ }
+
+ private _handleSubmit(): void {
+ this.readout = `Submitted prompt with ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'}.`;
+ }
+
+ protected override render(): TemplateResult {
+ const overflowCount = this._overflowArtifacts.length;
+
+ return html`
+ ${demoStyles}
+
+
+
+
+
+
+
+ ${PlusIcon()}
+
+
+ ${ChevronUpIcon()}
+
+
+
+
+
+ Responses are generated using AI, and may be inaccurate. Check
+ before using.
+
+ AI User Guidelines
+
+
+
+
${this.readout}
+
+
+
+
+
+
+ `;
+ }
+}
+
+void MultiArtifactScrollDemo;
+void MultiArtifactVerticalScrollDemo;
+void MultiArtifactViewMoreDemo;
+
+const meta: Meta = {
+ title: 'Conversational AI/Prompt field/Multi-artifact demos',
+ parameters: {
+ docs: {
+ disable: true,
+ page: null,
+ },
+ layout: 'fullscreen',
+ },
+ tags: ['dev'],
+};
+
+export default meta;
+
+/**
+ * Horizontal scroll strip with previous/next controls and a synced scrollbar.
+ * Starts with 18 seeded artifacts so overflow is immediate.
+ */
+export const ScrollGallery: Story = {
+ render: () => html`
+
+ `,
+};
+
+/**
+ * Wrapped artifact strip capped at two rows, with vertical scroll and a synced
+ * scrollbar. Starts with 18 seeded artifacts so overflow is immediate.
+ */
+export const ScrollGalleryVertical: Story = {
+ render: () => html`
+
+ `,
+};
+
+/**
+ * Inline cap of eight artifacts, with a view-more dialog for the remainder.
+ * Starts with 18 seeded artifacts so the overflow button appears immediately.
+ */
+export const ViewMorePopover: Story = {
+ render: () => html`
+
+ `,
+};
+
+// ────────────────────────────────
+// DRAG AND DROP STORY
+// ────────────────────────────────
+
+@customElement('swc-multi-artifact-drag-drop-demo')
+class MultiArtifactDragDropDemo extends LitElement {
+ @state()
+ private artifacts: DemoArtifact[] = createSeedArtifacts(8);
+
+ @state()
+ private value = demoPrompt;
+
+ @state()
+ private readout =
+ 'Drag files onto the composer or use + to add files and dismiss tiles to remove them.';
+
+ @state()
+ private isDragged = false;
+
+ private _debouncedDragLeave: number | null = null;
+
+ private _dropzone: HTMLElement | null = null;
+
+ @query('[data-file-input]')
+ private _fileInput!: HTMLInputElement;
+
+ protected override createRenderRoot(): this {
+ return this;
+ }
+
+ public override disconnectedCallback(): void {
+ this._bindDropzoneListeners(false);
+ this._clearDebouncedDragLeave();
+ revokeArtifactUrls(this.artifacts);
+ super.disconnectedCallback?.();
+ }
+
+ public override firstUpdated(): void {
+ this._dropzone = this.querySelector('[data-dropzone]');
+ this._bindDropzoneListeners(true);
+ }
+
+ private _bindDropzoneListeners(bind: boolean): void {
+ const zone = this._dropzone ?? this.querySelector('[data-dropzone]');
+ if (!zone) {
+ return;
+ }
+
+ this._dropzone = zone;
+
+ if (bind) {
+ zone.addEventListener('dragover', this._onDragOver);
+ zone.addEventListener('dragleave', this._onDragLeave);
+ zone.addEventListener('drop', this._onDrop);
+ return;
+ }
+
+ zone.removeEventListener('dragover', this._onDragOver);
+ zone.removeEventListener('dragleave', this._onDragLeave);
+ zone.removeEventListener('drop', this._onDrop);
+ }
+
+ private _clearDebouncedDragLeave(): void {
+ if (this._debouncedDragLeave !== null) {
+ window.clearTimeout(this._debouncedDragLeave);
+ this._debouncedDragLeave = null;
+ }
+ }
+
+ /**
+ * Mirrors sp-dropzone should-accept: cancelable gate before showing dragged state.
+ */
+ private _shouldAcceptDrop(event: DragEvent): boolean {
+ const shouldAcceptEvent = new CustomEvent(
+ 'swc-multi-artifact-drop-should-accept',
+ {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ detail: event,
+ }
+ );
+
+ if (!this.dispatchEvent(shouldAcceptEvent)) {
+ return false;
+ }
+
+ const types = event.dataTransfer?.types;
+ if (!types?.length) {
+ return true;
+ }
+
+ return Array.from(types).includes('Files');
+ }
+
+ private _onDragOver = (event: DragEvent): void => {
+ event.preventDefault();
+
+ if (!this._shouldAcceptDrop(event)) {
+ if (event.dataTransfer) {
+ event.dataTransfer.dropEffect = 'none';
+ }
+ return;
+ }
+
+ this._clearDebouncedDragLeave();
+
+ if (!this.isDragged) {
+ this.isDragged = true;
+ }
+
+ if (event.dataTransfer) {
+ event.dataTransfer.dropEffect = 'copy';
+ }
+ };
+
+ private _onDragLeave = (event: DragEvent): void => {
+ const zone = event.currentTarget as HTMLElement;
+ if (event.relatedTarget && zone.contains(event.relatedTarget as Node)) {
+ return;
+ }
+
+ this._clearDebouncedDragLeave();
+ this._debouncedDragLeave = window.setTimeout(() => {
+ this.isDragged = false;
+ }, 100);
+ };
+
+ private _onDrop = (event: DragEvent): void => {
+ event.preventDefault();
+
+ if (!this.isDragged) {
+ return;
+ }
+
+ this._clearDebouncedDragLeave();
+ this.isDragged = false;
+ this._acceptFiles(getDraggedFiles(event), 'drop');
+ };
+
+ private _handleArtifactDismiss(event: Event): void {
+ const artifactId = getDismissedArtifactId(event);
+ if (!artifactId) {
+ return;
+ }
+
+ const removed = this.artifacts.find(
+ (artifact) => artifact.id === artifactId
+ );
+ if (removed?.thumbnailUrl) {
+ URL.revokeObjectURL(removed.thumbnailUrl);
+ }
+
+ this.artifacts = this.artifacts.filter(
+ (artifact) => artifact.id !== artifactId
+ );
+ this.readout = `Removed ${removed?.fileName ?? 'artifact'}.`;
+ }
+
+ private _handleUploadClick(event: Event): void {
+ event.preventDefault();
+ this._fileInput?.click();
+ }
+
+ private _acceptFiles(files: File[], source: 'drop' | 'picker'): void {
+ if (!files.length) {
+ if (source === 'drop') {
+ this.readout = 'No files were dropped.';
+ }
+ return;
+ }
+
+ const nextArtifacts = createArtifactsFromFiles(
+ files,
+ this.artifacts.length
+ );
+ this.artifacts = [...this.artifacts, ...nextArtifacts];
+ this.readout =
+ source === 'drop'
+ ? `Added ${files.length} file${files.length === 1 ? '' : 's'} from drop. ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'} total.`
+ : `Added ${files.length} file${files.length === 1 ? '' : 's'}. ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'} total.`;
+ }
+
+ private _handleFileChange(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ const files = Array.from(input.files ?? []);
+ this._acceptFiles(files, 'picker');
+ input.value = '';
+ }
+
+ private _handleSubmit(): void {
+ this.readout = `Submitted prompt with ${this.artifacts.length} attachment${this.artifacts.length === 1 ? '' : 's'}.`;
+ }
+
+ protected override render(): TemplateResult {
+ return html`
+ ${demoStyles}
+
+
+
+
+
+
+
+
+ ${PlusIcon()}
+
+
+ ${ChevronUpIcon()}
+
+
+
+
+ ${this.isDragged
+ ? html`
+
+
+ ${dropzoneUploadIllustration}
+ Drag and drop your file
+
+
+ `
+ : nothing}
+
+
+
+ Responses are generated using AI, and may be inaccurate. Check
+ before using.
+
+ AI User Guidelines
+
+
+
+
${this.readout}
+
+
+ `;
+ }
+}
+
+void MultiArtifactDragDropDemo;
+
+/**
+ * Same composer layout as the other multi-artifact demos. Drag-and-drop follows
+ * the sp-dropzone interaction model: dragover with preventDefault, debounced
+ * dragleave, and a dragged visual state on the composer box.
+ */
+export const DragAndDropUpload: Story = {
+ render: () => html`
+
+ `,
+};
diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/UploadArtifact.ts b/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/UploadArtifact.ts
index 2d2d15e3e6a..1ca7707525d 100644
--- a/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/UploadArtifact.ts
+++ b/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/UploadArtifact.ts
@@ -11,12 +11,10 @@
*/
import { CSSResultArray, html, TemplateResult } from 'lit';
-import { property } from 'lit/decorators.js';
+import { property, queryAssignedElements } from 'lit/decorators.js';
import { SpectrumElement } from '@spectrum-web-components/core/element/index.js';
-import '@adobe/spectrum-wc/components/icon/swc-icon.js';
-
import { CrossIcon } from '../utils/icons/index.js';
import styles from './upload-artifact.css';
@@ -27,11 +25,11 @@ import styles from './upload-artifact.css';
* @element swc-upload-artifact
*
* @slot thumbnail - Shared visual slot for icon/thumbnail/preview image.
+ * @slot badge - Optional file-type badge rendered over media previews (for example, "PDF").
* @slot title - Primary text label.
* @slot subtitle - Secondary text label.
* @slot actions - Optional trailing actions.
- * @fires swc-upload-artifact-dismiss - Dispatched when the dismiss button is pressed.
- * Detail: `{ artifact: this }`
+ * @fires swc-upload-artifact-dismiss - Dispatched when the dismiss button is pressed. Detail includes the artifact element.
*/
export class UploadArtifact extends SpectrumElement {
/** Visual treatment type for this artifact. */
@@ -46,10 +44,21 @@ export class UploadArtifact extends SpectrumElement {
@property({ type: String, attribute: 'dismiss-label' })
public dismissLabel = 'Remove attachment';
+ @queryAssignedElements({ slot: 'badge', flatten: true })
+ private _assignedBadge!: HTMLElement[];
+
public static override get styles(): CSSResultArray {
return [styles];
}
+ private _handleSlotChange(): void {
+ this.requestUpdate();
+ }
+
+ private _hasBadgeContent(): boolean {
+ return (this._assignedBadge?.length ?? 0) > 0;
+ }
+
private _handleDismissClick(): void {
this.dispatchEvent(
new CustomEvent('swc-upload-artifact-dismiss', {
@@ -60,45 +69,74 @@ export class UploadArtifact extends SpectrumElement {
);
}
+ private _renderDismissButton(): TemplateResult {
+ return html`
+
+
+ ${CrossIcon()}
+
+ `;
+ }
+
+ private _renderBadge(): TemplateResult {
+ if (!this._hasBadgeContent()) {
+ return html`
+
+ `;
+ }
+
+ return html`
+
+
+
+ `;
+ }
+
protected override render(): TemplateResult {
const isMedia = this.type === 'media';
+ if (isMedia) {
+ return html`
+ ${this._renderDismissButton()}
+
+
+
+
+
+ ${this._renderBadge()}
+
+
+
+
+
+ `;
+ }
+
return html`
+ ${this._renderDismissButton()}
-
- ${CrossIcon()}
-
-
-
- ${isMedia
- ? html`
-
-
-
- `
- : html`
-
-
-
-
-
- `}
+
+
+
+
`;
diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/stories/upload-artifact.stories.ts b/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/stories/upload-artifact.stories.ts
index 70573cdef77..108d3956af6 100644
--- a/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/stories/upload-artifact.stories.ts
+++ b/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/stories/upload-artifact.stories.ts
@@ -162,13 +162,27 @@ export const Card: Story = {
};
/**
- * Media type uses a larger preview region without title and subtitle text.
+ * Media type uses a square preview region. Optional badge text identifies document types such as PDF.
*/
export const Media: Story = {
render: () => html`
-
+
`,
diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/test/upload-artifact.test.ts b/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/test/upload-artifact.test.ts
index bc2d7161cde..05171f59bcf 100644
--- a/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/test/upload-artifact.test.ts
+++ b/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/test/upload-artifact.test.ts
@@ -48,7 +48,9 @@ export const OverviewTest: Story = {
const dismissButton = el.shadowRoot?.querySelector
(
'.swc-UploadArtifact-dismiss'
);
- const icon = dismissButton?.querySelector('swc-icon');
+ const icon = dismissButton?.querySelector(
+ '.swc-UploadArtifact-dismiss-icon'
+ );
expect(dismissButton?.getAttribute('aria-label')).toBe(
'Remove attachment'
);
@@ -152,3 +154,38 @@ export const MediaPreviewOnlyTest: Story = {
});
},
};
+
+export const MediaBadgeTest: Story = {
+ render: () => html`
+
+
+ PDF
+
+ `,
+ play: async ({ canvasElement, step }) => {
+ const el = await getComponent(
+ canvasElement,
+ 'swc-upload-artifact'
+ );
+
+ await step('media artifact renders badge overlay', async () => {
+ expect(
+ el.shadowRoot?.querySelector('.swc-UploadArtifact-badge')
+ ).toBeTruthy();
+ expect(el.querySelector('[slot="badge"]')?.textContent?.trim()).toBe(
+ 'PDF'
+ );
+ });
+
+ await step(
+ 'media dismiss button renders as a sibling of the preview surface',
+ async () => {
+ const root = el.shadowRoot;
+ const surface = root?.querySelector('.swc-UploadArtifact-surface');
+ const dismissButton = root?.querySelector('.swc-UploadArtifact-dismiss');
+ expect(surface).toBeTruthy();
+ expect(dismissButton).toBeTruthy();
+ }
+ );
+ },
+};
diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/upload-artifact.css b/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/upload-artifact.css
index 3406c2dcad2..b5fab5173e5 100644
--- a/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/upload-artifact.css
+++ b/2nd-gen/packages/swc/patterns/conversational-ai/upload-artifact/upload-artifact.css
@@ -23,33 +23,104 @@
}
.swc-UploadArtifact-dismiss {
+ position: absolute;
+ z-index: 2;
+ padding: 0;
+ color: token("gray-25");
+ background: transparent;
+ border: none;
+ cursor: pointer;
+}
+
+.swc-UploadArtifact-dismiss-visual,
+.swc-UploadArtifact-dismiss-icon {
+ pointer-events: none;
+}
+
+.swc-UploadArtifact-dismiss-icon svg {
+ display: block;
+ inline-size: var(--swc-upload-artifact-dismiss-icon-inline-size, 10px);
+ block-size: var(--swc-upload-artifact-dismiss-icon-block-size, 10px);
+}
+
+.swc-UploadArtifact-dismiss[hidden] {
+ display: none;
+}
+
+.swc-UploadArtifact-dismiss:hover {
+ background: transparent;
+}
+
+:host([type="media"]) .swc-UploadArtifact-dismiss {
+ /* 32px hit target centered on the 20px visual at spacing-75 (4px) inset. */
+ inset-block-start: calc(token("spacing-75") - 6px);
+ inset-inline-end: calc(token("spacing-75") - 6px);
+ inline-size: 32px;
+ block-size: 32px;
+}
+
+:host([type="media"]) .swc-UploadArtifact-dismiss-visual {
+ position: absolute;
+ inset-block-start: 6px;
+ inset-inline-end: 6px;
+ inline-size: 20px;
+ block-size: 20px;
+ background: token("transparent-black-700");
+ border-radius: token("corner-radius-full");
+}
+
+:host([type="media"]) .swc-UploadArtifact-dismiss-icon {
display: flex;
position: absolute;
- inset-block-start: -12px;
- inset-inline-end: -12px;
- z-index: 1;
+ inset-block-start: 6px;
+ inset-inline-end: 6px;
align-items: center;
justify-content: center;
- inline-size: var(--swc-upload-artifact-dismiss-inline-size, 24px);
- block-size: var(--swc-upload-artifact-dismiss-block-size, 24px);
- padding: 0;
- color: token("gray-800");
+ inline-size: 20px;
+ block-size: 20px;
+}
+
+:host([type="card"]) .swc-UploadArtifact-dismiss {
+ inset-block-start: -12px;
+ inset-inline-end: -12px;
+ inline-size: var(--swc-upload-artifact-dismiss-inline-size, 44px);
+ block-size: var(--swc-upload-artifact-dismiss-block-size, 44px);
+}
+
+:host([type="card"]) .swc-UploadArtifact-dismiss-visual {
+ position: absolute;
+ inset-block-start: 0;
+ inset-inline-end: 0;
+ inline-size: 24px;
+ block-size: 24px;
background: token("gray-200");
border: 1px solid token("gray-25");
border-radius: token("corner-radius-full");
}
-.swc-UploadArtifact-dismiss[hidden] {
- display: none;
+:host([type="card"]) .swc-UploadArtifact-dismiss-icon {
+ display: flex;
+ position: absolute;
+ inset-block-start: 0;
+ inset-inline-end: 0;
+ align-items: center;
+ justify-content: center;
+ inline-size: 24px;
+ block-size: 24px;
}
-.swc-UploadArtifact-dismiss:hover {
+:host([type="media"]) .swc-UploadArtifact-dismiss:hover .swc-UploadArtifact-dismiss-visual {
+ background: token("transparent-black-800");
+}
+
+:host([type="card"]) .swc-UploadArtifact-dismiss:hover .swc-UploadArtifact-dismiss-visual {
background: token("gray-300");
}
-.swc-UploadArtifact-dismiss swc-icon {
- --swc-icon-inline-size: var(--swc-upload-artifact-dismiss-icon-inline-size, 10px);
- --swc-icon-block-size: var(--swc-upload-artifact-dismiss-icon-block-size, 10px);
+.swc-UploadArtifact {
+ position: relative;
+ inline-size: 100%;
+ block-size: 100%;
}
.swc-UploadArtifact-surface {
@@ -149,25 +220,27 @@
}
:host([type="media"]) {
- inline-size: var(--swc-upload-artifact-preview-size, 68px);
- block-size: var(--swc-upload-artifact-preview-size, 68px);
-}
-
-:host([type="media"]) .swc-UploadArtifact {
- inline-size: 100%;
- block-size: 100%;
+ flex: 0 0 auto;
+ inline-size: var(--swc-upload-artifact-preview-size, 64px);
+ min-inline-size: var(--swc-upload-artifact-preview-size, 64px);
+ block-size: var(--swc-upload-artifact-preview-size, 64px);
+ min-block-size: var(--swc-upload-artifact-preview-size, 64px);
+ overflow: visible;
}
:host([type="media"]) .swc-UploadArtifact-surface {
+ position: relative;
flex-direction: column;
- gap: token("spacing-100");
+ block-size: 100%;
+ border-radius: token("spacing-200");
overflow: hidden;
}
:host([type="media"]) .swc-UploadArtifact-thumbnail {
+ block-size: 100%;
background: token("gray-100");
border: 1px solid transparent;
- border-radius: token("corner-radius-200");
+ border-radius: token("spacing-200");
overflow: hidden;
}
@@ -184,36 +257,35 @@
block-size: 100%;
}
-:host([type="media"]) .swc-UploadArtifact-meta {
- inline-size: 100%;
-}
-
-:host([type="media"]) .swc-UploadArtifact-header {
- display: flex;
- gap: token("spacing-100");
- align-items: flex-start;
- justify-content: space-between;
-}
-
-:host([type="media"]) .swc-UploadArtifact-header > div {
- min-inline-size: 0;
-}
-
-:host([type="media"]) .swc-UploadArtifact-title {
- flex: 1 1 0;
- min-inline-size: 0;
- line-height: token("line-height-font-size-100");
- overflow: hidden;
+:host([type="media"]) .swc-UploadArtifact-badge {
+ display: inline-flex;
+ position: absolute;
+ inset-block-end: token("corner-radius-75");
+ inset-inline-start: token("corner-radius-75");
+ z-index: 1;
+ align-items: center;
+ max-inline-size: calc(100% - (2 * token("corner-radius-75")));
+ min-block-size: 24px;
+ padding-block: token("spacing-50");
+ padding-inline: token("spacing-100");
+ font-family: token("sans-serif-font");
+ font-size: token("font-size-75");
+ font-weight: token("medium-font-weight");
+ line-height: token("line-height-font-size-75");
+ color: token("gray-25");
+ background: token("transparent-black-700");
+ border-radius: token("corner-radius-400");
}
-:host([type="media"]) .swc-UploadArtifact-title ::slotted(*) {
+:host([type="media"]) .swc-UploadArtifact-badge ::slotted(*) {
display: block;
min-inline-size: 0;
+ max-inline-size: 152px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
-:host([type="media"]) .swc-UploadArtifact-subtitle {
- line-height: token("line-height-font-size-75");
+:host([type="media"]) .swc-UploadArtifact-actions {
+ display: none;
}
diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/UserMessage.ts b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/UserMessage.ts
index 2078e91406c..260a104d713 100644
--- a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/UserMessage.ts
+++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/UserMessage.ts
@@ -11,58 +11,134 @@
*/
import { CSSResultArray, html, TemplateResult } from 'lit';
-import { property } from 'lit/decorators.js';
+import { queryAssignedElements, queryAssignedNodes } from 'lit/decorators.js';
import { SpectrumElement } from '@spectrum-web-components/core/element/index.js';
import styles from './user-message.css';
-export type UserMessageType = 'copy' | 'card' | 'media';
-
/**
- * User-authored conversation bubble for conversational AI pattern exploration.
- * Default slot content is rendered only when `type="copy"` and ignored when
- * `type="card"` or `type="media"`.
+ * User-authored message container for conversational AI pattern exploration.
*
* @element swc-user-message
- * @slot - Message copy content when `type="copy"`.
- * @slot thumbnail - Attachment preview when `type="card"` or `type="media"`.
- * @slot title - Attachment title when `type="card"` or `type="media"`.
- * @slot subtitle - Attachment subtitle when `type="card"` or `type="media"`.
+ * @slot artifacts-media - Optional media artifacts rendered above file artifacts and message text.
+ * @slot artifacts-file - Optional file/card artifacts rendered above message text.
+ * @slot - Optional message copy rendered as the text bubble.
*/
export class UserMessage extends SpectrumElement {
- /**
- * Visual content type for the user message bubble.
- */
- @property({ type: String, reflect: true })
- public type: UserMessageType = 'copy';
+ @queryAssignedElements({ slot: 'artifacts-media', flatten: true })
+ private _assignedMediaArtifacts!: HTMLElement[];
+
+ @queryAssignedElements({ slot: 'artifacts-file', flatten: true })
+ private _assignedFileArtifacts!: HTMLElement[];
+
+ @queryAssignedNodes({ flatten: true })
+ private _assignedDefaultNodes!: Node[];
public static override get styles(): CSSResultArray {
return [styles];
}
+ private _handleSlotChange(): void {
+ this.requestUpdate();
+ }
+
+ private _handleHostClick(event: MouseEvent): void {
+ const target = event.composedPath()[0];
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+ if (!target.classList.contains('swc-UserMessage-overflow-indicator')) {
+ return;
+ }
+ this.dispatchEvent(
+ new CustomEvent('swc-user-message-view-all-click', {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+
+ private _hasTextContent(): boolean {
+ return (this._assignedDefaultNodes ?? []).some((node) => {
+ if (node.nodeType === Node.TEXT_NODE) {
+ return Boolean(node.textContent?.trim());
+ }
+
+ return node.nodeType === Node.ELEMENT_NODE;
+ });
+ }
+
+ private _renderMediaArtifacts(): TemplateResult | null {
+ const count = this._assignedMediaArtifacts?.length ?? 0;
+ if (count === 0) {
+ return html`
+
+ `;
+ }
+
+ return html`
+
+
+
+ `;
+ }
+
+ private _renderFileArtifacts(): TemplateResult | null {
+ const count = this._assignedFileArtifacts?.length ?? 0;
+ if (count === 0) {
+ return html`
+
+ `;
+ }
+
+ return html`
+
+
+
+ `;
+ }
+
+ private _renderTextBubble(): TemplateResult | null {
+ if (!this._hasTextContent()) {
+ return html`
+
+ `;
+ }
+
+ return html`
+
+ `;
+ }
+
protected override render(): TemplateResult {
- return this.type === 'copy'
- ? html`
-
- `
- : html`
-
- `;
+ return html`
+
+ ${this._renderMediaArtifacts()} ${this._renderFileArtifacts()}
+ ${this._renderTextBubble()}
+
+ `;
+ }
+
+ protected override firstUpdated(): void {
+ this.addEventListener('click', this._handleHostClick);
}
}
diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/stories/user-message.stories.ts b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/stories/user-message.stories.ts
index b8c9d3beabd..ef59c2a094c 100644
--- a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/stories/user-message.stories.ts
+++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/stories/user-message.stories.ts
@@ -10,53 +10,25 @@
* governing permissions and limitations under the License.
*/
-import { html } from 'lit';
+import { html, LitElement } from 'lit';
+import { customElement, state } from 'lit/decorators.js';
import type { Meta, StoryObj as Story } from '@storybook/web-components';
-import { getStorybookHelpers } from '@wc-toolkit/storybook-helpers';
import '../../conversation-turn/index.js';
+import '../../upload-artifact/index.js';
import '../index.js';
-// ────────────────
-// METADATA
-// ────────────────
-
-const { args, argTypes, template } = getStorybookHelpers('swc-user-message');
-delete (args as Record).content;
-delete (argTypes as Record).content;
-
-argTypes.type = {
- ...argTypes.type,
- control: { type: 'select' },
- options: ['copy', 'card', 'media'],
- table: {
- category: 'attributes',
- defaultValue: { summary: 'copy' },
- },
-};
-
-// Wraps a single swc-user-message in a conversation turn for proper alignment.
const withUserTurn = (story: () => unknown) => html`
${story()}
`;
-/**
- * User-authored message bubble. Use inside `` for thread alignment.
- *
- *
- * Note: This component does not sanitize slotted content. When rendering user-provided
- * or AI-generated markup, consumers must sanitize input to prevent XSS and
- * validate link targets. This is the consumer's responsibility.
- */
const meta: Meta = {
title: 'Conversational AI/User message',
component: 'swc-user-message',
- args,
- argTypes,
- render: (args) => template(args),
parameters: {
docs: {
- subtitle: 'User-submitted message rendered in the thread.',
+ subtitle:
+ 'User-submitted message rendered in the thread with optional media artifacts, file artifacts, and text.',
},
layout: 'padded',
},
@@ -66,147 +38,338 @@ const meta: Meta = {
export { meta };
export default meta;
-// ────────────────────
-// AUTODOCS STORY
-// ────────────────────
-
export const Playground: Story = {
- args: {
- type: 'copy',
- 'default-slot':
- 'Can you help me create a 45-minute presentation, with animations, for an executive update?',
- 'thumbnail-slot':
- ' ',
- 'title-slot': 'Hilton commercial assets',
- 'subtitle-slot': '2026',
- },
+ render: () => html`
+
+ Can you help me create a 45-minute presentation, with animations, for an
+ executive update?
+
+ `,
decorators: [withUserTurn],
tags: ['autodocs', 'dev'],
};
-// ──────────────────────────────
-// OVERVIEW STORY
-// ──────────────────────────────
-
export const Overview: Story = {
- args: {
- type: 'copy',
- 'default-slot':
- 'Can you help me create a 45-minute presentation, with animations, for an executive update?',
- 'thumbnail-slot':
- ' ',
- 'title-slot': 'Hilton commercial assets',
- 'subtitle-slot': '2026',
- },
+ render: () => html`
+
+ Can you help me create a 45-minute presentation, with animations, for an
+ executive update?
+
+ `,
decorators: [withUserTurn],
tags: ['overview'],
};
-// ──────────────────────────
-// ANATOMY STORY
-// ──────────────────────────
-
-/**
- * A user message consists of:
- *
- * 1. **Bubble** — Rounded container with a neutral gray background (`gray-50`)
- * 2. **Default slot** — The message content: plain text, a card attachment, or image-first content
- */
-export const Anatomy: Story = {
- args: {
- 'default-slot': 'Can you help me create a 45-minute presentation?',
- },
- decorators: [withUserTurn],
- tags: ['anatomy'],
-};
-
-// ──────────────────────────
-// OPTIONS STORIES
-// ──────────────────────────
-
-/**
- * Bubble sizing and padding are inferred from slotted content:
- *
- * - **Copy** — default text-only content with the bubble's default width and padding
- * - **Card** — compact attachment layout with thumbnail, title, and subtitle
- * - **Media** — larger preview-first attachment layout with metadata beneath the preview
- */
export const Content: Story = {
render: () => html`
-
-
-
- Can you help me create a 45-minute presentation, with animations,
- for an executive update?
-
-
- Copy
-
-
-
-
+
+
+
- Hilton commercial assets
- 2026
-
-
- Card
-
-
-
-
+
+
- Hilton commercial assets
- 2026
-
-
-
Media
-
+
+
+
+
+
+
+
+
+
+
+ Launch brief budget
+ Excel
+
+
+
+ FY26Q1 Competitor analysis
+ PDF
+
+
+ Can you help me create a 45-minute presentation, with animations, for
+ an executive update?
+
+
`,
parameters: { 'section-order': 1 },
tags: ['options'],
};
-// ────────────────────────────────
-// ACCESSIBILITY STORY
-// ────────────────────────────────
+export const OverflowIndicator: Story = {
+ render: () => html`
+
+
+
+
+
+
+
+
+
+
+
+ View all (8)
+
-/**
- * ### Features
- *
- * The `` element implements the following accessibility features:
- *
- * #### Semantic structure
- *
- * - The bubble is rendered as a `` acting as a visual container
- * - `type="copy"` uses the default slot for message text
- * - `type="card"` and `type="media"` use named slots for thumbnail, title, and subtitle
- *
- * ### Best practices
- *
- * - Ensure message text is descriptive and self-contained
- * - For card and media attachments, ensure titles/subtitles and `aria-label`/`alt` text are present for previews
- */
-export const Accessibility: Story = {
- args: {
- type: 'copy',
- 'default-slot':
- 'Can you help me create a 45-minute presentation, with animations, for an executive update?',
- },
+
+
+ Launch brief budget
+ Excel
+
+
+
+ FY26Q1 Competitor analysis
+ PDF
+
+
+ Can you help me create a 45-minute presentation, with animations, for an
+ executive update?
+
+ `,
+ decorators: [withUserTurn],
+ tags: ['states'],
+};
+
+@customElement('swc-user-message-overflow-demo')
+class UserMessageOverflowDemo extends LitElement {
+ @state()
+ private open = false;
+
+ protected override createRenderRoot(): this {
+ return this;
+ }
+
+ private _openOverlay(): void {
+ this.open = true;
+ }
+
+ private _closeOverlay(): void {
+ this.open = false;
+ }
+
+ public override render() {
+ return html`
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View all (8)
+
+
+
+ Launch brief budget
+ Excel
+
+
+
+ FY26Q1 Competitor analysis
+ PDF
+
+ Can you help me create a 45-minute presentation, with animations,
+ for an executive update?
+
+
+
+ ${this.open
+ ? html`
+
+
+ ${Array.from({ length: 8 }).map(
+ (_, i) => html`
+
+ `
+ )}
+
+
+ Close
+
+
+ `
+ : null}
+
+ `;
+ }
+}
+
+void UserMessageOverflowDemo;
+
+export const ViewAllOverlay: Story = {
+ render: () => html`
+
+ `,
+ tags: ['states'],
+};
+
+export const MediaGrid: Story = {
+ render: () => html`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Launch brief budget
+ Excel
+
+
+
+ FY26Q1 Competitor analysis
+ PDF
+
+
+ Can you help me create a 45-minute presentation, with animations, for an
+ executive update?
+
+ `,
decorators: [withUserTurn],
- tags: ['a11y'],
+ tags: ['states'],
};
diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.a11y.spec.ts b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.a11y.spec.ts
index e96fa10ed77..472863bbcc1 100644
--- a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.a11y.spec.ts
+++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.a11y.spec.ts
@@ -1,28 +1,11 @@
/**
* Copyright 2026 Adobe. All rights reserved.
- * This file is licensed to you under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License. You may obtain a copy
- * of the License at http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under
- * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
- * OF ANY KIND, either express or implied. See the License for the specific language
- * governing permissions and limitations under the License.
*/
import { expect, test } from '@playwright/test';
import { gotoStory } from '../../../../utils/a11y-helpers.js';
-/**
- * Accessibility tests for UserMessage pattern (2nd Generation)
- *
- * ARIA snapshot tests validate the accessibility tree structure.
- * aXe WCAG compliance and color contrast validation are run via
- * test-storybook (see .storybook/test-runner.ts). Both are included
- * in the `test:a11y` command.
- */
-
test.describe('UserMessage - ARIA Snapshots', () => {
test('should have correct accessibility tree for default user message', async ({
page,
@@ -37,31 +20,21 @@ test.describe('UserMessage - ARIA Snapshots', () => {
`);
});
- test('should expose accessible card attachment content', async ({ page }) => {
+ test('should expose mixed artifacts and text content', async ({ page }) => {
const root = await gotoStory(
page,
'patterns-conversational-ai-user-message--content',
'swc-user-message'
);
- const cardMessage = root.locator('swc-user-message').nth(1);
- await expect(cardMessage).toMatchAriaSnapshot(`
- - img "File"
- - text: Hilton commercial assets 2026
- `);
- });
-
- test('should expose accessible media attachment content', async ({
- page,
- }) => {
- const root = await gotoStory(
- page,
- 'patterns-conversational-ai-user-message--content',
- 'swc-user-message'
- );
- const mediaMessage = root.locator('swc-user-message').nth(2);
- await expect(mediaMessage).toMatchAriaSnapshot(`
- - img "Campaign preview"
- - text: Hilton commercial assets 2026
+ await expect(root).toMatchAriaSnapshot(`
+ - img "Campaign still"
+ - img "Storyboard frame"
+ - img "Moodboard"
+ - img "Excel"
+ - text: Launch brief budget Excel
+ - img "PDF"
+ - text: FY26Q1 Competitor analysis PDF
+ - text: /Can you help me/
`);
});
});
diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.test.ts b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.test.ts
index 7ab95ecfb2e..9843779bca6 100644
--- a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.test.ts
+++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/test/user-message.test.ts
@@ -3,21 +3,14 @@
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under
- * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
- * OF ANY KIND, either express or implied. See the License for the specific language
- * governing permissions and limitations under the License.
*/
-import { html } from 'lit';
import { expect } from '@storybook/test';
import type { Meta, StoryObj as Story } from '@storybook/web-components';
-import '../../conversation-turn/index.js';
import '../index.js';
-import { getComponent, getComponents } from '../../../../utils/test-utils.js';
+import { getComponent } from '../../../../utils/test-utils.js';
import { meta, Overview } from '../stories/user-message.stories.js';
import { UserMessage } from '../UserMessage.js';
@@ -39,196 +32,10 @@ export const OverviewTest: Story = {
'swc-user-message'
);
- await step('uses copy type by default', async () => {
- expect(el.type).toBe('copy');
- expect(el.getAttribute('type')).toBe('copy');
+ await step('renders composed user-message shell', async () => {
+ const root = el.shadowRoot;
+ expect(root?.querySelector('.swc-UserMessage')).toBeTruthy();
+ expect(root?.querySelector('.swc-UserMessage-text-bubble')).toBeTruthy();
});
},
};
-
-export const TypeAndSlotTest: Story = {
- ...Overview,
- play: async ({ canvasElement, step }) => {
- const el = await getComponent
(
- canvasElement,
- 'swc-user-message'
- );
-
- await step(
- 'type reflects to the host and drives card structure',
- async () => {
- el.type = 'card';
- el.innerHTML = `
-
- Brand guidelines
- PDF
- `;
- await el.updateComplete;
-
- const title = el.shadowRoot?.querySelector('.swc-UserMessage-title');
- const subtitle = el.shadowRoot?.querySelector(
- '.swc-UserMessage-subtitle'
- );
- expect(el.getAttribute('type')).toBe('card');
- expect(title).toBeTruthy();
- expect(subtitle).toBeTruthy();
- }
- );
-
- await step(
- 'media type renders the media attachment container',
- async () => {
- el.type = 'media';
- el.innerHTML = `
-
- Preview image
- PNG
- `;
- await el.updateComplete;
-
- const attachment = el.shadowRoot?.querySelector(
- '.swc-UserMessage-attachment--media'
- );
- expect(el.getAttribute('type')).toBe('media');
- expect(attachment).toBeTruthy();
- }
- );
-
- await step('copy type uses the default slot text path', async () => {
- el.type = 'copy';
- el.innerHTML = `Can you summarize this document?`;
- await el.updateComplete;
-
- const textSlot =
- el.shadowRoot?.querySelector('slot:not([name])');
- const assignedText = textSlot
- ?.assignedNodes({ flatten: true })
- .map((node) => node.textContent ?? '')
- .join('')
- .trim();
-
- expect(el.getAttribute('type')).toBe('copy');
- expect(assignedText).toBe('Can you summarize this document?');
- });
- },
-};
-
-export const DefaultSlotHiddenForAttachmentTypesTest: Story = {
- name: 'Default slot not used for card and media',
- ...Overview,
- play: async ({ canvasElement, step }) => {
- const el = await getComponent(
- canvasElement,
- 'swc-user-message'
- );
-
- const attachmentMarkup = (label: string) => `
- ${label}
-
- T
- S
- `;
-
- for (const type of ['card', 'media'] as const) {
- await step(
- `type="${type}": no unnamed slot; default-slot children are not shown`,
- async () => {
- el.type = type;
- el.innerHTML = attachmentMarkup(
- 'Default copy that must not appear in the bubble for attachment types.'
- );
- await el.updateComplete;
-
- expect(el.shadowRoot?.querySelector('slot:not([name])')).toBeNull();
-
- const leaked = el.querySelector(
- '[data-test-default-slotted]'
- );
- expect(leaked).toBeTruthy();
- const { width, height } = leaked!.getBoundingClientRect();
- expect(width * height).toBe(0);
- }
- );
- }
-
- await step(
- 'type="copy" keeps an unnamed (default) slot in the shadow root',
- async () => {
- el.type = 'copy';
- el.innerHTML = 'Visible copy text';
- await el.updateComplete;
-
- const defaultSlot =
- el.shadowRoot?.querySelector('slot:not([name])');
- expect(defaultSlot).toBeTruthy();
- }
- );
- },
-};
-
-const longSpacedCopy =
- 'This is a deliberately long line of user copy that should wrap within a narrow column without horizontal overflow. '.repeat(
- 3
- );
-
-const longUnbrokenFileName = `${'VeryLongAttachmentNamePortion'.repeat(12)}.pdf`;
-
-/**
- * Exercises long copy in a tight column and a long unbroken card title
- * (ellipsis) so the bubble does not grow past its layout width.
- */
-export const LongTextWrapTest: Story = {
- name: 'Long text wrap and containment',
- render: () => html`
-
-
- ${longSpacedCopy}
-
-
-
-
-
-
- ${longUnbrokenFileName}
- PDF
-
-
-
- `,
- play: async ({ canvasElement, step }) => {
- const [copyBubble, cardBubble] = await getComponents(
- canvasElement,
- 'swc-user-message'
- );
-
- await step(
- 'long copy message does not overflow horizontally in a narrow column',
- async () => {
- expect(copyBubble.scrollWidth).toBeLessThanOrEqual(
- copyBubble.clientWidth + 1
- );
- expect(copyBubble.getBoundingClientRect().height).toBeGreaterThan(40);
- }
- );
-
- await step(
- 'long unbroken card title is confined with ellipsis (no host overflow)',
- async () => {
- expect(cardBubble.scrollWidth).toBeLessThanOrEqual(
- cardBubble.clientWidth + 1
- );
- }
- );
- },
-};
diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.css b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.css
index bee1d8bf8d2..bfc1e06cc5d 100644
--- a/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.css
+++ b/2nd-gen/packages/swc/patterns/conversational-ai/user-message/user-message.css
@@ -10,30 +10,9 @@
* governing permissions and limitations under the License.
*/
-/* Sizing per explicit user-message type sourced from Figma spec (node 5-2281), full-screen modality.
- *
- * copy — fills available column width, capped at 536px (text reflows naturally)
- * card — fixed 324px (content 292px = 324px − 2×16px padding), capped at 440px
- * media — shrinks to the fixed 180×180px inner card + 2×8px padding ≈ 196px square
- *
- * The host IS the visible bubble so focus rings rendered by the parent
- * `swc-conversation-turn` hug its border box exactly.
- */
-
:host {
display: block;
- inline-size: fit-content;
- max-inline-size: 536px;
- padding-block: var(--swc-user-message-padding-block, token("spacing-100"));
- padding-inline: var(--swc-user-message-padding-inline, token("spacing-300"));
- font-family: token("sans-serif-font");
- font-size: token("font-size-200");
- font-weight: token("regular-font-weight");
- line-height: token("line-height-200");
- color: token("gray-800");
- background: token("gray-50");
- border: 1px solid transparent;
- border-radius: token("corner-radius-large-default");
+ inline-size: 100%;
}
*,
@@ -42,135 +21,104 @@
box-sizing: border-box;
}
-/* Card: fixed 324px per Figma spec (content 292px = 324px − 2×16px padding). */
-:host([type="card"]) {
- inline-size: 324px;
- min-inline-size: 212px;
- max-inline-size: 440px;
- padding: var(--swc-user-message-card-padding, token("spacing-300"));
-}
-
-/* Image: shrinks to the fixed 180px inner card + 2×8px padding. */
-:host([type="media"]) {
- inline-size: fit-content;
- padding: var(--swc-user-message-media-padding, token("spacing-100"));
-}
-
-.swc-UserMessage-attachment {
+.swc-UserMessage {
display: flex;
- min-inline-size: 0;
-}
-
-.swc-UserMessage-attachment--card {
- gap: var(--swc-user-message-attachment-card-gap, token("spacing-300"));
- align-items: center;
-}
-
-/* Media inner card is fixed 180 × 180px, constrained by min/max (Figma spec). */
-.swc-UserMessage-attachment--media {
flex-direction: column;
- gap: var(--swc-user-message-attachment-media-gap, token("spacing-100"));
- inline-size: 180px;
- min-inline-size: 150px;
- max-inline-size: 210px;
- block-size: 180px;
- min-block-size: 135px;
+ gap: token("spacing-300");
+ align-items: flex-end;
+ inline-size: 100%;
}
-.swc-UserMessage-thumbnail {
- min-inline-size: 0;
+.swc-UserMessage-media-row,
+.swc-UserMessage-file-row,
+.swc-UserMessage-text-row {
+ display: flex;
+ gap: token("spacing-100");
+ align-items: flex-start;
+ justify-content: flex-end;
+ inline-size: fit-content;
+ max-inline-size: 100%;
}
-.swc-UserMessage-thumbnail ::slotted(*) {
- display: block;
+.swc-UserMessage-media-row > slot,
+.swc-UserMessage-file-row > slot {
+ display: contents;
}
-:host([type="card"]) .swc-UserMessage-thumbnail {
- display: flex;
- flex-shrink: 0;
- align-items: center;
- justify-content: center;
+.swc-UserMessage-media-row > slot::slotted(*) {
+ flex: 0 0 auto;
}
-:host([type="card"]) .swc-UserMessage-thumbnail ::slotted(*) {
- flex-shrink: 0;
- inline-size: 32px;
- block-size: 32px;
- border: 1px solid transparent;
- border-radius: token("corner-radius-75");
- object-fit: cover;
-}
-
-/* Thumbnail fills the available vertical space, image covers it fully. */
-:host([type="media"]) .swc-UserMessage-thumbnail {
+.swc-UserMessage-media-row {
position: relative;
- flex: 1 0 0;
- inline-size: 100%;
- min-block-size: 0;
- background: token("gray-100");
- border: 1px solid transparent;
- border-radius: token("corner-radius-200");
- overflow: hidden;
+ flex-wrap: wrap;
+ max-inline-size: 536px;
}
-:host([type="media"]) .swc-UserMessage-thumbnail ::slotted(*) {
- position: absolute;
- inset: 0;
- inline-size: 100%;
- block-size: 100%;
- object-fit: cover;
+.swc-UserMessage-file-row > slot::slotted(*) {
+ flex: 0 1 auto;
}
-.swc-UserMessage-meta {
- display: flex;
- flex-direction: column;
- gap: var(--swc-user-message-meta-gap, token("spacing-50"));
- min-inline-size: 0;
+/* Figma thread mock uses 128x128 media tiles in user-message rows. */
+.swc-UserMessage-media-row > slot::slotted([type="media"]) {
+ inline-size: 128px;
+ min-inline-size: 128px;
+ block-size: 128px;
+ min-block-size: 128px;
+ border-radius: token("corner-radius-700");
+ overflow: hidden;
}
-:host([type="card"]) .swc-UserMessage-meta {
- flex: 1;
- align-items: flex-start;
+/* Figma thread mock uses ~304px card artifacts in post-submit user messages. */
+.swc-UserMessage-file-row > slot::slotted([type="card"]) {
+ inline-size: 304px;
+ max-inline-size: 304px;
+ border-radius: token("spacing-200");
}
-:host([type="media"]) .swc-UserMessage-meta {
- inline-size: 100%;
+.swc-UserMessage-file-row {
+ max-inline-size: 616px;
}
-.swc-UserMessage-title {
+.swc-UserMessage-media-row > slot::slotted(.swc-UserMessage-overflow-indicator) {
+ display: inline-flex;
+ position: absolute;
+ inset-block-end: token("spacing-100");
+ inset-inline-end: 0;
+ z-index: 1;
+ align-items: center;
+ justify-content: center;
+ min-inline-size: 88px;
+ min-block-size: 44px;
+ padding-block: 7px;
+ padding-inline: token("spacing-300");
font-family: token("sans-serif-font");
font-size: token("font-size-100");
font-weight: token("bold-font-weight");
line-height: token("line-height-font-size-100");
- color: token("gray-900");
-}
-
-.swc-UserMessage-subtitle {
- font-family: token("sans-serif-font");
- font-size: token("font-size-75");
- font-weight: token("regular-font-weight");
- line-height: token("line-height-font-size-75");
color: token("gray-800");
- text-overflow: ellipsis;
+ background: token("gray-100");
+ border: none;
+ border-radius: token("corner-radius-800");
+ cursor: pointer;
}
-:host([type="card"]) .swc-UserMessage-title,
-:host([type="card"]) .swc-UserMessage-subtitle,
-:host([type="media"]) .swc-UserMessage-title {
- inline-size: 100%;
- min-inline-size: 0;
- text-align: start;
+.swc-UserMessage-text-row {
+ max-inline-size: 536px;
}
-/* Long, unbroken tokens (e.g. filenames without spaces) break at any character
- * so the title wraps inside the meta column instead of overflowing the bubble. */
-:host([type="card"]) .swc-UserMessage-title ::slotted(*),
-:host([type="media"]) .swc-UserMessage-title ::slotted(*),
-:host([type="card"]) .swc-UserMessage-subtitle ::slotted(*),
-:host([type="media"]) .swc-UserMessage-subtitle ::slotted(*) {
+.swc-UserMessage-text-bubble {
display: block;
- min-inline-size: 0;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
+ inline-size: fit-content;
+ max-inline-size: 536px;
+ padding-block: token("spacing-100");
+ padding-inline: token("spacing-300");
+ font-family: token("sans-serif-font");
+ font-size: token("font-size-200");
+ font-weight: token("regular-font-weight");
+ line-height: token("line-height-200");
+ color: token("gray-800");
+ background: token("gray-50");
+ border: 1px solid transparent;
+ border-radius: token("spacing-200");
}
diff --git a/2nd-gen/packages/swc/patterns/conversational-ai/utils/icons/index.ts b/2nd-gen/packages/swc/patterns/conversational-ai/utils/icons/index.ts
index 4f36d07950e..3ac5a8eb865 100644
--- a/2nd-gen/packages/swc/patterns/conversational-ai/utils/icons/index.ts
+++ b/2nd-gen/packages/swc/patterns/conversational-ai/utils/icons/index.ts
@@ -51,6 +51,34 @@ export const ChevronUpIcon = (): TemplateResult => html`
`;
+/** Chevron-left icon — used for scroll-back controls. */
+export const ChevronLeftIcon = (): TemplateResult => html`
+
+
+
+`;
+
+/** Chevron-right icon — used for scroll-forward controls. */
+export const ChevronRightIcon = (): TemplateResult => html`
+
+
+
+`;
+
/** Three-dots icon — used for the loading / AI-thinking state. */
export const ThreeDotsIcon = (): TemplateResult => html`
diff --git a/2nd-gen/packages/swc/utils/test-utils.ts b/2nd-gen/packages/swc/utils/test-utils.ts
index c511d39d026..beb79ef6e18 100644
--- a/2nd-gen/packages/swc/utils/test-utils.ts
+++ b/2nd-gen/packages/swc/utils/test-utils.ts
@@ -12,21 +12,34 @@
import { render } from 'lit';
-type SwcTestGlobals = {
- warn: (...args: unknown[]) => void;
- DEBUG?: boolean;
- issuedWarnings?: Set;
-};
+type SwcGlobals = Window['__swc'];
-// Returns the shared SWC test globals, creating defaults when needed.
-export const getSwcTestGlobals = (): SwcTestGlobals => {
- const swcWindow = window as Window & { __swc?: SwcTestGlobals };
+const createDefaultSwcGlobals = (): SwcGlobals => ({
+ DEBUG: false,
+ warn: () => {},
+ issuedWarnings: new Set(),
+ ignoreWarningTypes: {
+ default: false,
+ accessibility: false,
+ api: false,
+ },
+ ignoreWarningLevels: {
+ default: false,
+ low: false,
+ medium: false,
+ high: false,
+ deprecation: false,
+ },
+ ignoreWarningLocalNames: {},
+});
- if (!swcWindow.__swc) {
- swcWindow.__swc = { warn: () => {} };
+// Returns the shared SWC test globals, creating defaults when needed.
+export const getSwcTestGlobals = (): SwcGlobals => {
+ if (!window.__swc) {
+ window.__swc = createDefaultSwcGlobals();
}
- return swcWindow.__swc;
+ return window.__swc;
};
// Enables debug warnings and captures warn calls for assertions.