From b8b068822998c859a98d4ba94270eba8f57484c6 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 18 Jun 2026 05:47:15 +1000 Subject: [PATCH 1/4] chore: Audit style props in AI package (#10218) * chore: Audit style props in AI package * more cleanup * get rid of unsafe and simplify to just use styleString also updated chat to be more consistent and other audit todos * add missing style props jsdoc * match toggleButtonGroup style prop type --------- Co-authored-by: Daniel Lu --- .../@react-spectrum/ai/src/AttachmentList.tsx | 25 ++++++--- packages/@react-spectrum/ai/src/Chat.tsx | 52 ++++++++++++------- .../@react-spectrum/ai/src/HorizontalCard.tsx | 2 +- .../ai/src/MessageFeedback.tsx | 12 ++--- .../@react-spectrum/ai/src/MessageSource.tsx | 36 +++---------- .../ai/src/MessageSuggestion.tsx | 12 +++-- .../@react-spectrum/ai/src/PromptField.tsx | 10 ++-- .../@react-spectrum/ai/src/ResponseStatus.tsx | 11 ++-- .../@react-spectrum/ai/src/UserMessage.tsx | 2 +- packages/@react-spectrum/ai/src/types.ts | 13 ----- .../ai/stories/Chat.stories.tsx | 38 ++++---------- .../src/gridlist/useGridListItem.ts | 2 - .../src/selection/ListKeyboardDelegate.ts | 5 +- 13 files changed, 91 insertions(+), 129 deletions(-) delete mode 100644 packages/@react-spectrum/ai/src/types.ts diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index 8881b4426b0..a56dcf1b323 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -10,7 +10,13 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, DOMRef, forwardRefType, GlobalDOMAttributes} from '@react-types/shared'; +import { + AriaLabelingProps, + DOMProps, + DOMRef, + forwardRefType, + GlobalDOMAttributes +} from '@react-types/shared'; import {baseColor, focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {BasicHorizontalCard} from './HorizontalCard'; import {Button} from 'react-aria-components/Button'; @@ -22,7 +28,7 @@ import {ImageContext} from '@react-spectrum/s2/Image'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {pressScale} from '@react-spectrum/s2/pressScale'; import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; -import {StyleString} from './types'; +import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import { Tag, TagGroup, @@ -120,6 +126,7 @@ const CloseButton = function CloseButton(props) { export interface AttachmentListProps extends + DOMProps, Omit< TagGroupProps, | 'children' @@ -148,12 +155,13 @@ export const AttachmentList = (forwardRef as forwardRefType)(function Attachment props: AttachmentListProps, ref: DOMRef ) { + let {styles, items, children, dependencies, ...otherProps} = props; let domRef = useDOMRef(ref); return ( - + - {props.children} + {children} ); @@ -172,7 +180,7 @@ export interface AttachmentProps extends Omit, AriaLabelingProps, - Pick { + Pick { /** The children of the Attachment. */ children: ReactNode | ((renderProps: AttachmentRenderProps) => ReactNode); uploadProgress?: number; @@ -200,6 +208,7 @@ export const Attachment = forwardRef(function Attachment( 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-describedby': ariaDescribedby, + styles, ...otherProps } = props; let domRef = useDOMRef(ref); @@ -211,7 +220,7 @@ export const Attachment = forwardRef(function Attachment( aria-labelledby={ariaLabelledby} aria-describedby={ariaDescribedby} ref={domRef} - className={renderProps => mergeStyles(tagStyles({...renderProps}), props.styles)}> + className={renderProps => mergeStyles(tagStyles({...renderProps}), styles)}> {props.uploadProgress != null && props.uploadProgress < 100 && (
( // TODO: make this more RAC like (aka default class name and other RAC prop) export interface ChatProps { - className?: string; - style?: CSSProperties; + /** + * Spectrum-defined styles, returned by the `style()` macro. + */ + styles?: StyleString; + /** + * Children of the chat, such as Thread, PromptField, and ThreadScrollButton. + */ children?: ReactNode; } @@ -101,7 +106,7 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( props: ChatProps, ref: DOMRef ) { - let {children, className, style} = props; + let {children, styles} = props; let domRef = useDOMRef(ref); let isFieldFocusedRef = useRef(false); let isChatFocusWithinRef = useRef(false); @@ -190,24 +195,28 @@ export const Chat = /*#__PURE__*/ (forwardRef as forwardRefType)(function Chat( } ] ]}> -
+
{children}
); }); -// TODO: update the items/className/children/etc type to reflect a thread specific classname once we finalize API export interface ThreadProps extends Pick< GridListProps, - 'items' | 'children' | 'UNSTABLE_focusOnEntry' | 'aria-label' | 'aria-labelledby' | 'className' -> {} + 'items' | 'children' | 'UNSTABLE_focusOnEntry' | 'aria-label' | 'aria-labelledby' +> { + /** + * Spectrum-defined styles, returned by the `style()` macro. + */ + styles?: StyleString; +} export function Thread(props: ThreadProps) { let { items, children, - className, + styles, UNSTABLE_focusOnEntry, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby @@ -267,7 +276,7 @@ export function Thread(props: ThreadProps) { boxSizing: 'border-box', minWidth: 0 }} - className={className}> + className={styles}> {children} ); @@ -314,11 +323,16 @@ function ThreadScrollButtonInner({domRef, isExiting, children}: ThreadScrollButt ); } -// TODO: update the className type to reflect a thread specific classname once we finalize API -export interface ThreadItemProps extends Pick< - GridListItemProps, - 'className' | 'children' | 'textValue' -> { +const threadItemBase = style({ + ...focusRing(), + borderRadius: 'default' +}); + +export interface ThreadItemProps extends Pick { + /** + * Spectrum-defined styles, returned by the `style()` macro. + */ + styles?: StyleString; /** Whether or not the item's content is currently being streamed in. */ isStreaming?: boolean; /** Announce textValue on mount even when isStreaming is provided. */ @@ -326,7 +340,7 @@ export interface ThreadItemProps extends Pick< } export function ThreadItem(props: ThreadItemProps) { - let {className, children, textValue = ' ', isStreaming, shouldAnnounceOnMount} = props; + let {styles, children, textValue = ' ', isStreaming, shouldAnnounceOnMount} = props; let {announceItem} = useContext(InternalChatContext); // TODO: using aria-live on the gridlist item was pretty chatty and the streaming causes the text announcement @@ -354,7 +368,9 @@ export function ThreadItem(props: ThreadItemProps) { }, [isStreaming, isStreamingNow, textValue, announceItem]); return ( - + mergeStyles(threadItemBase({...renderProps}), styles)}> {children} ); diff --git a/packages/@react-spectrum/ai/src/HorizontalCard.tsx b/packages/@react-spectrum/ai/src/HorizontalCard.tsx index 4135e44dbb3..df6d6ad7bcc 100644 --- a/packages/@react-spectrum/ai/src/HorizontalCard.tsx +++ b/packages/@react-spectrum/ai/src/HorizontalCard.tsx @@ -36,7 +36,7 @@ import {LinkButtonContext} from '@react-spectrum/s2/LinkButton'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {pressScale} from '@react-spectrum/s2/pressScale'; import {SkeletonContext, useIsSkeleton} from '@react-spectrum/s2/Skeleton'; -import {StyleString} from './types'; +import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import {TextContext} from '@react-spectrum/s2/Text'; import {useDOMRef} from './useDOMRef'; interface HorizontalCardRenderProps { diff --git a/packages/@react-spectrum/ai/src/MessageFeedback.tsx b/packages/@react-spectrum/ai/src/MessageFeedback.tsx index fb34798849f..2f692b858b8 100644 --- a/packages/@react-spectrum/ai/src/MessageFeedback.tsx +++ b/packages/@react-spectrum/ai/src/MessageFeedback.tsx @@ -16,7 +16,7 @@ import {forwardRef} from 'react'; // @ts-ignore import intlMessages from '../intl/*.json'; import {SlotProps} from 'react-aria-components/slots'; -import {StyleString} from './types'; +import {StylesPropWithHeight} from '@react-spectrum/s2'; import ThumbDown from '@react-spectrum/s2/icons/ThumbDown'; import ThumbUp from '@react-spectrum/s2/icons/ThumbUp'; import {ToggleButton} from '@react-spectrum/s2/ToggleButton'; @@ -39,10 +39,8 @@ export interface MessageFeedbackProps extends DOMProps, AriaLabelingProps, SlotP thumbUpLabel?: string; /** Accessible label for the thumbs down button. */ thumbDownLabel?: string; - /** - * Spectrum-defined styles, returned by the `style()` macro. - */ - styles?: StyleString; + /** Spectrum-defined styles, returned by the `style()` macro. */ + styles?: StylesPropWithHeight; } function selectionToValue(selection: Selection): MessageFeedbackValue { @@ -83,9 +81,7 @@ export const MessageFeedback = forwardRef(function MessageFeedback( defaultSelectedKeys={defaultSelectedKeys} onSelectionChange={handleSelectionChange} isDisabled={isDisabled} - // Pass styles to UNSAFE because S2 ToggleButtonGroup styles have type StylesPropWithHeight which restrict what can be overridden - //@ts-ignore - UNSAFE_className={styles}> + styles={styles}> { label: string; - /** - * Spectrum-defined styles, returned by the `style()` macro. - */ - styles?: StyleString; } const MessageSourceInternalContext = createContext<{size: 'S' | 'M' | 'L' | 'XL'}>({size: 'M'}); @@ -62,13 +58,7 @@ export const MessageSource = (forwardRef as forwardRefType)(function MessageSour return ( - + {label} {children} @@ -96,15 +86,7 @@ const itemStyles = style({ gap: 8 }); -export interface SourceListProps extends Omit< - DisclosurePanelProps, - 'styles' | 'UNSAFE_className' | 'UNSAFE_style' -> { - /** - * Spectrum-defined styles, returned by the `style()` macro. - */ - styles?: StyleString; -} +export interface SourceListProps extends DisclosurePanelProps {} /** * A SourceList displays an ordered list of sources inside a MessageSource. @@ -114,18 +96,14 @@ export const SourceList = (forwardRef as forwardRefType)(function SourceList( props: SourceListProps, ref: DOMRef ) { - let {children, styles, ...otherProps} = props; + let {children, ...otherProps} = props; let numberedChildren = React.Children.map(children, (child, i) => ( {child} )); return ( - +
    {numberedChildren}
); @@ -147,7 +125,7 @@ const linkStyles = style({ }); export interface SourceListItemProps - extends Omit, DOMProps { + extends Omit, DOMProps { /** The content of the source list item. */ children: React.ReactNode; /** diff --git a/packages/@react-spectrum/ai/src/MessageSuggestion.tsx b/packages/@react-spectrum/ai/src/MessageSuggestion.tsx index 88eef065af0..e7fa524286a 100644 --- a/packages/@react-spectrum/ai/src/MessageSuggestion.tsx +++ b/packages/@react-spectrum/ai/src/MessageSuggestion.tsx @@ -26,7 +26,7 @@ import {IconContext} from '@react-spectrum/s2/Icon'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {pressScale} from '@react-spectrum/s2'; import {Provider, SlotProps} from 'react-aria-components/slots'; -import {StyleString} from './types'; +import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import {useDOMRef} from './useDOMRef'; import {useLocale} from 'react-aria/I18nProvider'; @@ -42,15 +42,17 @@ const controlSizeM = { export interface MessageSuggestionProps extends Omit< ButtonProps, - 'style' | 'className' | 'isPending' | 'isDisabled' | keyof GlobalDOMAttributes + 'style' | 'className' | 'isPending' | 'isDisabled' | 'render' | keyof GlobalDOMAttributes > { /** The text content of the suggestion. */ children: ReactNode; - /** The size of the MessageSuggestion. */ - size?: 'S' | 'M' | 'L' | 'XL'; /** - * Spectrum-defined styles, returned by the `style()` macro. + * The size of the MessageSuggestion. + * + * @default 'M' */ + size?: 'S' | 'M' | 'L' | 'XL'; + /** Spectrum-defined styles, returned by the `style()` macro. */ styles?: StyleString; } diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 66851d662b0..7614bcae7d3 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -37,7 +37,7 @@ import { } from './TokenSegmentList'; import {getEventTarget} from 'react-aria/private/utils/shadowdom/DOMFunctions'; import {Group} from 'react-aria-components/Group'; -import {IconContext, mergeStyles, UnsafeStyles} from '@react-spectrum/s2'; +import {IconContext, mergeStyles} from '@react-spectrum/s2'; import {Image, Text} from '@react-spectrum/s2/Card'; import {isFileDropItem, useDrop} from 'react-aria-components/useDrop'; import {isFocusable} from 'react-aria/private/utils/isFocusable'; @@ -58,7 +58,7 @@ export interface PromptFieldAttachment { image: string; } -export interface PromptFieldProps extends UnsafeStyles { +export interface PromptFieldProps { children: React.ReactNode; acceptedAttachmentTypes?: string[]; onSubmit?: (prompt: TokenSegmentList, attachments: PromptFieldAttachment[]) => void; @@ -139,8 +139,6 @@ export function PromptField(props: PromptFieldProps) { acceptedAttachmentTypes, isGenerating, onStop, - UNSAFE_className = '', - UNSAFE_style, styles, onAddAttachments, onRemoveAttachments @@ -207,9 +205,7 @@ export function PromptField(props: PromptFieldProps) { - UNSAFE_className + mergeStyles( style({ display: 'flex', @@ -457,7 +453,7 @@ function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) { ); } -export interface PromptTokenProps extends Omit { +export interface PromptTokenProps extends Omit { children: React.ReactNode; } diff --git a/packages/@react-spectrum/ai/src/ResponseStatus.tsx b/packages/@react-spectrum/ai/src/ResponseStatus.tsx index 786f9120609..9e0a66cc424 100644 --- a/packages/@react-spectrum/ai/src/ResponseStatus.tsx +++ b/packages/@react-spectrum/ai/src/ResponseStatus.tsx @@ -39,7 +39,7 @@ import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; import {Provider} from 'react-aria-components/slots'; import React, {createContext, forwardRef, ReactNode, useContext} from 'react'; -import {StyleString} from './types'; +import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import {useDOMRef} from './useDOMRef'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; @@ -321,6 +321,9 @@ export interface ResponseStatusPanelProps DOMProps, AriaLabelingProps { children: React.ReactNode; + /** + * Spectrum-defined styles, returned by the `style()` macro. + */ styles?: StyleString; } @@ -355,14 +358,12 @@ export const ResponseStatusPanel = forwardRef(function ResponseStatusPanel( props: ResponseStatusPanelProps, ref: DOMRef ) { + let {styles} = props; let {size = 'M'} = useContext(ResponseStatusContext)!; const domProps = filterDOMProps(props); let panelRef = useDOMRef(ref); return ( - +
{props.children}
); diff --git a/packages/@react-spectrum/ai/src/UserMessage.tsx b/packages/@react-spectrum/ai/src/UserMessage.tsx index 4415b3b687a..29b5d91677b 100644 --- a/packages/@react-spectrum/ai/src/UserMessage.tsx +++ b/packages/@react-spectrum/ai/src/UserMessage.tsx @@ -17,7 +17,7 @@ import {forwardRef, ReactNode} from 'react'; import {ImageContext} from '@react-spectrum/s2/Image'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; -import {StyleString} from './types'; +import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import {useDOMRef} from './useDOMRef'; export interface UserMessageProps extends DOMProps, AriaLabelingProps, SlotProps { diff --git a/packages/@react-spectrum/ai/src/types.ts b/packages/@react-spectrum/ai/src/types.ts deleted file mode 100644 index 0d0f5866676..00000000000 --- a/packages/@react-spectrum/ai/src/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright 2024 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. - */ - -export type StyleString

= string & {properties: P}; diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index aab79d76610..23d0794fc81 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -16,7 +16,6 @@ import {AssetCard, CardPreview} from '@react-spectrum/s2/Card'; import {Chat} from '../src/Chat'; import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; import {Content} from '@react-spectrum/s2/Content'; -import {focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {GridList} from 'react-aria-components'; import {Image} from '@react-spectrum/s2/Image'; import {ListLayout} from 'react-stately/useVirtualizerState'; @@ -42,6 +41,7 @@ import { } from '@react-spectrum/ai'; import type {Meta} from '@storybook/react'; import {ReactNode, useRef, useState} from 'react'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {Text} from '@react-spectrum/s2/Text'; import {Virtualizer} from 'react-aria-components/Virtualizer'; @@ -130,7 +130,7 @@ function CardMessage({ imageUrl: string; }) { return ( - + @@ -365,7 +365,7 @@ export function StreamingChat() { height: '100%' })}> + styles={style({display: 'flex', justifyContent: 'end'})}> {msg.content} ); @@ -437,8 +432,7 @@ export function StreamingChat() { + shouldAnnounceOnMount> {title} @@ -463,9 +457,7 @@ export function StreamingChat() { // TODO: probably should have ThreadItem auto wrap MessageSuggestionList as well // but this one I could see perhaps being a standalone component to be used outside of thread return ( - + {msg.suggestions.map((s, i) => ( {s} @@ -559,12 +551,7 @@ export function VirtualizedChat() { return ( + styles={style({borderRadius: 'lg', display: 'flex', justifyContent: 'end'})}> {msg.content} ); @@ -574,9 +561,7 @@ export function VirtualizedChat() { let message = isPending ? 'Generating response' : 'Response generated'; return ( - + {message} @@ -616,10 +601,7 @@ function SystemMessage({ sources?: string[]; }) { return ( - + {children} {sources && sources.length > 0 && ( diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index f352fc824f4..511bf86fa8d 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -362,8 +362,6 @@ export function useGridListItem( id: getRowId(state, node.key) }); - // TODO: guarding against selection when firing space/enter/click on a element in a row is technically not only limited to textfields so I - // am not making it specific to keyboardNavigationBehavior = tab, but maybe we should still? // we need to guard against space/enter triggering selection/row link via usePress (from itemProps) so check if propagation // is stopped. this also fixes space not working in a textfield in a tree parent row let baseOnKeyDown = rowProps.onKeyDown; diff --git a/packages/react-aria/src/selection/ListKeyboardDelegate.ts b/packages/react-aria/src/selection/ListKeyboardDelegate.ts index ee89a2a7fa0..06e736447c6 100644 --- a/packages/react-aria/src/selection/ListKeyboardDelegate.ts +++ b/packages/react-aria/src/selection/ListKeyboardDelegate.ts @@ -312,8 +312,7 @@ export class ListKeyboardDelegate implements KeyboardDelegate { } } else { let visibleRect = this.layoutDelegate.getVisibleRect(); - // TODO: column reverse makes y negative for items so we need to instead do current pos - height instead - // will need to revist for virtualized reverse layouts? + // column reverse makes y negative for items so we need to instead do current pos - height instead let pageY = reversed ? itemRect.y - visibleRect.height : Math.max(0, itemRect.y + itemRect.height - visibleRect.height); @@ -324,7 +323,6 @@ export class ListKeyboardDelegate implements KeyboardDelegate { } } - // TODO: in column reverse, the top most key is the last key return nextKey ?? (reversed ? this.getLastKey() : this.getFirstKey()); } @@ -363,7 +361,6 @@ export class ListKeyboardDelegate implements KeyboardDelegate { } } - // TODO: in column reverse, the bottom most key is the first key return nextKey ?? (reversed ? this.getFirstKey() : this.getLastKey()); } From 05949146c0e72a3f3fc0fa9ecb6c33b9b2efdfec Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:32:05 -0700 Subject: [PATCH 2/4] chore: update chat story prompts to be more realistic (#10223) --- .../ai/stories/Chat.stories.tsx | 104 ++++++++++++++---- 1 file changed, 82 insertions(+), 22 deletions(-) diff --git a/packages/@react-spectrum/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index 23d0794fc81..ae2c03cd45a 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -74,18 +74,78 @@ type Message = | {id: number; type: 'status'; status: 'pending' | 'complete'}; let initialResponses = [ - {id: 0, type: 'user', content: 'prompt 1'}, - {id: 1, type: 'system', content: dummyResponses[0]}, - {id: 2, type: 'user', content: 'prompt 2'}, - {id: 3, type: 'system', content: dummyResponses[1]}, - {id: 4, type: 'user', content: 'prompt 3'}, - {id: 5, type: 'system', content: dummyResponses[2]}, - {id: 6, type: 'user', content: 'prompt 4'}, - {id: 7, type: 'system', content: dummyResponses[0]}, - {id: 8, type: 'user', content: 'prompt 5'}, - {id: 9, type: 'system', content: dummyResponses[1]}, - {id: 10, type: 'user', content: 'prompt 6'}, - {id: 11, type: 'system', content: dummyResponses[2]} + { + id: 0, + type: 'user', + content: + "I'm launching a new product next month—a sustainable, affordable athletic wear line targeting Gen Z. We have a limited budget and need to figure out our go-to-market strategy. Where do we even start?" + }, + { + id: 1, + type: 'system', + content: + "Exciting launch! A few things to clarify so I give you relevant guidance: (1) What's your total marketing budget for the launch month? (2) Do you already have an email list, social following, or any existing audience? (3) Who's your main competitor, and what are they doing? (4) Is this DTC (direct-to-consumer) or are you selling through retailers? These will shape whether we focus on paid ads, influencers, organic content, or a mix." + }, + { + id: 2, + type: 'user', + content: + "We have about $15,000 for the first month. We're DTC only, launching on our own website. We have a small email list of about 2,000 people from a waitlist, and basically no social following yet. Our main competitor is Reformation, but they're way more established. We want to emphasize our sustainability angle and lower price point." + }, + { + id: 3, + type: 'system', + content: + "Okay, $15K is modest but workable for DTC. Here's my initial take: Since you're starting from zero on social and have a small email list, I'd suggest a three-part approach: (1) Allocate ~$8K to highly targeted paid ads (TikTok and Instagram) reaching eco-conscious Gen Z with strong creative, (2) Use $3K for micro-influencer partnerships (people with 10K-100K followers in the sustainability space—cheaper but authentic), (3) Keep $4K for email and organic content. Before we dive deeper—what's your product's main hook? Is it the materials, the price, the designs, or the transparency of your supply chain?" + }, + { + id: 4, + type: 'user', + content: + "The main hook is that we use recycled ocean plastic and sell at 40% lower prices than Reformation. Our supply chain is also fully transparent. We've got 15 styles launching across tops, shorts, and leggings." + }, + { + id: 5, + type: 'system', + content: + "Perfect—that's a strong, differentiated message. Here's what I'd focus on: The ocean plastic angle is visually compelling and emotionally resonant with Gen Z; the price-to-quality ratio is your competitive advantage against Reformation. For your first month, I'd recommend this campaign narrative: 'High performance, zero guilt, actual prices.' Now, a strategic question: Do you want to go all-in on one or two channels, or spread across TikTok, Instagram, and YouTube? Gen Z is on TikTok and Instagram, but each requires different content styles. What does your creative team feel most confident producing?" + }, + { + id: 6, + type: 'user', + content: + "We can handle both TikTok and Instagram, but honestly TikTok feels more authentic for our brand. We have some behind-the-scenes footage of our production process and some early customer testimonials. Instagram we'd probably do more polished product shots." + }, + { + id: 7, + type: 'system', + content: + "Smart instinct—TikTok is perfect for you because behind-the-scenes and authenticity is the content style there, and sustainability messaging performs really well. That said, here's my suggestion: (1) Use TikTok primarily for awareness and virality—behind-the-scenes, sustainability stories, affordability messaging. Spend $5K there. (2) Use Instagram for conversion—polished product shots, customer testimonials, sustainability proof. Spend $3K there. (3) With your micro-influencers ($3K), focus on TikTok creators first since that's where your audience is. One question though: Do you have a clear conversion path ready? Like, when someone clicks your ad, what's the landing page experience? Are you doing a discount code to incentivize first purchase?" + }, + { + id: 8, + type: 'user', + content: + "We have the website ready, but we haven't thought about a launch discount. Should we do one? Won't that hurt our perceived value?" + }, + { + id: 9, + type: 'system', + content: + "Great question—common concern. For a DTC launch with limited awareness, I'd actually recommend a strategic discount, not a deep one. Something like 15-20% off for the first 48 hours, with messaging like 'Early founder pricing' or 'Launch week exclusive.' Why? It creates urgency, lowers the barrier for first-time buyers trying an unknown brand, and you get email addresses and reviews to leverage later. You're not training people to wait for sales—you're using urgency to convert. Pair it with clear value messaging (ocean plastic, transparent supply chain, high quality) so it doesn't feel cheap. Also, include a referral incentive—Gen Z shares—maybe $10 off for them and the referee. This turns customers into advocates. Does that strategy feel right for your brand?" + }, + { + id: 10, + type: 'user', + content: + 'That makes sense. Before we move forward, can you summarize the full plan and tell me what could go wrong?' + }, + { + id: 11, + type: 'system', + content: + "Absolutely. Your Launch Plan: $8K paid ads (TikTok $5K, Instagram $3K) with behind-the-scenes and product content; $3K micro-influencer partnerships on TikTok; $4K allocated to email nurture and organic/referral growth. 15-20% launch discount for 48 hours. What could go wrong: (1) Creative underperforms—test multiple ad versions early and kill losers fast, (2) Website crashes or checkout breaks—stress test before launch, (3) Influencers don't deliver—vet their audiences carefully and look for engagement rates, not just follower count, (4) You don't have enough inventory—make sure supply matches demand projections, (5) Customer acquisition cost exceeds your margins—monitor CAC closely in week one and adjust spending. My advice: Launch on a Tuesday or Wednesday so you have time to optimize before the weekend. Track everything obsessively in the first 72 hours—CAC, conversion rate, email signup rate. Be ready to shift budget if one channel is outperforming. Want me to help you build a monitoring dashboard or refine your email launch sequence next?" + } ] as Message[]; type StreamingMessage = @@ -236,7 +296,7 @@ export function StreamingChat() { // faster and thus the announcements will get cut off even with polite... // first batch, does tool calls with text response let timestamp = 0; - let toolCallDuration = 4000; + let toolCallDuration = 1000; // Status added after short delay so user message announcement plays first addTimeout( () => { @@ -251,9 +311,9 @@ export function StreamingChat() { } ]); }, - (timestamp += 1000) + (timestamp += 500) ); - addTimeout(() => addTool('Thinking', true), (timestamp += 1000)); + addTimeout(() => addTool('Thinking', true), (timestamp += 500)); addTimeout( () => completeTool( @@ -261,12 +321,12 @@ export function StreamingChat() { ), (timestamp += toolCallDuration) ); - addTimeout(() => addTool('Loading tool'), (timestamp += 1000)); + addTimeout(() => addTool('Loading tool'), (timestamp += 500)); addTimeout( () => completeTool('Asset search tool loaded with access to the Hilton brand library.'), (timestamp += toolCallDuration) ); - addTimeout(() => addTool('Searching'), (timestamp += 1000)); + addTimeout(() => addTool('Searching'), (timestamp += 500)); addTimeout( () => completeTool('Found 15 assets matching the brand criteria across 3 campaigns.'), (timestamp += toolCallDuration) @@ -276,11 +336,11 @@ export function StreamingChat() { streamText( 'I found some relevant assets that match your request. Let me pull up the details.' ), - (timestamp += 1000) + (timestamp += 500) ); // then does searching, streaming more text, returning a card and sources - addTimeout(() => addTool('Searching'), (timestamp += 4000)); + addTimeout(() => addTool('Searching'), (timestamp += 1000)); addTimeout( () => completeTool('Identified additional brand materials related to the presentation context.'), @@ -321,7 +381,7 @@ export function StreamingChat() { 'The user shared Hilton brand assets and is asking for a presentation outline. I analyzed the visual themes and brand guidelines to suggest a narrative structure that aligns with the hospitality brand identity.' } ]), - (timestamp += 2000) + (timestamp += 1000) ); addTimeout( () => @@ -331,10 +391,10 @@ export function StreamingChat() { 'revolve around brand consistency, audience engagement, and clear calls to action.', MOCK_SOURCES ), - (timestamp += 1000) + (timestamp += 500) ); - let streamEndTimestamp = timestamp + 8000; + let streamEndTimestamp = timestamp + 500; addTimeout(() => { setMessages(prev => [...prev, {id: nextId.current++, type: 'card', ...MOCK_CARD}]); }, streamEndTimestamp); From a064c190640ee83c1d266a95dc3fe45f6581e579 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Wed, 17 Jun 2026 16:38:03 -0500 Subject: [PATCH 3/4] docs: add S2 audit skill (#10160) * initialize spectrum-audit skill * clean up skill * add rule about Interactive components in ListView/TreeView --- .../s2-docs/scripts/generateAgentSkills.mjs | 105 ++++++++++++++++++ .../spectrum-audit/checks/01-setup-config.md | 53 +++++++++ .../checks/02-component-usage.md | 42 +++++++ .../spectrum-audit/checks/03-styling.md | 47 ++++++++ .../spectrum-audit/checks/04-accessibility.md | 30 +++++ .../spectrum-audit/checks/05-versioning.md | 18 +++ .../spectrum-audit/checks/06-testing.md | 12 ++ .../skills/spectrum-audit/report-template.md | 72 ++++++++++++ .../skills/spectrum-audit/scoring-rubric.md | 57 ++++++++++ 9 files changed, 436 insertions(+) create mode 100644 packages/dev/s2-docs/skills/spectrum-audit/checks/01-setup-config.md create mode 100644 packages/dev/s2-docs/skills/spectrum-audit/checks/02-component-usage.md create mode 100644 packages/dev/s2-docs/skills/spectrum-audit/checks/03-styling.md create mode 100644 packages/dev/s2-docs/skills/spectrum-audit/checks/04-accessibility.md create mode 100644 packages/dev/s2-docs/skills/spectrum-audit/checks/05-versioning.md create mode 100644 packages/dev/s2-docs/skills/spectrum-audit/checks/06-testing.md create mode 100644 packages/dev/s2-docs/skills/spectrum-audit/report-template.md create mode 100644 packages/dev/s2-docs/skills/spectrum-audit/scoring-rubric.md diff --git a/packages/dev/s2-docs/scripts/generateAgentSkills.mjs b/packages/dev/s2-docs/scripts/generateAgentSkills.mjs index 8815e2e12c2..ca793ea3b5f 100644 --- a/packages/dev/s2-docs/scripts/generateAgentSkills.mjs +++ b/packages/dev/s2-docs/scripts/generateAgentSkills.mjs @@ -28,6 +28,7 @@ const MARKDOWN_DOCS_DIST = path.join(REPO_ROOT, 'packages/dev/s2-docs/dist'); const MDX_PAGES_DIR = path.join(REPO_ROOT, 'packages/dev/s2-docs/pages'); const MARKDOWN_DOCS_SCRIPT = path.join(__dirname, 'generateMarkdownDocs.mjs'); const MIGRATION_REFS_DIR = path.join(REPO_ROOT, 'packages/dev/s2-docs/migration-references'); +const AUDIT_SKILL_SOURCE_DIR = path.join(REPO_ROOT, 'packages/dev/s2-docs/skills/spectrum-audit'); const WELL_KNOWN_DIR = '.well-known'; const WELL_KNOWN_SKILLS_DIR = 'skills'; @@ -79,6 +80,19 @@ const SKILLS = { author: 'Adobe', website: 'https://react-aria.adobe.com/' } + }, + 'spectrum-audit': { + name: 'spectrum-audit', + description: + 'Audit a codebase for adherence to the Spectrum design system and React Spectrum S2 best practices. Use when a developer asks to audit, review, lint, or check a project for Spectrum/S2 correctness, configuration, styling, accessibility, or component-usage issues.', + kind: 'audit', + license: 'Apache-2.0', + sourceDir: 's2', + compatibility: 'Requires a React project using @react-spectrum/s2.', + metadata: { + author: 'Adobe', + website: 'https://react-spectrum.adobe.com/' + } } }; @@ -665,6 +679,65 @@ Use these when you need more component-by-component or API-level detail: ); } +function generateAuditSkillMd(skillConfig) { + return ( + `${generateFrontmatter(skillConfig)}# Spectrum Audit + +Audit a codebase for adherence to the Spectrum design system and React Spectrum S2 best practices, then produce a scored, prioritized report. This skill is **report-only** — it does not modify any files. + +## When to use + +Use when a developer asks to audit, review, lint, or check a project for Spectrum / S2 correctness, configuration, styling, accessibility, or component-usage issues. + +Requires a project with \`@react-spectrum/s2\` installed. If S2 is not a dependency, say so and stop. + +## How it works + +Run these phases in order. + +### Phase 0 — Scope + +- Identify the project — or, in a monorepo, the specific package — to audit. Audit the package, not the workspace root. +- Detect the package manager from the lockfile, the bundler (Vite / webpack / Next.js / Parcel / Rollup / ESBuild), and read \`package.json\` dependencies. +- Confirm \`@react-spectrum/s2\` is installed. Establish the source globs to scan (e.g. \`src/**/*.{tsx,jsx,ts,js}\`). +- Check whether the \`react-spectrum-s2\` skill is available (listed among the agent's installed skills, or present under a local skills directory such as \`.cursor/skills/\` or \`.agents/skills/\`). Record whether it was found — the report template uses this. + +### Phase 1 — Run the checks + +Work through each check file in \`references/checks/\`, in order. For every violation, record a finding: \`{file:line, rule, severity, category, fix}\`. Cite only line numbers you actually read or grepped — never invent locations. Record **one finding per distinct root cause** at a \`file:line\` — if multiple check files cover the same issue (e.g. a missing collection \`aria-label\` or a third-party design system), assign it to the most specific check and do not duplicate it across categories. + +- [01 — Setup & configuration](references/checks/01-setup-config.md) +- [02 — Component usage](references/checks/02-component-usage.md) +- [03 — Styling](references/checks/03-styling.md) +- [04 — Accessibility & correctness](references/checks/04-accessibility.md) +- [05 — Versioning & maintenance](references/checks/05-versioning.md) +- [06 — Testing](references/checks/06-testing.md) + +The canonical rules behind these checks live in the \`react-spectrum-s2\` skill — read its \`SKILL.md\` (implementation guidance) and \`references/guides/getting-started.md\`, \`references/guides/component-decision-tree.md\`, and \`references/components/\` as needed. If that skill is not installed, proceed using the check files alone, but note the limitation in the report (see Phase 3). + +### Phase 2 — Score + +Apply the [scoring rubric](references/scoring-rubric.md) to the recorded findings to compute per-category scores and the overall Spectrum Adherence Score. The score is arithmetic over counted findings — do not estimate it. + +### Phase 3 — Report + +Write \`SPECTRUM-AUDIT.md\` to the audited project following the [report template](references/report-template.md): headline score, grade, and severity counts; a summary; scores by category; findings grouped by severity (each with a clickable \`file:line\` and the check name that defines its rule); prioritized action items; what looks good; and — if \`react-spectrum-s2\` was not detected in Phase 0 — a recommendation to install it. + +### Phase 4 — Hand off + +This skill does not edit code. Recommend: + +- The \`react-spectrum-s2\` skill to implement the fixes. If it is not installed: + + \`\`\`bash + npx skills add https://react-spectrum.adobe.com --skill react-spectrum-s2 + \`\`\` + +- The \`migrate-react-spectrum-v3-to-s2\` skill if Spectrum 1 packages (\`@adobe/react-spectrum\`, \`@react-spectrum/*\`, \`@spectrum-icons/*\`) are present. +`.trimEnd() + '\n' + ); +} + /** * Copy documentation files to the skill's references directory. */ @@ -823,6 +896,26 @@ function writeMigrationReferences(skillDir, sourceDir) { ]); } +function writeAuditReferences(skillDir) { + const refsDir = path.join(skillDir, 'references'); + fs.mkdirSync(refsDir, {recursive: true}); + + // Copy authored audit check files. + const checksSourceDir = path.join(AUDIT_SKILL_SOURCE_DIR, 'checks'); + const checksTargetDir = path.join(refsDir, 'checks'); + fs.mkdirSync(checksTargetDir, {recursive: true}); + for (const file of fs.readdirSync(checksSourceDir)) { + if (file.endsWith('.md')) { + fs.copyFileSync(path.join(checksSourceDir, file), path.join(checksTargetDir, file)); + } + } + + // Copy the authored scoring rubric and report template. + for (const file of ['scoring-rubric.md', 'report-template.md']) { + fs.copyFileSync(path.join(AUDIT_SKILL_SOURCE_DIR, file), path.join(refsDir, file)); + } +} + function collectSkillFiles(skillDir) { const files = []; @@ -918,6 +1011,18 @@ function generateSkill(skillConfig, wellKnownRoot) { return skillDir; } + if (skillConfig.kind === 'audit') { + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), generateAuditSkillMd(skillConfig)); + console.log(`Generated ${path.relative(REPO_ROOT, path.join(skillDir, 'SKILL.md'))}`); + + writeAuditReferences(skillDir); + console.log( + `Copied audit references to ${path.relative(REPO_ROOT, path.join(skillDir, 'references'))}` + ); + + return skillDir; + } + const isS2 = skillConfig.name === 'react-spectrum-s2'; // Parse documentation entries diff --git a/packages/dev/s2-docs/skills/spectrum-audit/checks/01-setup-config.md b/packages/dev/s2-docs/skills/spectrum-audit/checks/01-setup-config.md new file mode 100644 index 00000000000..572c454695a --- /dev/null +++ b/packages/dev/s2-docs/skills/spectrum-audit/checks/01-setup-config.md @@ -0,0 +1,53 @@ +# Check 01 — Setup & configuration + +Validates that the project is configured the way the `react-spectrum-s2` skill specifies. + +Read first: `react-spectrum-s2` skill → `references/guides/getting-started.md` (canonical setup), plus the project's `package.json` and bundler config file. + +## Detection inputs (gathered in Phase 0) + +- **Package manager:** lockfile — `yarn.lock`, `package-lock.json`, or `pnpm-lock.yaml`. +- **Bundler:** `vite.config.*`, `webpack.config.*`, `next.config.*`, `.parcelrc` / a `parcel` dependency, `rollup.config.*`, or an esbuild build script. +- **Installed deps:** `dependencies` / `devDependencies` in `package.json`. + +## Checks + +### `@react-spectrum/s2` is installed +- **Detect:** `@react-spectrum/s2` present in `package.json`. If absent, the audit does not apply — stop and tell the user. +- **Severity:** — (precondition) + +### Style macro plugin configured +- **Rule:** Non-Parcel bundlers must wire `unplugin-parcel-macros` into the build; Parcel needs ≥ 2.12.0. Without it, `style({...})` calls are never evaluated at build time and components render unstyled. +- **Detect:** + - Vite/Rollup/React Router/ESBuild/webpack/Next: `unplugin-parcel-macros` in deps **and** referenced in the bundler config (`macros.vite()`, `macros.webpack()`, `macros.rollup()`, `macros.esbuild()`). + - Parcel: `parcel` ≥ 2.12.0 (macros are built in) — no plugin needed. + - Next.js: also confirm the app starts with `--webpack` (not Turbopack) — check the `dev`/`build` scripts. `unplugin-parcel-macros` does not work with Turbopack. +- **Severity:** Critical. + +### CSS bundle optimization +- **Rule:** All S2 + macro-generated CSS should be combined into a single shared `s2-styles` bundle rather than code-split per route. Atomic CSS overlaps heavily between components, so loading it up front is smaller than duplicating it across chunks. +- **Detect:** + - Parcel: `@parcel/bundler-default.manualSharedBundles` with an `s2-styles` entry in the root `package.json`. + - webpack/Next: `optimization.splitChunks.cacheGroups` with an `s2`/`s2-styles` group testing for `@react-spectrum/s2` and `macro-*.css`. + - Vite/Rollup/React Router: `build.rollupOptions.output.manualChunks` returning `'s2-styles'` for `macro-*.css` and `@react-spectrum/s2/*.css`. +- **Severity:** High. + +### lightningcss minification +- **Rule:** Compile/minify CSS with lightningcss — it produces a much smaller bundle and dedupes atomic rules. +- **Detect:** `cssMinify: 'lightningcss'` (Vite/React Router) or `CssMinimizerPlugin.lightningCssMinify` (webpack/Next). +- **Severity:** Medium. + +### Locale optimization +- **Rule:** S2 ships localized strings for 30+ languages by default. Projects should install the locale optimization plugin and declare only the languages they support. +- **Detect:** `@react-aria/optimize-locales-plugin` (webpack/Vite/Rollup/React Router/ESBuild) or `@react-aria/parcel-resolver-optimize-locales` (Parcel) installed **and** configured with a `locales` list. +- **Severity:** Medium. + +### Single root Provider, wired to the router +- **Rule:** Mount one `Provider` from `@react-spectrum/s2/Provider` at the app root, wired to the client router; SSR frameworks (Next.js, React Router) set `locale` from the server request and render `Provider` with `elementType="html"`. +- **Detect:** exactly one root `Provider`; a `router={{navigate}}` prop; for Next/RR, a server-derived locale. Flag a missing `router`, or SSR setups that don't sync the locale. +- **Severity:** High. (Provider *scope* misuse — stacking, hard-coded `colorScheme` — is in [04-accessibility](04-accessibility.md).) + +### React 19 +- **Rule:** React 19 is recommended for S2. +- **Detect:** `react` version in `package.json`. +- **Severity:** Low. diff --git a/packages/dev/s2-docs/skills/spectrum-audit/checks/02-component-usage.md b/packages/dev/s2-docs/skills/spectrum-audit/checks/02-component-usage.md new file mode 100644 index 00000000000..0c00b6fe407 --- /dev/null +++ b/packages/dev/s2-docs/skills/spectrum-audit/checks/02-component-usage.md @@ -0,0 +1,42 @@ +# Check 02 — Component usage + +Validates that the project uses S2 components where they exist, builds custom components on React Aria Components + the `style` macro, and follows S2 composition and collection conventions. This is the most judgment-heavy category — read the component's docs in the `react-spectrum-s2` skill (`references/components/`) before flagging. + +Canonical rules: `react-spectrum-s2` skill → `SKILL.md` (Component composition, Collections, Typography, Form fields). Reverse-lookup help ("is there an S2 component for this?"): `references/guides/component-decision-tree.md`. + +## Checks + +### Use an S2 component where one exists +- **Rule:** Prefer S2 components over hand-rolled UI. S2 covers ~85 components — Button, Picker, ComboBox, Menu, Dialog/AlertDialog, Card/CardView, TableView, ListView, Tabs, and more. +- **Detect:** hand-rolled equivalents — `

`, custom dropdown/modal/tooltip implementations, native `