From ab63b8f55f2b6ef861f9c5fcb8652afbed341408 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Tue, 4 Aug 2026 17:25:41 -0700 Subject: [PATCH 1/2] feat: Support icon placeholders in Attachment, onToggle, and other PromptField updates (#10416) * chore: export various props from mono package and narrow types * audit updates * rename AutoLinkingTokenFieldValue * fix docs and tweak copy a bit * ugh lint * expose promptfield input ref so user can have autofill buttons open autocomplete menu * truncate attachments and onToggle for voice input omega tracking * handle icon thumbnails in attachment list * add menu width to sidestep weird popover resizing behavior when at edge of screen --- .gitignore | 1 + .../@react-spectrum/ai/src/AttachmentList.tsx | 81 +++++++++++++------ .../@react-spectrum/ai/src/HorizontalCard.tsx | 42 ++++++++-- .../@react-spectrum/ai/src/PromptField.tsx | 36 ++++++--- packages/@react-spectrum/ai/src/useDOMRef.ts | 26 +++++- .../ai/stories/AttachmentList.stories.tsx | 66 +++++++++++++++ .../ai/stories/PromptField.stories.tsx | 65 ++++++++++----- 7 files changed, 253 insertions(+), 64 deletions(-) diff --git a/.gitignore b/.gitignore index 178fe850797..ac8c322e491 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ starters/docs/registry starters/tailwind/registry starters/docs/yarn.lock starters/tailwind/yarn.lock +.scout/ diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index c67d6c9945e..da3e9cb4f49 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -28,13 +28,15 @@ import {BasicHorizontalCard} from './HorizontalCard'; import {Button} from 'react-aria-components/Button'; import {CardProps} from '@react-spectrum/s2/Card'; import Cross from '../ui-icons/Cross'; -import {forwardRef, ReactNode, useRef} from 'react'; +import {forwardRef, ReactNode, useContext, useRef} from 'react'; +import {IconContext} from '@react-spectrum/s2/Icon'; import {ImageContext} from '@react-spectrum/s2/Image'; // @ts-ignore import intlMessages from '../intl/*.json'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {pressScale} from '@react-spectrum/s2/pressScale'; import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; +import {Provider} from 'react-aria-components/slots'; import {StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; import { Tag, @@ -217,6 +219,56 @@ const attachmentErrorStyles = style({ } }); +function AttachmentContextProvider({ + children, + isUploading +}: { + children: ReactNode; + isUploading: boolean; +}) { + let imageCtx = useContext(ImageContext); + let iconCtx = useContext(IconContext); + const opacityStyles = style({ + opacity: {default: 1, isUploading: 0.15}, + transition: 'default' + })({isUploading}); + const imageSlots = imageCtx && 'slots' in imageCtx ? imageCtx.slots : undefined; + const iconSlots = iconCtx && 'slots' in iconCtx ? iconCtx.slots : undefined; + + return ( + + {children} + + ); +} + export const Attachment = forwardRef(function Attachment( props: AttachmentProps, ref: DOMRef @@ -266,29 +318,10 @@ export const Attachment = forwardRef(function Attachment( /> )} - {/* Reduce opacity of the thumbnail if upload is in progress */} - - {ctx => ( - - {typeof children === 'function' ? children({size}) : children} - - )} - + + {typeof children === 'function' ? children({size}) : children} + {isInvalid && ( @@ -483,10 +487,12 @@ export interface PromptTokenFieldPopoverProps extends Omit; isFocused?: boolean; + // TODO: temp for coworker see above comment + menuWidth?: number; } function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) { - let {filterAnchor, items, isFocused} = props; + let {filterAnchor, items, isFocused, menuWidth} = props; let {inputRef} = useContext(PromptFieldContext); let resolvedItems = items instanceof Promise ? use(items) : items; @@ -506,6 +512,7 @@ function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) { isNonModal hideArrow placement="bottom start" + UNSAFE_style={menuWidth != null ? {width: menuWidth} : undefined} getTargetRect={target => { return tokenFieldPositionToDOMRange(target, filterAnchor!).getBoundingClientRect(); }}> @@ -611,10 +618,11 @@ export interface PromptFieldVoiceButtonProps { lang?: string; isDisabled?: boolean; onError?: (code: VoiceInputErrorCode) => void; + onToggle?: (isListening: boolean) => void; } export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { - let {lang: langProp, isDisabled: isDisabledProp, onError} = props; + let {lang: langProp, isDisabled: isDisabledProp, onError, onToggle} = props; let {locale} = useLocale(); let lang = langProp ?? locale; let {prompt, setPrompt, inputRef, setListening} = useContext(PromptFieldContext); @@ -646,14 +654,20 @@ export function PromptFieldVoiceButton(props: PromptFieldVoiceButtonProps) { setPrompt(finalPrompt); }); + let onToggleEvent = useEffectEvent((isListening: boolean) => { + onToggle?.(isListening); + }); + let wasListeningRef = useRef(false); useEffect(() => { if (isVoiceListening) { updateBasePrompt(); wasListeningRef.current = true; + onToggleEvent(true); } else if (wasListeningRef.current) { wasListeningRef.current = false; restoreFocus(); + onToggleEvent(false); } }, [isVoiceListening]); diff --git a/packages/@react-spectrum/ai/src/useDOMRef.ts b/packages/@react-spectrum/ai/src/useDOMRef.ts index 8b3afaf7881..7cc24518b7c 100644 --- a/packages/@react-spectrum/ai/src/useDOMRef.ts +++ b/packages/@react-spectrum/ai/src/useDOMRef.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {DOMRef, DOMRefValue, RefObject} from '@react-types/shared'; +import {DOMRef, DOMRefValue, FocusableRef, FocusableRefValue, RefObject} from '@react-types/shared'; import {useImperativeHandle, useRef} from 'react'; export function createDOMRef( @@ -23,6 +23,21 @@ export function createDOMRef( }; } +export function createFocusableRef( + domRef: RefObject, + focusableRef?: RefObject +): FocusableRefValue { + let resolvedFocusableRef = focusableRef || domRef; + return { + ...createDOMRef(domRef), + focus() { + if (resolvedFocusableRef.current) { + resolvedFocusableRef.current.focus(); + } + } + }; +} + export function useDOMRef( ref: DOMRef ): RefObject { @@ -30,3 +45,12 @@ export function useDOMRef( useImperativeHandle(ref, () => createDOMRef(domRef)); return domRef; } + +export function useFocusableRef( + ref: FocusableRef, + focusableRef?: RefObject +): RefObject { + let domRef = useRef(null); + useImperativeHandle(ref, () => createFocusableRef(domRef, focusableRef)); + return domRef; +} diff --git a/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx b/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx index cae286fa369..57dc6287524 100644 --- a/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx +++ b/packages/@react-spectrum/ai/stories/AttachmentList.stories.tsx @@ -13,6 +13,8 @@ import {Attachment as AttachmentComponent, AttachmentList} from '../src/AttachmentList'; import {categorizeArgTypes, getActionArgs} from '../../s2/stories/utils'; import {Content} from '@react-spectrum/s2/Content'; +import File from '@react-spectrum/s2/icons/File'; +import FileText from '@react-spectrum/s2/icons/FileText'; import {Image} from '@react-spectrum/s2/Image'; import type {Meta, StoryObj} from '@storybook/react'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; @@ -99,3 +101,67 @@ function AttachmentListRender(args) { export const AIAttachmentList: Story = { render: args => }; + +function NonImageAttachmentListRender(args) { + let {isInvalid, size, uploadProgress, ...listArgs} = args; + return ( + + {/* TODO: what should the thumbnail only look like with icons */} + + + + + + + notes.txt + Plain text document + + + + + + data.csv + + + + ); +} + +export const NonImageAttachments: Story = { + render: args => +}; + +export const LongContents: Story = { + name: 'Long contents', + render: (args: any) => ( + + + + + Very long file name that exceeds the container width.pdf + + Long long long long long long long long long long description. + + + + + ) +}; diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx index 132b78c9d59..e8c004ba22d 100644 --- a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -43,6 +43,7 @@ import { import {Content} from '@react-spectrum/s2/Content'; import Data from '@react-spectrum/s2/icons/Data'; import * as data from '../src/loader/data'; +import type {FocusableRefValue} from '@react-types/shared'; import {iconStyle, style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {Image} from '@react-spectrum/s2/Image'; import LinkIcon from '@react-spectrum/s2/icons/Link'; @@ -89,6 +90,10 @@ const meta: Meta = { placeholder: { control: 'text', table: {category: 'PromptTokenField'} + }, + menuWidth: { + control: 'number', + table: {category: 'PromptTokenField'} } }, args: { @@ -97,6 +102,7 @@ const meta: Meta = { attachmentVariant: 'thumbnail', attachmentInvalid: false, placeholder: undefined, + menuWidth: undefined, ...getActionArgs(events) }, title: 'AI/PromptField', @@ -245,35 +251,45 @@ interface UploadState { progress?: number; } +function atEnd(v: PromptFieldValue) { + let segs = v.segments; + return {index: segs.length - 1, offset: segs[segs.length - 1].text.length}; +} + +let prompt1 = new PromptFieldValue([ + {type: 'text', text: 'Analyze '}, + {type: 'token', text: 'New Customers', value: {type: 'audience', title: 'New Customers'}}, + {type: 'text', text: ' and suggest targeting strategies'} +]); + +let prompt2 = new PromptFieldValue([ + {type: 'text', text: 'Write a brief for '}, + { + type: 'token', + text: 'Spring Launch 2026', + value: {type: 'campaign', title: 'Spring Launch 2026'} + } +]); + let prompt3Base = new PromptFieldValue([ {type: 'text', text: 'Summarize the '}, {type: 'token', text: 'Welcome Flow', value: {type: 'journey', title: 'Welcome Flow'}} ]); -let prompt3End = { - index: 1, - offset: prompt3Base.segments[1].text.length -}; let prompts = [ - new PromptFieldValue([ - {type: 'text', text: 'Analyze '}, - {type: 'token', text: 'New Customers', value: {type: 'audience', title: 'New Customers'}}, - {type: 'text', text: ' and suggest targeting strategies'} - ]), - new PromptFieldValue([ - {type: 'text', text: 'Write a brief for '}, - { - type: 'token', - text: 'Spring Launch 2026', - value: {type: 'campaign', title: 'Spring Launch 2026'} - } - ]), - prompt3Base.replaceRange(prompt3End, prompt3End, ' journey performance from test.com ') + prompt1.withCaretPosition(atEnd(prompt1)), + prompt2.withCaretPosition(atEnd(prompt2)), + prompt3Base.replaceRange( + atEnd(prompt3Base), + atEnd(prompt3Base), + ' journey performance from test.com /' + ) ]; function EverythingRender(args) { - let {placeholder, ...otherArgs} = args; + let {placeholder, menuWidth, ...otherArgs} = args; let [value, setValue] = useState(() => new PromptFieldValue([])); + let promptFieldRef = useRef>(null); let [attachments, setAttachments] = useState([]); let [attachmentState, setAttachmentState] = useState>(new Map()); let historyRef = useRef([]); @@ -352,13 +368,19 @@ function EverythingRender(args) {
{prompts.map((prompt, i) => ( - setValue(prompt)}> + { + setValue(prompt); + promptFieldRef.current?.focus(); + }}> {prompt.toString()} ))} {segment => ( @@ -508,7 +531,7 @@ function EverythingRender(args) { {/* TODO is this kind of styling expected from the user? Or should we have a slot that places the mic button next to the submit button? */}
- +
From 55180fa108ded4d5734a86331925301aaa095de5 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 4 Aug 2026 17:34:04 -0700 Subject: [PATCH 2/2] feat: PromptField animation, AI button, and loader updates (#10425) * Add PromptField gradient animation in generating state * Add new CXO loaders * basic WHCM support * AI Button * adjust loader cell rounding * update pixel loader animation * update generating animation * always set --s2-color-scheme to either light or dark * Adjust animation * support reduced motion setting --- packages/@react-spectrum/ai/src/AIButton.tsx | 265 ++++++++++++++++++ .../@react-spectrum/ai/src/PromptField.tsx | 32 ++- .../ai/src/PromptFieldContainer.tsx | 231 +++++++++++---- .../@react-spectrum/ai/src/loader/data.ts | 250 +++++++++++++++-- .../@react-spectrum/ai/src/loader/react.tsx | 211 ++++++++++---- .../@react-spectrum/ai/src/tokens.macro.ts | 80 +++++- .../ai/stories/AIButton.stories.tsx | 45 +++ packages/@react-spectrum/s2/src/page.macro.ts | 3 +- .../@react-spectrum/s2/src/style-utils.ts | 5 +- 9 files changed, 974 insertions(+), 148 deletions(-) create mode 100644 packages/@react-spectrum/ai/src/AIButton.tsx create mode 100644 packages/@react-spectrum/ai/stories/AIButton.stories.tsx diff --git a/packages/@react-spectrum/ai/src/AIButton.tsx b/packages/@react-spectrum/ai/src/AIButton.tsx new file mode 100644 index 00000000000..234c51316d3 --- /dev/null +++ b/packages/@react-spectrum/ai/src/AIButton.tsx @@ -0,0 +1,265 @@ +import {Button, ButtonProps} from 'react-aria-components/Button'; +import { + convertColor, + defaultBrand, + defineProperties, + token +} from './tokens.macro' with {type: 'macro'}; +import {createIcon, IconContext} from '@react-spectrum/s2/Icon'; +import { + css, + focusRing, + fontRelative, + space, + style +} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {CSSProperties, useRef} from 'react'; +import {GlobalDOMAttributes} from '@react-types/shared'; +import {pressScale} from '@react-spectrum/s2/pressScale'; + +export interface AIButtonProps extends Omit< + ButtonProps, + | 'className' + | 'style' + | 'render' + | 'children' + | 'onHover' + | 'onHoverStart' + | 'onHoverEnd' + | 'onHoverChange' + | 'onClick' + | 'isPending' + | keyof GlobalDOMAttributes +> { + size?: 'S' | 'M' | 'L' | 'XL'; + brandColor?: string; + children?: string; +} + +const controlSize = { + default: 32, + size: { + XS: 20, + S: 24, + L: 40, + XL: 48 + } +} as const; + +const button = style({ + ...focusRing(), + // ...control({shape: 'pill', wrap: true, icon: true}), + font: { + default: 'ui', + size: { + S: 'ui-sm', + L: 'ui-lg', + XL: 'ui-xl' + } + }, + display: 'flex', + alignItems: 'center', + columnGap: 'text-to-visual', + boxSizing: 'border-box', + paddingX: { + default: 'edge-to-text', + ':has(svg:only-child)': 0 + }, + minWidth: controlSize, + height: controlSize, + borderRadius: 'pill', + borderWidth: 0, + position: 'relative', + justifyContent: 'center', + textAlign: 'start', + fontWeight: 'bold', + userSelect: 'none', + width: 'fit', + transition: 'default', + color: { + default: `[color-mix(in srgb, light-dark(black, white), ${token('container.gradient.con-bg.idle.stop-4')} 20%)]`, + isDisabled: 'transparent-overlay-400' + }, + '--iconPrimary': { + type: 'fill', + value: 'currentColor' + }, + forcedColorAdjust: 'none', + disableTapHighlight: true, + overflow: 'clip', + '--fill-y': { + type: 'top', + value: { + size: { + S: 4, + M: 4, + L: space(6), + XL: space(10) + } + } + }, + '--fill-x': { + type: 'top', + value: { + size: { + S: 2, + M: 2, + L: 4, + XL: 4 + } + } + } +}); + +defineProperties(` + @property --brand { + syntax: ''; + initial-value: ${defaultBrand()}; + inherits: true; + } +`); + +const bg = css(` + &::before, &::after { + content: ''; + pointer-events: none; + position: absolute; + bottom: 0px; + border-radius: 9999px; + z-index: -1; + transition: inherit; + } + + @container style(--s2-color-scheme: dark) { + background-image: linear-gradient(to bottom, light-dark(white, #292929), light-dark(white, #383838)); + --border-opacity: 10%; + box-shadow: + inset 0 0.5px 0 0px rgb(255 255 255 / var(--border-opacity)), + inset 0 0 0 0.5px rgb(255 255 255 / var(--border-opacity)), + inset 0px 2px 8px rgb(95 95 95 / 50%); + + /* bottom sheen */ + &::before { + height: 24px; + inset-inline: 4px; + background-image: linear-gradient(to bottom, transparent, white); + mix-blend-mode: plus-lighter; + filter: blur(2px); + opacity: 50%; + } + + /* color gradient (top layer) */ + &::after { + top: var(--fill-y); + inset-inline: var(--fill-x); + background-image: linear-gradient( + to right, + ${token('container.gradient.con-bg.generating.stop-3')} 0%, + ${token('container.gradient.con-bg.idle.stop-3')} 28%, + ${token('container.gradient.con-bg.idle.stop-2')} 98% + ); + opacity: 75%; + mix-blend-mode: hard-light; + filter: blur(8px); + } + + &:has(svg:only-child) { + &::after { + filter: blur(4px); + } + } + + &[data-hovered] { + --border-opacity: 20%; + &::before { + opacity: 75%; + } + } + + &[data-disabled] { + &::before { + opacity: 25%; + } + + &::after { + opacity: 0; + } + } + } + + @container style(--s2-color-scheme: light) { + background-color: white; + + /* color gradient */ + &::before { + top: var(--fill-y); + inset-inline: var(--fill-x); + background-image: linear-gradient( + to right, + ${token('container.gradient.con-bg.idle.stop-3')} 0%, + ${token('container.gradient.con-bg.idle.stop-2')} 12%, + ${token('container.gradient.con-bg.generating.stop-3')} 77% + ); + opacity: 75%; + filter: blur(4px); + } + + /* inset shadows */ + &::after { + inset: 0px; + box-shadow: + inset 0 -0.5px 1px ${token('container.gradient.con-bg.generating.stop-3')}, + inset 0.5px -1.5px 6px ${convertColor('rgb(252, 228, 233)')}, + inset -7px -23px 9px -12px rgb(255 255 255 / 75%); + } + + &[data-hovered] { + &::before { + opacity: 95%; + } + } + + &[data-disabled] { + background-color: #F8F8F8; + + &::before { + opacity: 0; + } + + &::after { + box-shadow: + inset 0 -0.5px 1px rgb(0 0 0 / 10%), + inset 0.5px -1.5px 6px rgb(0 0 0 / 5%), + inset -7px -23px 9px -12px rgb(255 255 255 / 75%); + } + } + } +`); + +export function AIButton({size = 'M', brandColor, children, ...otherProps}: AIButtonProps) { + let ref = useRef(null); + return ( + + ); +} + +const AIIcon = createIcon(props => { + return ( + + + + ); +}); diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 9356f696e64..d9306e539e4 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -398,15 +398,41 @@ export function PromptTokenField(props: PromptTokenFieldProps) { alignItems: 'baseline', color: { default: 'transparent-overlay-600', - isFocused: 'body' + isFocused: 'body', + forcedColors: 'ButtonText' }, transition: 'default', transitionDuration: 350, paddingStart: 4, - width: 'full' + width: 'full', + '--loader-color': { + type: 'color', + value: { + default: 'gray-1000', + isFocused: 'body', + forcedColors: 'ButtonText' + } + }, + '--loader-opacity': { + type: 'opacity', + value: { + default: 0.51, + isFocused: 1, + forcedColors: 1 + } + } })({isFocused: isFocused || prompt.segments.length > 0})}> - + '; initial-value: 0%; - inherits: false; + inherits: true; } @property --bg-stop-1 { syntax: ''; initial-value: #0000; - inherits: false; + inherits: true; } @property --bg-stop-2 { syntax: ''; initial-value: #0000; - inherits: false; + inherits: true; } @property --bg-stop-3 { syntax: ''; initial-value: #0000; - inherits: false; + inherits: true; } @property --bg-stop-4 { syntax: ''; initial-value: #0000; - inherits: false; + inherits: true; } `); const containerBackground = css(` transition: --con-hue-opacity ${STATE_TRANSITION}, --bg-stop-1 ${STATE_TRANSITION}, --bg-stop-2 ${STATE_TRANSITION}, --bg-stop-3 ${STATE_TRANSITION}, --bg-stop-4 ${STATE_TRANSITION}, box-shadow ${STATE_TRANSITION}; - + background: - linear-gradient( - to bottom, - light-dark(rgb(255 255 255 / 75%), rgb(0 0 0 / 40%)) 0% 37%, - light-dark(rgb(255 255 255 / 15%), rgb(0 0 0 / 12%)) 83% 100% - ), - radial-gradient( - 50% 50% at -20% 100%, - rgb(from ${token('container.gradient.con-hue.generating.stop-3')} r g b / var(--con-hue-opacity)), - transparent - ), radial-gradient( - 70% 60% at 5% 80%, - rgb(from ${token('container.gradient.con-hue.generating.stop-2')} r g b / var(--con-hue-opacity)), - transparent - ), - radial-gradient( - 70% 50% at 40% 80%, - rgb(from ${token('container.gradient.con-hue.generating.stop-1')} r g b / var(--con-hue-opacity)), - transparent - ), - radial-gradient( - circle at right bottom, + circle at right bottom in oklch, var(--bg-stop-1) 0%, var(--bg-stop-2) 35%, var(--bg-stop-3) 82%, @@ -92,24 +73,23 @@ const containerBackground = css(` --border-color: ${token(`container.border.default`)}; --inset-shadow-color: ${color('transparent-white-50')}; --drop-shadow-color: light-dark(${brand(0.5826, 0.2265, -0.4, 0.05)}, ${brand(0.6617, 0.2508, -0.5, 0.05)}); - --prominent-outline-glow: ; + --prominent-outer-glow: ; + --prominent-inset-glow: ; + /* Only the non-inset (outward) shadows live here: the inset shadows are painted + on top of the background layers by a separate element (insetShadow below), since + this element's overflow:clip must stay on this box to clip the outward shadows. */ box-shadow: - var(--prominent-outline-glow) - inset 0 0 0 1px var(--border-color), - inset 0 6px 15px 0 var(--inset-shadow-color), - inset 0 0 0 0 transparent, /* placeholder for generating state so transition is smooth */ - inset 0 -5px 21.6px 0 ${color('transparent-white-50')}, - inset 0 24px 32px 0 ${color('transparent-white-50')}, + var(--prominent-outer-glow) 0 -3px 10px 1px var(--drop-shadow-color); &[data-variant=prominent] { /* trailing comma is intentional so it can be interpolated above */ - --prominent-outline-glow: - 0 20px 20px -24px ${token('outline-glow.gradient.generating.stop-3')}, - inset 0 -20px 20px -24px ${token('outline-glow.gradient.generating.stop-3')},; + --prominent-outer-glow: 0 20px 20px -24px ${token('outline-glow.gradient.generating.stop-3')},; + --prominent-inset-glow: inset 0 -20px 20px -24px ${token('outline-glow.gradient.generating.stop-3')},; &[data-focused] { - --prominent-outline-glow: 0 20px 20px -24px transparent, inset 0 -20px 20px -24px transparent,; + --prominent-outer-glow: 0 20px 20px -24px transparent,; + --prominent-inset-glow: inset 0 -20px 20px -24px transparent,; } } @@ -160,12 +140,7 @@ const containerBackground = css(` &[data-state=generating] { box-shadow: - var(--prominent-outline-glow) - inset 0 0 0 1px var(--border-color), - inset 0 6px 15px 0 var(--inset-shadow-color), - inset 0 -32px 100px -50px ${token('container.color.inner-shadow.generating')}, - inset 0 -5px 21.6px 0 ${color('transparent-white-50')}, - inset 0 24px 32px 0 ${color('transparent-white-50')}, + var(--prominent-outer-glow) 0 -3px 10px 1px var(--drop-shadow-color), 0 6px 83px rgb(from ${token('outer-border.gradient.ob-spread-shadow.generating.stop-3')} r g b / var(--spread-shadow-opacity)); @@ -198,6 +173,109 @@ const containerBackground = css(` } `); +const insetShadow = css(` + pointer-events: none; + position: absolute; + inset: 0; + border-radius: inherit; + transition: box-shadow ${STATE_TRANSITION}; + + box-shadow: + var(--prominent-inset-glow) + inset 0 0 0 1px var(--border-color), + inset 0 6px 15px 0 var(--inset-shadow-color), + inset 0 0 0 0 transparent, /* placeholder for generating state so transition is smooth */ + inset 0 -5px 21.6px 0 ${color('transparent-white-50')}, + inset 0 24px 32px 0 ${color('transparent-white-50')}; + + &[data-state=generating] { + box-shadow: + var(--prominent-inset-glow) + inset 0 0 0 1px var(--border-color), + inset 0 6px 15px 0 var(--inset-shadow-color), + inset 0 -32px 100px -50px ${token('container.color.inner-shadow.generating')}, + inset 0 -5px 21.6px 0 ${color('transparent-white-50')}, + inset 0 24px 32px 0 ${color('transparent-white-50')}; + } +`); + +const containerHue = css(` + background: + radial-gradient( + 50% 50% at -20% 100% in oklch, + oklch(from ${token('container.gradient.con-hue.generating.stop-3')} l c h / var(--con-hue-opacity)), + transparent + ), + radial-gradient( + 70% 60% at 5% 80% in oklch, + oklch(from ${token('container.gradient.con-hue.generating.stop-2')} l c h / var(--con-hue-opacity)), + transparent + ), + radial-gradient( + 70% 50% at 40% 80% in oklch, + oklch(from ${token('container.gradient.con-hue.generating.stop-1')} l c h / var(--con-hue-opacity)), + transparent + ); + + --rotation: 7deg; + --translation: 44px; + @supports (rotate: atan(1px / 1cqw)) { + --rotation: atan(30px / 50cqw); + --translation: clamp(44px, 44px * (800px / 100cqw), 72px); + } +`); + +const overlay = css(` + background: + linear-gradient( + to bottom in oklch, + light-dark(oklch(from white l c h / 75%), oklch(from black l c h / 40%)) 0% 37%, + light-dark(oklch(from white l c h / 15%), oklch(from black l c h / 12%)) 83% 100% + ); +`); + +/* The rotation and translation animations use the computed variables above, which adjust depending + on the container width (when division with units is supported - everywhere except Firefox). + The rotation is calculated based on the desired "lift" amount, which is (width / 2) * sin(angle). + The translation is based on a ratio with the full size reference width (800px). Both translation + and rotation increase at smaller widths to make the motion more visible in that space. */ +const rotation = keyframes(` + 0% { rotate: 0rad } + 25% { rotate: var(--rotation) } + 50% { rotate: 0rad } + 75% { rotate: calc(-1 * var(--rotation)) } + 100% { rotate: 0rad } +`); + +const translation = keyframes(` + 0% { + animation-timing-function: ease-in-out; + translate: 0px 0px; + } + + 50% { + translate: 0px var(--translation); + } + + 100% { + translate: 0px 0px; + } +`); + +const scale = keyframes(` + 0% { + scale: 1 1; + } + + 50% { + scale: 3 1; + } + + 100% { + scale: 1 1; + } +`); + const outerBorder = css(` --outer-drop-shadow-color: ${token('outer-border.color.drop-shadow.ob-border.default')}; @@ -205,7 +283,7 @@ const outerBorder = css(` border-radius: calc(24px + 6px); transition: --bg-stop-1 ${STATE_TRANSITION}, --bg-stop-2 ${STATE_TRANSITION}, --bg-stop-3 ${STATE_TRANSITION}, box-shadow ${STATE_TRANSITION}; background: linear-gradient( - to right, + to right in oklch, var(--bg-stop-1) 0%, var(--bg-stop-2) 37%, var(--bg-stop-3) 77% @@ -272,7 +350,16 @@ export function PromptFieldContainer(props: PropFieldContainerProps) { data-variant={variant} data-state={isGenerating ? 'generating' : 'idle'} data-focused={isFocused || undefined} - className={outerBorder} + className={ + outerBorder + + // outline for WHCM + style({ + outlineStyle: 'solid', + outlineColor: 'transparent', + outlineWidth: 1, + containerType: 'inline-size' + }) + } style={{ ...props.style, // @ts-ignore @@ -307,22 +394,46 @@ export function PromptFieldContainer(props: PropFieldContainerProps) { data-variant={variant} data-state={isGenerating ? 'generating' : 'idle'} className={ - (props.className || '') + ' ' + containerBackground + mergeStyles( style({ - display: 'flex', - flexDirection: 'column', - gap: 16, - padding: 16, - cursor: 'text', borderRadius: '[24px]', - position: 'relative' + position: 'relative', + overflow: 'clip' }), styles ) }> +
+
+
{isDropTarget && (
)} - {props.children} +
+ {props.children} +
)} diff --git a/packages/@react-spectrum/ai/src/loader/data.ts b/packages/@react-spectrum/ai/src/loader/data.ts index 850c9939c80..bde62acf5cd 100644 --- a/packages/@react-spectrum/ai/src/loader/data.ts +++ b/packages/@react-spectrum/ai/src/loader/data.ts @@ -16,9 +16,6 @@ export interface Cell { // Retained from the source data; no longer affects rendering. outer: boolean; stagger: number; - exitStart: number; - fadeIn: number[]; - fadeOut: number[]; } type StaggerMode = 'individual' | 'grouped' | 'by-row'; @@ -33,18 +30,18 @@ interface BuildOptions { // ai-logo: original 12-cell diamond layout with hand-tuned timings. // ───────────────────────────────────────────────────────────── export const aiLogo: Cell[] = [ - {cx: 240, cy: 360, outer: true, stagger: 0, exitStart: 47, fadeIn: [1, 4], fadeOut: [47, 59]}, - {cx: 160, cy: 320, outer: true, stagger: 2, exitStart: 48, fadeIn: [4, 7], fadeOut: [48, 60]}, - {cx: 320, cy: 320, outer: true, stagger: 4, exitStart: 49, fadeIn: [6, 9], fadeOut: [49, 61]}, - {cx: 200, cy: 280, outer: false, stagger: 6, exitStart: 50, fadeIn: [9, 12], fadeOut: [50, 62]}, - {cx: 280, cy: 280, outer: false, stagger: 8, exitStart: 51, fadeIn: [13, 16], fadeOut: [51, 63]}, - {cx: 120, cy: 240, outer: true, stagger: 10, exitStart: 52, fadeIn: [14, 17], fadeOut: [52, 64]}, - {cx: 360, cy: 240, outer: true, stagger: 14, exitStart: 54, fadeIn: [18, 21], fadeOut: [54, 66]}, - {cx: 200, cy: 200, outer: false, stagger: 16, exitStart: 55, fadeIn: [21, 24], fadeOut: [55, 67]}, - {cx: 280, cy: 200, outer: false, stagger: 18, exitStart: 56, fadeIn: [23, 26], fadeOut: [56, 68]}, - {cx: 160, cy: 160, outer: true, stagger: 20, exitStart: 57, fadeIn: [26, 29], fadeOut: [57, 69]}, - {cx: 320, cy: 160, outer: true, stagger: 22, exitStart: 58, fadeIn: [27, 30], fadeOut: [58, 70]}, - {cx: 240, cy: 120, outer: true, stagger: 24, exitStart: 59, fadeIn: [31, 34], fadeOut: [59, 71]} + {cx: 240, cy: 360, outer: true, stagger: 0}, + {cx: 160, cy: 320, outer: true, stagger: 2}, + {cx: 320, cy: 320, outer: true, stagger: 4}, + {cx: 200, cy: 280, outer: false, stagger: 6}, + {cx: 280, cy: 280, outer: false, stagger: 8}, + {cx: 120, cy: 240, outer: true, stagger: 10}, + {cx: 360, cy: 240, outer: true, stagger: 14}, + {cx: 200, cy: 200, outer: false, stagger: 16}, + {cx: 280, cy: 200, outer: false, stagger: 18}, + {cx: 160, cy: 160, outer: true, stagger: 20}, + {cx: 320, cy: 160, outer: true, stagger: 22}, + {cx: 240, cy: 120, outer: true, stagger: 24} ]; // ───────────────────────────────────────────────────────────── @@ -650,10 +647,161 @@ const adobeEPositions: number[][] = [ [4, 2] ]; +const documentPositions: number[][] = [ + [0, 6], + [1, 6], + [2, 6], + [3, 6], + [4, 6], + [5, 6], + [6, 6], + [0, 5], + [6, 5], + [0, 4], + [6, 4], + [0, 3], + [6, 3], + [0, 2], + [4, 2], + [5, 2], + [6, 2], + [0, 1], + [4, 1], + [5, 1], + [0, 0], + [1, 0], + [2, 0], + [3, 0], + [4, 0] +]; + +const graphPositions: number[][] = [ + [0, 6], + [2, 6], + [4, 6], + [6, 6], + [0, 5], + [2, 5], + [4, 5], + [6, 5], + [2, 4], + [4, 4], + [6, 4], + [2, 3], + [6, 3], + [0, 2], + [4, 2], + [6, 2], + [1, 1], + [3, 1], + [5, 1], + [2, 0], + [6, 0] +]; + +const cartPositions: number[][] = [ + [1, 6], + [4, 6], + [1, 4], + [2, 4], + [3, 4], + [4, 4], + [5, 4], + [1, 3], + [5, 3], + [1, 2], + [6, 2], + [1, 1], + [2, 1], + [3, 1], + [4, 1], + [5, 1], + [6, 1], + [0, 0], + [1, 0] +]; + +const shopPositions: number[][] = [ + [1, 6], + [2, 6], + [3, 6], + [4, 6], + [5, 6], + [1, 5], + [2, 5], + [5, 5], + [1, 3], + [3, 3], + [5, 3], + [0, 2], + [2, 2], + [4, 2], + [6, 2], + [0, 1], + [2, 1], + [4, 1], + [6, 1], + [1, 0], + [2, 0], + [3, 0], + [4, 0], + [5, 0] +]; + +const journeyPositions: number[][] = [ + [5, 6], + [4, 5], + [6, 5], + [3, 4], + [5, 4], + [1, 3], + [3, 3], + [0, 2], + [2, 2], + [3, 2], + [5, 2], + [1, 1], + [4, 1], + [6, 1], + [5, 0] +]; + +const floppyPositions: number[][] = [ + [0, 6], + [1, 6], + [2, 6], + [3, 6], + [4, 6], + [5, 6], + [6, 6], + [0, 5], + [6, 5], + [0, 4], + [6, 4], + [0, 3], + [6, 3], + [0, 2], + [1, 2], + [2, 2], + [3, 2], + [4, 2], + [5, 2], + [6, 2], + [0, 1], + [1, 1], + [5, 1], + [6, 1], + [0, 0], + [1, 0], + [3, 0], + [5, 0] +]; + // ───────────────────────────────────────────────────────────── // buildCells — turn a positions list ([col, row] coords on a 7×7 grid, // bottom-up/left-to-right within a row → stagger order) into a cells -// array with per-cell timing (stagger, exitStart, fadeIn, fadeOut). +// array with a per-cell entrance stagger. All other timing (settle, hold, +// exit, group opacity) is derived from stagger at render time. // // stagger modes: // 'individual' — brush style. Each cell gets i * interval. @@ -694,20 +842,12 @@ export function buildCells(positions: number[][], options: BuildOptions = {}): C `Unknown stagger mode: ${staggerMode}. Use 'individual', 'grouped', or 'by-row'.` ); } - const maxStagger = Math.max(1, ...staggers); - return positions.map(([col, row], i) => { - const stagger = staggers[i]; - const exitStart = 47 + (stagger / maxStagger) * 12; - return { - cx: 120 + col * 40, - cy: 120 + (row + rowOffset) * 40, - outer: false, - stagger, - exitStart, - fadeIn: [stagger + 1, stagger + 4], - fadeOut: [exitStart, exitStart + 12] - }; - }); + return positions.map(([col, row], i) => ({ + cx: 120 + col * 40, + cy: 120 + (row + rowOffset) * 40, + outer: false, + stagger: staggers[i] + })); } // ───────────────────────────────────────────────────────────── @@ -838,6 +978,36 @@ export const adobeE = /* @__PURE__ */ buildCells(adobeEPositions, { rowOffset: -1 }); +export const document = /* @__PURE__ */ buildCells(documentPositions, { + staggerInterval: 1, + stagger: 'grouped' +}); + +export const graph = /* @__PURE__ */ buildCells(graphPositions, { + staggerInterval: 2, + stagger: 'grouped' +}); + +export const cart = /* @__PURE__ */ buildCells(cartPositions, { + staggerInterval: 1, + stagger: 'grouped' +}); + +export const shop = /* @__PURE__ */ buildCells(shopPositions, { + staggerInterval: 1, + stagger: 'grouped' +}); + +export const journey = /* @__PURE__ */ buildCells(journeyPositions, { + staggerInterval: 2, + stagger: 'grouped' +}); + +export const floppy = /* @__PURE__ */ buildCells(floppyPositions, { + staggerInterval: 1, + stagger: 'grouped' +}); + // ───────────────────────────────────────────────────────────── // Presets — sequences (`Cell[][]`) that loop through their icons. // Importing a preset pulls in only the icons it references. @@ -889,6 +1059,26 @@ export const exp: Cell[][] = [ export const analyze: Cell[][] = [flower, image, brush, eye, eyedrop, wand, lasso, crop]; +export const cxo: Cell[][] = [ + aiLogo, + document, + graph, + cart, + shop, + dial, + image, + journey, + folder, + timeline, + comment, + floppy, + adobeA, + adobeD, + adobeO, + adobeB, + adobeE +]; + export const mega: Cell[][] = [ aiLogo, brush, diff --git a/packages/@react-spectrum/ai/src/loader/react.tsx b/packages/@react-spectrum/ai/src/loader/react.tsx index 22eaad0c769..5f7436e7dcb 100644 --- a/packages/@react-spectrum/ai/src/loader/react.tsx +++ b/packages/@react-spectrum/ai/src/loader/react.tsx @@ -20,19 +20,42 @@ import {aiLogo, type Cell} from './data'; import * as React from 'react'; // Easing control points, formatted into `cubic-bezier(...)` in the -// generated keyframes. `fade` is intentionally linear — its control -// points lie on y=x. +// generated keyframes. `fade` is intentionally near-linear — its control +// points lie on y=x. `scaleIn`/`scaleOut` drive the per-cell pop. const EASE = { drop: [0.333, 0, 0.667, 1], recover: [0.333, 0, 0.833, 1], exit: [0.563, 0, 0.906, 0.757], - fade: [0.167, 0.167, 0.833, 0.833] + fade: [0.167, 0.167, 0.833, 0.833], + scaleIn: [0.505, 0.015, 0.42, 0.938], + scaleOut: [0.538, 0.017, 0.851, 0.357] }; -// One full cycle is 72 frames at 30fps (2.4s). -const TOTAL_FRAMES = 72; const FPS = 30; -const DURATION_MS = (TOTAL_FRAMES / FPS) * 1000; +const ms2f = (ms: number) => (ms * FPS) / 1000; + +// Timing model: every cell exits a constant `hold` after it settles, and the +// loop grows to fit. A cell settles DROP_SETTLE frames after its stagger, holds +// for HOLD_FRAMES, then takes EXIT_FALL frames to fall out of frame. +const DROP_SETTLE = 13; // frames from a cell's launch to its settled rest +const EXIT_FALL = 12; // frames for a cell to fall out of frame +const HOLD_FRAMES = ms2f(1400); // constant per-cell assembled hold (42 frames) + +// Per-cell scale pop: entrance 10% → 100%, exit 100% → 30%. The entrance pop +// *completes* ENT_LEAD frames before the cell settles (so it finishes on-screen +// rather than while still clipped above the frame), and the exit shrink starts +// as the cell begins to fall away. +const ENT_DUR = ms2f(150); +const ENT_LEAD = ms2f(150); +const ENT_FLOOR = 0.1; +const EXIT_DUR = ms2f(230); +const EXIT_FLOOR = 0.3; + +// Group opacity envelope, applied once to the cell-grid container (one +// compositing group, so overlapping cells never stack their alpha): fades +// 0 → peak → 0 across the loop. +const OP_FADE_IN = ms2f(400); +const OP_FADE_OUT = ms2f(400); // Per-cell vertical offsets in the 24-unit space: start off // the top, overshoot just past the settled position, exit off the bottom. @@ -40,6 +63,19 @@ const Y_START = -26; const Y_OVERSHOOT = 1; const Y_EXIT = 26; +// A cell begins its exit fall a constant hold after it settles. +const exitStartOf = (c: Cell) => c.stagger + DROP_SETTLE + HOLD_FRAMES; + +// Loop length grows with the icon's stagger spread so every cell gets the +// same settle → hold → exit cadence. +function loopFramesFor(cells: Cell[]): number { + let maxStagger = 1; + for (const c of cells) { + maxStagger = Math.max(maxStagger, c.stagger); + } + return maxStagger + DROP_SETTLE + HOLD_FRAMES + EXIT_FALL; +} + // The animation lives in a fixed VIEWBOX-sized coordinate space that is scaled down to the // rendered `size`. Cells are absolutely positioned in this space, so all // the translateY offsets below are in these units — identical to the old @@ -54,9 +90,9 @@ const CELL = VIEWBOX / 12; // drop/recover/exit/fade curves natively. // ───────────────────────────────────────────────────────────── -function pct(frame) { +function pct(frame, total) { // Frame → keyframe offset percentage, trimmed of noise. - return `${+((frame / TOTAL_FRAMES) * 100).toFixed(4)}%`; + return `${+((frame / total) * 100).toFixed(4)}%`; } function cb(coeffs) { @@ -68,11 +104,11 @@ function cb(coeffs) { // starting at it. Stops sharing a frame are de-duped (first wins). type Stop = {f: number; decl: string; ease?: readonly number[] | null}; -function emitKeyframes(name: string, stops: Stop[]) { +function emitKeyframes(name: string, stops: Stop[], total: number) { let body = ''; let lastPct: string | null = null; for (const s of stops) { - const p = pct(s.f); + const p = pct(s.f, total); if (p === lastPct) continue; lastPct = p; const ease = s.ease ? `animation-timing-function:${cb(s.ease)};` : ''; @@ -81,35 +117,63 @@ function emitKeyframes(name: string, stops: Stop[]) { return `@keyframes ${name}{${body}}`; } -const ty = v => `transform:translateY(${v}px);`; +// Use the individual `translate`/`scale` transform properties so the drop and +// the pop can animate on the same element without clobbering one another. +const ty = v => `translate:0 ${v}px;`; +const sc = v => `scale:${v};`; const op = v => `opacity:${v};`; -function cellYKeyframes(name, c) { +function cellYKeyframes(name, c, total) { const s = c.stagger; - const e = c.exitStart; + const e = exitStartOf(c); const stops: Stop[] = [{f: 0, decl: ty(Y_START), ease: s > 0 ? null : EASE.drop}]; if (s > 0) { stops.push({f: s, decl: ty(Y_START), ease: EASE.drop}); } stops.push({f: s + 10, decl: ty(Y_OVERSHOOT), ease: EASE.recover}); - stops.push({f: s + 13, decl: ty(0)}); // settled — hold until exit + stops.push({f: s + DROP_SETTLE, decl: ty(0)}); // settled — hold until exit stops.push({f: e, decl: ty(0), ease: EASE.exit}); - stops.push({f: e + 12, decl: ty(Y_EXIT)}); - stops.push({f: TOTAL_FRAMES, decl: ty(Y_EXIT)}); // hold offscreen until wrap - return emitKeyframes(name, stops); + stops.push({f: e + EXIT_FALL, decl: ty(Y_EXIT)}); + stops.push({f: total, decl: ty(Y_EXIT)}); // hold offscreen until wrap + return emitKeyframes(name, stops, total); } -function cellOpacityKeyframes(name, c) { - const [fi0, fi1] = c.fadeIn; - const [fo0, fo1] = c.fadeOut; - return emitKeyframes(name, [ - {f: 0, decl: op(0)}, - {f: fi0, decl: op(0), ease: EASE.fade}, - {f: fi1, decl: op(1)}, - {f: fo0, decl: op(1), ease: EASE.fade}, - {f: fo1, decl: op(0)}, - {f: TOTAL_FRAMES, decl: op(0)} - ]); +// Per-cell scale pop, applied to the inner cell element (transform-origin +// center, so no layout shift). The entrance completes ENT_LEAD frames before +// the cell settles; the exit starts as the cell begins to fall away. +function cellScaleKeyframes(name, c, total) { + const inEnd = c.stagger + DROP_SETTLE - ENT_LEAD; + const inStart = inEnd - ENT_DUR; + const outStart = exitStartOf(c); + const outEnd = outStart + EXIT_DUR; + return emitKeyframes( + name, + [ + {f: 0, decl: sc(0)}, + {f: inStart, decl: sc(ENT_FLOOR), ease: EASE.scaleIn}, + {f: inEnd, decl: sc(1)}, + {f: outStart, decl: sc(1), ease: EASE.scaleOut}, + {f: outEnd, decl: sc(EXIT_FLOOR)}, + {f: outStart + EXIT_FALL, decl: sc(0)}, + {f: total, decl: sc(0)} + ], + total + ); +} + +// Group opacity envelope for the whole loader, applied once to the cell-grid +// container so overlapping cells never stack their alpha. +function groupOpacityKeyframes(name, total) { + return emitKeyframes( + name, + [ + {f: 0, decl: op(0), ease: EASE.fade}, + {f: OP_FADE_IN, decl: op('var(--loader-opacity, 1)')}, + {f: total - OP_FADE_OUT, decl: op('var(--loader-opacity, 1)'), ease: EASE.fade}, + {f: total, decl: op(0)} + ], + total + ); } // Stable per-icon id, keyed by the cell-array reference (icons are @@ -131,9 +195,14 @@ function keyframesFor(cells: Cell[]): string { let css = cssCache.get(cells); if (css === undefined) { const id = iconId(cells); - css = cells - .map((c, i) => cellYKeyframes(`${id}-${i}-y`, c) + cellOpacityKeyframes(`${id}-${i}-o`, c)) - .join(''); + const total = loopFramesFor(cells); + css = + cells + .map( + (c, i) => + cellYKeyframes(`${id}-${i}-y`, c, total) + cellScaleKeyframes(`${id}-${i}-s`, c, total) + ) + .join('') + groupOpacityKeyframes(`${id}-group-o`, total); cssCache.set(cells, css); } return css; @@ -168,12 +237,14 @@ export interface PixelLoaderProps { export function PixelLoader(props: PixelLoaderProps) { const { size = 21, - isPlaying = true, + isPlaying: isPlayingProp = true, icon = aiLogo, color = 'currentColor', className, ...rest } = props; + let isReducedMotion = useReducedMotion(); + let isPlaying = isReducedMotion ? false : isPlayingProp; // Normalize to a sequence. const sequence = React.useMemo( @@ -181,7 +252,6 @@ export function PixelLoader(props: PixelLoaderProps) { [icon] ); const isSequence = sequence.length > 1; - const duration = DURATION_MS; // `tick` increments once per cycle; the current icon is `tick % len`. const [tick, setTick] = React.useState(0); @@ -193,6 +263,11 @@ export function PixelLoader(props: PixelLoaderProps) { setTick(0); } + const cells = sequence[isSequence ? tick % sequence.length : 0]; + // The loop length — and therefore the cycle duration — is dynamic: it grows + // with the current icon's stagger spread so every cell shares one cadence. + const duration = React.useMemo(() => (loopFramesFor(cells) / FPS) * 1000, [cells]); + // Advance the sequence one icon per cycle while playing. Single-icon // loaders never start a timer — they're a pure infinite CSS loop. React.useEffect(() => { @@ -203,7 +278,6 @@ export function PixelLoader(props: PixelLoaderProps) { return () => clearInterval(id); }, [isPlaying, isSequence, duration, sequence]); - const cells = sequence[isSequence ? tick % sequence.length : 0]; const animId = iconId(cells); const css = keyframesFor(cells); // Sequences play each icon once and hold its faded-out final frame @@ -212,14 +286,15 @@ export function PixelLoader(props: PixelLoaderProps) { const cellSize = size / 7; const offset = (size - cellSize * 7) / 2; - const matrix = Array.from({length: 7}, () => Array.from({length: 7}, () => false)); - for (let c of cells) { - let x = (c.cx - 120) / CELL; - let y = (c.cy - 120) / CELL; - matrix[y][x] = true; - } - - const isHighDPI = window.devicePixelRatio >= 2; + const matrix = React.useMemo(() => { + let matrix = Array.from({length: 7}, () => Array.from({length: 7}, () => false)); + for (let c of cells) { + let x = (c.cx - 120) / CELL; + let y = (c.cy - 120) / CELL; + matrix[y][x] = true; + } + return matrix; + }, [cells]); return (
{isPlaying ? : null} @@ -238,22 +319,21 @@ export function PixelLoader(props: PixelLoaderProps) { let x = (c.cx - 120) / CELL; let y = (c.cy - 120) / CELL; - // Convex-corner rounding: round a corner only when both orthogonal neighbors toward it are absent. + // Convex-corner rounding: round a corner only when both orthogonal neighbors toward it, + // and the diagonal neighbor toward it, are all absent. let left = matrix[y][x - 1]; let right = matrix[y][x + 1]; let top = matrix[y - 1]?.[x]; let bottom = matrix[y + 1]?.[x]; - let corner = (a, b) => (!a && !b ? '1px' : '0px'); + let topLeft = matrix[y - 1]?.[x - 1]; + let topRight = matrix[y - 1]?.[x + 1]; + let bottomLeft = matrix[y + 1]?.[x - 1]; + let bottomRight = matrix[y + 1]?.[x + 1]; + let corner = (a, b, diag) => (!a && !b && !diag ? '1px' : '0px'); // Adjust position for outer cells on high DPI displays. let xPx = x * cellSize + offset; let yPx = y * cellSize + offset; - if (isHighDPI && c.outer && xPx > size / 2) { - xPx -= 0.5; - } - if (isHighDPI && c.outer && yPx > size / 2) { - yPx -= 0.5; - } return (
@@ -279,3 +360,21 @@ export function PixelLoader(props: PixelLoaderProps) {
); } + +function useReducedMotion() { + const [isReducedMotion, setReducedMotion] = React.useState(false); + React.useEffect(() => { + if (typeof window === 'undefined') { + return; + } + let mq = window.matchMedia('(prefers-reduced-motion: reduce)'); + let update = () => { + setReducedMotion(mq.matches); + }; + + update(); + mq.addEventListener('change', update); + return () => mq.removeEventListener('change', update); + }, []); + return isReducedMotion; +} diff --git a/packages/@react-spectrum/ai/src/tokens.macro.ts b/packages/@react-spectrum/ai/src/tokens.macro.ts index 43fe81fa6f3..5abbfc6348e 100644 --- a/packages/@react-spectrum/ai/src/tokens.macro.ts +++ b/packages/@react-spectrum/ai/src/tokens.macro.ts @@ -68,7 +68,7 @@ export function brand(l: number, c: number, hueOffset: number, alpha = 1) { // Converts a single token color value to a brand-relative color, unless it's a // neutral (kept as-is) or not a color at all (e.g. an opacity number, kept as-is). -function convertColor(value: any) { +export function convertColor(value: any) { if (typeof value !== 'string') { return value; } @@ -109,7 +109,7 @@ export function token(name: string) { export function mix(gray: string, stop: string, opacity: string) { let stopColor = token(stop); let stopOpacity = token(opacity); - return `color-mix(in srgb, ${gray}, ${stopColor} ${stopOpacity}%)`; + return `color-mix(in oklch, ${gray}, ${stopColor} ${stopOpacity}%)`; } export function stop(gray: string, stop: string, opacity: string) { @@ -164,5 +164,79 @@ function outerBorderStop(stop: number, variant: string, colorScheme: string, div if (colorScheme === 'light') { opacity = opacity / div; } - return `rgb(from ${token(`outer-border.gradient.ob-hue.stop-${stop}.${colorScheme}`)} r g b / ${opacity}%)`; + return `oklch(from ${token(`outer-border.gradient.ob-hue.stop-${stop}.${colorScheme}`)} l c h / ${opacity}%)`; +} + +export function keyframes(this: any | void, css: string): string { + // Check if `this` is undefined, which means style was not called as a macro but as a normal function. + // We also check if this is globalThis, which happens in non-strict mode bundles. + // Also allow style to be called as a normal function in tests. + // @ts-ignore + + if ((this == null || this === globalThis) && process.env.NODE_ENV !== 'test') { + throw new Error('The keyframes macro must be imported with {type: "macro"}.'); + } + let name = generateArbitraryValueSelector(css, true); + css = `@keyframes ${name} { + ${css} +}`; + if (this && typeof this.addAsset === 'function') { + this.addAsset({ + type: 'css', + content: css + }); + } + return name; +} + +function generateArbitraryValueSelector(v: string, atStart = false) { + let c = toBase62(hash(v)); + if (atStart && /^[0-9]/.test(c)) { + c = `_${c}`; + } + return c; +} + +function toBase62(value: number) { + if (value === 0) { + return generateName(value); + } + + let res = ''; + while (value) { + let remainder = value % 62; + res += generateName(remainder); + value = Math.floor((value - remainder) / 62); + } + + return res; +} + +function generateName(index: number, atStart = false): string { + if (index < 26) { + // lower case letters + return String.fromCharCode(index + 97); + } + + if (index < 52) { + // upper case letters + return String.fromCharCode(index - 26 + 65); + } + + if (index < 62 && !atStart) { + // numbers + return String.fromCharCode(index - 52 + 48); + } + + return '_' + generateName(index - (atStart ? 52 : 62)); +} + +// djb2 hash function. +// http://www.cse.yorku.ca/~oz/hash.html +function hash(v: string) { + let hash = 5381; + for (let i = 0; i < v.length; i++) { + hash = ((hash << 5) + hash + v.charCodeAt(i)) >>> 0; + } + return hash; } diff --git a/packages/@react-spectrum/ai/stories/AIButton.stories.tsx b/packages/@react-spectrum/ai/stories/AIButton.stories.tsx new file mode 100644 index 00000000000..01575ef0d8e --- /dev/null +++ b/packages/@react-spectrum/ai/stories/AIButton.stories.tsx @@ -0,0 +1,45 @@ +/* + * 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 {AIButton} from '../src/AIButton'; +import type {Meta, StoryObj} from '@storybook/react'; + +const meta: Meta = { + component: AIButton, + parameters: { + layout: 'centered' + }, + argTypes: { + children: {table: {disable: true}}, + brandColor: { + control: 'color', + table: {category: 'Theming'} + } + }, + args: { + brandColor: 'rgb(236, 105, 255)' + }, + tags: ['autodocs'], + title: 'AI/AIButton' +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = { + render: args => ( +
+ Ask AI + +
+ ) +}; diff --git a/packages/@react-spectrum/s2/src/page.macro.ts b/packages/@react-spectrum/s2/src/page.macro.ts index 08e61eaab08..2739ad4f0d9 100644 --- a/packages/@react-spectrum/s2/src/page.macro.ts +++ b/packages/@react-spectrum/s2/src/page.macro.ts @@ -26,7 +26,7 @@ export function generatePageStyles(this: MacroContext | void): void { this.addAsset({ type: 'css', content: `:where(:root, :host) { - --s2-color-scheme: light dark; + --s2-color-scheme: light; color-scheme: var(--s2-color-scheme); --s2-container-bg: ${colorToken(tokens['background-base-color'])}; background: var(--s2-container-bg); @@ -40,6 +40,7 @@ export function generatePageStyles(this: MacroContext | void): void { --lightningcss-dark: ; @media (prefers-color-scheme: dark) { + --s2-color-scheme: dark; --lightningcss-light: ; --lightningcss-dark: initial; } diff --git a/packages/@react-spectrum/s2/src/style-utils.ts b/packages/@react-spectrum/s2/src/style-utils.ts index d67086de276..1c6cfff83bd 100644 --- a/packages/@react-spectrum/s2/src/style-utils.ts +++ b/packages/@react-spectrum/s2/src/style-utils.ts @@ -151,7 +151,10 @@ export const setColorScheme = () => type: 'colorScheme', value: { colorScheme: { - 'light dark': 'light dark', + 'light dark': { + default: 'light', + '@media (prefers-color-scheme: dark)': 'dark' + }, light: 'light', dark: 'dark' }