diff --git a/docs/components/COMPONENT_MATRIX.md b/docs/components/COMPONENT_MATRIX.md index 1d283e8..7b6a26c 100644 --- a/docs/components/COMPONENT_MATRIX.md +++ b/docs/components/COMPONENT_MATRIX.md @@ -17,8 +17,11 @@ Updated: 2026-09-02 | PageHeader | Product-neutral composition | Candidate | Design System | AgentReady report and Console candidate | One h1; responsive actions | Validate in two products | | EmptyState | Product-neutral composition | Candidate | Design System | AgentReady #9/#14; Console candidate | Heading, recovery and useful next action | Validate in two products | | ThemeProvider | Headless foundation | Experimental | Design System | Existing package | Preserve preference and content | Light/dark regression coverage | -| AsyncStatus | Headless primitive | Proposed | Design System | AgentReady #14 | Announced state changes and stable vocabulary | Issue #12 | -| Progress | Styled primitive | Proposed | Design System | AgentReady #14 | Accessible name/value and indeterminate mode | Issue #12 | +| MotionProvider | Headless foundation | Experimental | Design System | AgentReady #14 | System/forced preference and static fallback | Validate in AgentReady | +| AsyncStatus | Headless primitive | Experimental | Design System | AgentReady #14 | Visible text, controlled live region and stable vocabulary | Validate in AgentReady | +| Progress | Styled primitive | Experimental | Design System | AgentReady #14 | Accessible name/value and indeterminate mode | Validate in AgentReady | +| StatusIndicator | Styled primitive | Experimental | Design System | AgentReady #14 | Required visible label; color/motion supplementary | Validate in AgentReady | +| AnimatedNumber | Styled primitive | Experimental | Design System | AgentReady score/metrics | Final value announced; static reduced-motion fallback | Validate in AgentReady | | Disclosure/Accordion | Headless primitive | Proposed | Design System | AgentReady #8/#14 | Keyboard and expanded state | Future slice | | Tabs/SegmentedControl | Headless/styled primitive | Proposed | Design System | AgentReady report filters | Focus and selected-state semantics | Future slice | | Tooltip | Headless primitive | Proposed | Design System | AgentReady evidence help | Hover/focus/dismissal | Future slice | @@ -29,5 +32,5 @@ Updated: 2026-09-02 | Finding/EvidenceCard | Product composition | Experimental-local | AgentReady | AgentReady #8/#14 | Structured disclosure and bidi-safe evidence | Keep in AgentReady | | RecommendationCard | Product composition | Experimental-local | AgentReady | AgentReady #8/#14 | Priority/action not color-only | Keep in AgentReady | | AgentJourneyTimeline | Product composition | Deferred | AgentReady | AgentReady #2/#14 | Ordered steps and explicit outcomes | Wait for journey schema | -| AgentStatusOrb | Experimental adapter | Deferred | Design System | AgentReady idea | Never sole cue; reduced motion; static fallback | Issue #12 | +| AgentStatusOrb | Experimental adapter | Experimental | Design System | AgentReady idea | Required label; never sole cue; static fallback | Validate alongside AsyncStatus | | Canvas/WebGL effects | Brand experiment | Deferred | Consumer | No production evidence | Static fallback and performance budget | Keep out of base package | diff --git a/docs/components/EXPERIMENTAL_AGENT_FEEDBACK.md b/docs/components/EXPERIMENTAL_AGENT_FEEDBACK.md new file mode 100644 index 0000000..6a264ea --- /dev/null +++ b/docs/components/EXPERIMENTAL_AGENT_FEEDBACK.md @@ -0,0 +1,31 @@ +# Experimental agent feedback + +Status: Experimental. AgentReady must validate these contracts before promotion. + +## Contract + +- Import components from `src/experimental` and styles explicitly from `src/experimental/styles.css`; the stable entry and stable CSS do not import this slice. +- Every state requires visible text. Color, pulse and `AgentStatusOrb` are supplementary. +- `AsyncStatus` uses polite announcements by default and assertive announcements only for blocked/error states. Consumers should update one mounted region rather than append repeated live regions. +- Determinate `Progress` exposes min/max/value; indeterminate progress omits `aria-valuenow`. +- `AnimatedNumber` exposes the final formatted value to assistive technology and becomes static in reduced-motion mode. +- Streaming containers must reserve a minimum block size, retain stable keys and update content in place to avoid layout shifts. + +## Motion budget + +| Role | Token | Budget | +|---|---|---| +| Instant feedback | `--core-motion-instant` | 0 ms | +| Hover/focus | `--core-motion-fast` | 120 ms | +| State/layout move | `--core-motion-normal` | 240 ms | +| One-off emphasis | `--core-motion-slow` | 400 ms maximum | + +Stagger is consumer-owned: 30–50 ms between items, at most 6 items, and no sequence longer than 500 ms. Continuous status animation uses only opacity/transform and stops when reduced motion is requested. Animate no more than one orb and one progress indicator in the primary mobile viewport. No filter, blur, box-shadow, layout or GPU-canvas animation is permitted in critical flows. + +## Provenance + +The API was informed by public patterns from Beautiful UI and beUI. `AgentStatusOrb` is an original CSS adapter inspired by the state vocabulary of Thinking Orbs; no upstream source or Canvas implementation was copied. It has no runtime dependency and is not a reasoning/chain-of-thought display. Rare UI informed the opt-in delivery model. Canvas UI remains excluded due to critical-flow suitability and licensing/performance constraints. + +## Promotion evidence + +Capture AgentReady evidence for queued, running, partial, blocked, error and success states; Persian RTL and English LTR; light/dark; keyboard and screen-reader announcements; a 360 px viewport; reduced motion; and low-end mobile trace without long animation frames. diff --git a/src/experimental/AgentFeedback.stories.tsx b/src/experimental/AgentFeedback.stories.tsx new file mode 100644 index 0000000..27c6f1d --- /dev/null +++ b/src/experimental/AgentFeedback.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Button } from '../components/Button'; +import { AgentStatusOrb } from './AgentStatusOrb'; +import { AnimatedNumber } from './AnimatedNumber'; +import { AsyncStatus } from './AsyncStatus'; +import { MotionProvider } from './MotionProvider'; +import { Progress } from './Progress'; +import { StatusIndicator, type FeedbackStatus } from './StatusIndicator'; +import './styles.css'; + +const meta = { title: 'Experimental/Agent feedback', parameters: { layout: 'padded' } } satisfies Meta; +export default meta; type Story = StoryObj; +const states: FeedbackStatus[] = ['idle', 'queued', 'running', 'partial', 'success', 'blocked', 'error']; + +export const EnglishLTR: Story = { render: () =>
+ Cancel} /> + + +
{states.map(status => )}
+
`${Math.round(value)}%`} />
+
}; + +export const PersianRTL: Story = { render: () =>
+ تلاش دوباره} /> + + +
}; + +export const DarkAndReducedMotion: Story = { render: () =>
+ + + +
}; diff --git a/src/experimental/AgentStatusOrb.tsx b/src/experimental/AgentStatusOrb.tsx new file mode 100644 index 0000000..6275997 --- /dev/null +++ b/src/experimental/AgentStatusOrb.tsx @@ -0,0 +1,6 @@ +import type { HTMLAttributes } from 'react'; +import type { FeedbackStatus } from './StatusIndicator'; +export interface AgentStatusOrbProps extends HTMLAttributes { status: FeedbackStatus; label: string; size?: 'sm' | 'md'; } +export function AgentStatusOrb({ status, label, size = 'md', className = '', ...props }: AgentStatusOrbProps) { + return ; +} diff --git a/src/experimental/AnimatedNumber.tsx b/src/experimental/AnimatedNumber.tsx new file mode 100644 index 0000000..6421276 --- /dev/null +++ b/src/experimental/AnimatedNumber.tsx @@ -0,0 +1,12 @@ +import { useEffect, useRef, useState, type HTMLAttributes } from 'react'; +import { useMotionPreference } from './MotionProvider'; +export interface AnimatedNumberProps extends Omit, 'children'> { value: number; format?: (value: number) => string; duration?: number; } +export function AnimatedNumber({ value, format = String, duration = 320, className = '', ...props }: AnimatedNumberProps) { + const { reduceMotion } = useMotionPreference(); const previous = useRef(value); const [displayed, setDisplayed] = useState(value); + useEffect(() => { const from = previous.current; previous.current = value; + if (reduceMotion || duration <= 0 || typeof requestAnimationFrame !== 'function') { setDisplayed(value); return; } + let frame = 0; const startedAt = performance.now(); const tick = (now: number) => { const progress = Math.min(1, (now - startedAt) / duration); const eased = 1 - Math.pow(1 - progress, 3); setDisplayed(from + (value - from) * eased); if (progress < 1) frame = requestAnimationFrame(tick); }; + frame = requestAnimationFrame(tick); return () => cancelAnimationFrame(frame); + }, [duration, reduceMotion, value]); + return ; +} diff --git a/src/experimental/AsyncStatus.tsx b/src/experimental/AsyncStatus.tsx new file mode 100644 index 0000000..e58e40a --- /dev/null +++ b/src/experimental/AsyncStatus.tsx @@ -0,0 +1,10 @@ +import type { HTMLAttributes, ReactNode } from 'react'; +import { StatusIndicator, type FeedbackStatus } from './StatusIndicator'; +export interface AsyncStatusProps extends Omit, 'children'> { status: FeedbackStatus; label: ReactNode; detail?: ReactNode; action?: ReactNode; icon?: ReactNode; announce?: 'off' | 'polite' | 'assertive'; } +export function AsyncStatus({ status, label, detail, action, icon, announce = status === 'error' || status === 'blocked' ? 'assertive' : 'polite', className = '', ...props }: AsyncStatusProps) { + const active = status === 'queued' || status === 'running'; + return
+ + {detail &&
{detail}
}{action &&
{action}
} +
; +} diff --git a/src/experimental/MotionProvider.tsx b/src/experimental/MotionProvider.tsx new file mode 100644 index 0000000..42464aa --- /dev/null +++ b/src/experimental/MotionProvider.tsx @@ -0,0 +1,30 @@ +import { createContext, useContext, useEffect, useMemo, useState, type PropsWithChildren } from 'react'; + +export type MotionPreference = 'system' | 'reduce' | 'full'; +export type ResolvedMotionPreference = 'reduce' | 'full'; + +interface MotionContextValue { preference: MotionPreference; resolvedPreference: ResolvedMotionPreference; reduceMotion: boolean; } +const MotionContext = createContext({ preference: 'system', resolvedPreference: 'full', reduceMotion: false }); + +function systemPreference(): ResolvedMotionPreference { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return 'full'; + return window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'reduce' : 'full'; +} + +export interface MotionProviderProps extends PropsWithChildren { preference?: MotionPreference; } + +export function MotionProvider({ preference = 'system', children }: MotionProviderProps) { + const [system, setSystem] = useState(systemPreference); + useEffect(() => { + if (preference !== 'system' || typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; + const media = window.matchMedia('(prefers-reduced-motion: reduce)'); + const update = () => setSystem(media.matches ? 'reduce' : 'full'); + update(); media.addEventListener?.('change', update); + return () => media.removeEventListener?.('change', update); + }, [preference]); + const resolvedPreference = preference === 'system' ? system : preference; + const value = useMemo(() => ({ preference, resolvedPreference, reduceMotion: resolvedPreference === 'reduce' }), [preference, resolvedPreference]); + return
{children}
; +} + +export function useMotionPreference() { return useContext(MotionContext); } diff --git a/src/experimental/Progress.tsx b/src/experimental/Progress.tsx new file mode 100644 index 0000000..fb53e4e --- /dev/null +++ b/src/experimental/Progress.tsx @@ -0,0 +1,11 @@ +import type { HTMLAttributes, ReactNode } from 'react'; +export function normalizeProgress(value: number, min = 0, max = 100) { if (!isFinite(value) || max <= min) return min; return Math.min(max, Math.max(min, value)); } +export interface ProgressProps extends Omit, 'children'> { label: ReactNode; value?: number; min?: number; max?: number; valueLabel?: ReactNode; } +export function Progress({ label, value, min = 0, max = 100, valueLabel, className = '', ...props }: ProgressProps) { + const determinate = typeof value === 'number'; const normalized = determinate ? normalizeProgress(value, min, max) : undefined; + const percent = determinate && max > min ? ((normalized! - min) / (max - min)) * 100 : 0; const labelId = props.id ? `${props.id}-label` : undefined; + return
{label}{valueLabel && {valueLabel}}
+
+ +
; +} diff --git a/src/experimental/StatusIndicator.tsx b/src/experimental/StatusIndicator.tsx new file mode 100644 index 0000000..668e409 --- /dev/null +++ b/src/experimental/StatusIndicator.tsx @@ -0,0 +1,11 @@ +import type { HTMLAttributes, ReactNode } from 'react'; +export type FeedbackStatus = 'idle' | 'queued' | 'running' | 'partial' | 'success' | 'blocked' | 'error'; +export type FeedbackTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger'; +const toneByStatus: Record = { idle: 'neutral', queued: 'neutral', running: 'info', partial: 'warning', success: 'success', blocked: 'warning', error: 'danger' }; +export interface StatusIndicatorProps extends HTMLAttributes { status: FeedbackStatus; label: ReactNode; icon?: ReactNode; showDot?: boolean; } +export function StatusIndicator({ status, label, icon, showDot = true, className = '', ...props }: StatusIndicatorProps) { + const tone = toneByStatus[status]; + return + {icon ? : showDot ? ; +} diff --git a/src/experimental/feedback.test.ts b/src/experimental/feedback.test.ts new file mode 100644 index 0000000..d3523c5 --- /dev/null +++ b/src/experimental/feedback.test.ts @@ -0,0 +1,6 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeProgress } from './Progress'; +describe('normalizeProgress', () => { + it('clamps transitions without changing layout ranges', () => { expect(normalizeProgress(-10)).toBe(0); expect(normalizeProgress(50)).toBe(50); expect(normalizeProgress(140)).toBe(100); }); + it('supports custom ranges and invalid input', () => { expect(normalizeProgress(4, 2, 6)).toBe(4); expect(normalizeProgress(Number.NaN, 2, 6)).toBe(2); expect(normalizeProgress(3, 5, 5)).toBe(5); }); +}); diff --git a/src/experimental/index.ts b/src/experimental/index.ts new file mode 100644 index 0000000..e662ffb --- /dev/null +++ b/src/experimental/index.ts @@ -0,0 +1,6 @@ +export * from './MotionProvider'; +export * from './StatusIndicator'; +export * from './AsyncStatus'; +export * from './Progress'; +export * from './AnimatedNumber'; +export * from './AgentStatusOrb'; diff --git a/src/experimental/styles.css b/src/experimental/styles.css new file mode 100644 index 0000000..4e2e485 --- /dev/null +++ b/src/experimental/styles.css @@ -0,0 +1,52 @@ +:root { + --core-motion-instant: 0ms; + --core-motion-fast: 120ms; + --core-motion-normal: 240ms; + --core-motion-slow: 400ms; + --core-ease-enter: cubic-bezier(.2, .8, .2, 1); + --core-ease-exit: cubic-bezier(.4, 0, 1, 1); + --core-ease-move: cubic-bezier(.4, 0, .2, 1); + --core-ease-emphasize: cubic-bezier(.2, .9, .3, 1.2); + --core-ease-progress: linear; +} + +.core-status-indicator { display: inline-flex; align-items: center; gap: var(--core-space-2); color: var(--core-color-text-secondary); font-weight: 600; } +.core-status-indicator__dot { inline-size: 8px; block-size: 8px; border-radius: 50%; background: currentColor; flex: none; } +.core-status-indicator--info { color: var(--core-color-info); } +.core-status-indicator--success { color: var(--core-color-success); } +.core-status-indicator--warning { color: var(--core-color-warning); } +.core-status-indicator--danger { color: var(--core-color-danger); } +.core-status-indicator[data-status='running'] .core-status-indicator__dot { animation: core-status-pulse 1.4s var(--core-ease-move) infinite; } + +.core-async-status { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--core-space-2) var(--core-space-4); min-block-size: 64px; padding: var(--core-space-3) var(--core-space-4); border: 1px solid var(--core-color-border-default); border-radius: var(--core-radius-md); background: var(--core-color-surface-default); } +.core-async-status__detail { grid-column: 1; min-inline-size: 0; color: var(--core-color-text-secondary); font-size: .875rem; } +.core-async-status__action { grid-column: 2; grid-row: 1 / span 2; } + +.core-progress { display: grid; gap: var(--core-space-2); } +.core-progress__header { display: flex; justify-content: space-between; gap: var(--core-space-4); color: var(--core-color-text-secondary); font-size: .875rem; font-weight: 600; } +.core-progress__track { position: relative; overflow: hidden; block-size: 8px; border-radius: 999px; background: var(--core-color-surface-subtle); } +.core-progress__bar { display: block; block-size: 100%; border-radius: inherit; background: var(--core-color-action-primary); transition: inline-size var(--core-motion-normal) var(--core-ease-move); } +.core-progress__track[data-indeterminate='true'] .core-progress__bar { inline-size: 40%; animation: core-progress-indeterminate 1.2s var(--core-ease-progress) infinite; } + +.core-animated-number { display: inline-block; font-variant-numeric: tabular-nums; } +.core-agent-orb { --core-orb-color: var(--core-color-text-secondary); display: inline-grid; place-items: center; inline-size: 32px; block-size: 32px; border-radius: 50%; background: color-mix(in srgb, var(--core-orb-color) 16%, transparent); } +.core-agent-orb--sm { inline-size: 20px; block-size: 20px; } +.core-agent-orb[data-status='running'] { --core-orb-color: var(--core-color-info); } +.core-agent-orb[data-status='success'] { --core-orb-color: var(--core-color-success); } +.core-agent-orb[data-status='partial'], .core-agent-orb[data-status='blocked'] { --core-orb-color: var(--core-color-warning); } +.core-agent-orb[data-status='error'] { --core-orb-color: var(--core-color-danger); } +.core-agent-orb__core { inline-size: 45%; block-size: 45%; border-radius: 50%; background: var(--core-orb-color); transform: scale(.9); } +.core-agent-orb[data-status='running'] .core-agent-orb__core { animation: core-orb-breathe 1.4s var(--core-ease-emphasize) infinite alternate; } + +@keyframes core-status-pulse { 50% { opacity: .45; transform: scale(.8); } } +@keyframes core-progress-indeterminate { from { transform: translateX(-110%); } to { transform: translateX(260%); } } +@keyframes core-orb-breathe { to { transform: scale(1.25); } } + +[dir='rtl'] .core-progress__track[data-indeterminate='true'] .core-progress__bar { animation-direction: reverse; } +[data-core-motion='reduce'] *, [data-core-motion='reduce'] *::before, [data-core-motion='reduce'] *::after { animation-duration: 1ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: 1ms !important; } +@media (prefers-reduced-motion: reduce) { + .core-status-indicator__dot, .core-progress__bar, .core-agent-orb__core { animation: none !important; transition: none !important; transform: none !important; } + .core-progress__track[data-indeterminate='true'] .core-progress__bar { inline-size: 100%; opacity: .55; } +} + +@media (max-width: 480px) { .core-async-status { grid-template-columns: 1fr; } .core-async-status__action { grid-column: 1; grid-row: auto; } } diff --git a/src/index.ts b/src/index.ts index 216927e..9c7d9f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,5 +13,6 @@ export * from './components/EmptyState'; export * from './tokens'; export * from './icons'; export * from './themes'; +export * from './experimental'; import './styles/index.css';