Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/components/COMPONENT_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
31 changes: 31 additions & 0 deletions docs/components/EXPERIMENTAL_AGENT_FEEDBACK.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions src/experimental/AgentFeedback.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof meta>;
const states: FeedbackStatus[] = ['idle', 'queued', 'running', 'partial', 'success', 'blocked', 'error'];

export const EnglishLTR: Story = { render: () => <MotionProvider><div className="core-root" dir="ltr" lang="en" style={{ display: 'grid', gap: 20, padding: 24 }}>
<AsyncStatus status="running" label="Auditing discoverability" detail="6 of 12 checks complete" action={<Button variant="secondary">Cancel</Button>} />
<Progress id="audit-en" label="Audit progress" value={50} valueLabel="6 of 12" />
<Progress id="queue-en" label="Waiting for a worker" valueLabel="Queued" />
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>{states.map(status => <StatusIndicator key={status} status={status} label={status} />)}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}><AgentStatusOrb status="running" label="Agent is running" /><AnimatedNumber value={82} format={value => `${Math.round(value)}%`} /></div>
</div></MotionProvider> };

export const PersianRTL: Story = { render: () => <MotionProvider><div className="core-root" dir="rtl" lang="fa" style={{ display: 'grid', gap: 20, padding: 24 }}>
<AsyncStatus status="partial" label="ارزیابی بخشی تکمیل شد" detail="سه بررسی به دلیل محدودیت شبکه انجام نشد." action={<Button variant="secondary">تلاش دوباره</Button>} />
<Progress id="audit-fa" label="پیشرفت ارزیابی" value={75} valueLabel="۹ از ۱۲" />
<StatusIndicator status="blocked" label="نیازمند اقدام" />
</div></MotionProvider> };

export const DarkAndReducedMotion: Story = { render: () => <div data-theme="dark" style={{ background: '#0c111d', padding: 24 }}><MotionProvider preference="reduce"><div className="core-root" dir="rtl" lang="fa" style={{ display: 'grid', gap: 20, padding: 24 }}>
<AsyncStatus status="running" label="در حال بررسی" detail="حرکت غیرضروری غیرفعال است." />
<Progress id="reduced-fa" label="پیشرفت نامشخص" valueLabel="در حال اجرا" />
<AgentStatusOrb status="running" label="عامل در حال اجراست" />
</div></MotionProvider></div> };
6 changes: 6 additions & 0 deletions src/experimental/AgentStatusOrb.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { HTMLAttributes } from 'react';
import type { FeedbackStatus } from './StatusIndicator';
export interface AgentStatusOrbProps extends HTMLAttributes<HTMLSpanElement> { status: FeedbackStatus; label: string; size?: 'sm' | 'md'; }
export function AgentStatusOrb({ status, label, size = 'md', className = '', ...props }: AgentStatusOrbProps) {
return <span className={`core-agent-orb core-agent-orb--${size} ${className}`.trim()} data-status={status} role="img" aria-label={label} {...props}><span className="core-agent-orb__core" aria-hidden="true" /></span>;
}
12 changes: 12 additions & 0 deletions src/experimental/AnimatedNumber.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useEffect, useRef, useState, type HTMLAttributes } from 'react';
import { useMotionPreference } from './MotionProvider';
export interface AnimatedNumberProps extends Omit<HTMLAttributes<HTMLSpanElement>, '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 <span className={`core-animated-number ${className}`.trim()} aria-label={format(value)} {...props}><span aria-hidden="true">{format(displayed)}</span></span>;
}
10 changes: 10 additions & 0 deletions src/experimental/AsyncStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { StatusIndicator, type FeedbackStatus } from './StatusIndicator';
export interface AsyncStatusProps extends Omit<HTMLAttributes<HTMLDivElement>, '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 <div className={`core-async-status ${className}`.trim()} data-status={status} aria-live={announce} aria-atomic="true" aria-busy={active || undefined} {...props}>
<StatusIndicator status={status} label={label} icon={icon} />
{detail && <div className="core-async-status__detail">{detail}</div>}{action && <div className="core-async-status__action">{action}</div>}
</div>;
}
30 changes: 30 additions & 0 deletions src/experimental/MotionProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<MotionContextValue>({ 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<ResolvedMotionPreference>(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 <MotionContext.Provider value={value}><div data-core-motion={resolvedPreference}>{children}</div></MotionContext.Provider>;
}

export function useMotionPreference() { return useContext(MotionContext); }
11 changes: 11 additions & 0 deletions src/experimental/Progress.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLAttributes<HTMLDivElement>, '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 <div className={`core-progress ${className}`.trim()} {...props}><div className="core-progress__header"><span id={labelId}>{label}</span>{valueLabel && <span>{valueLabel}</span>}</div>
<div className="core-progress__track" role="progressbar" aria-labelledby={labelId} aria-label={labelId ? undefined : typeof label === 'string' ? label : undefined} aria-valuemin={min} aria-valuemax={max} aria-valuenow={normalized} aria-valuetext={typeof valueLabel === 'string' ? valueLabel : undefined} data-indeterminate={!determinate || undefined}>
<span className="core-progress__bar" style={determinate ? { inlineSize: `${percent}%` } : undefined} />
</div></div>;
}
11 changes: 11 additions & 0 deletions src/experimental/StatusIndicator.tsx
Original file line number Diff line number Diff line change
@@ -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<FeedbackStatus, FeedbackTone> = { idle: 'neutral', queued: 'neutral', running: 'info', partial: 'warning', success: 'success', blocked: 'warning', error: 'danger' };
export interface StatusIndicatorProps extends HTMLAttributes<HTMLSpanElement> { status: FeedbackStatus; label: ReactNode; icon?: ReactNode; showDot?: boolean; }
export function StatusIndicator({ status, label, icon, showDot = true, className = '', ...props }: StatusIndicatorProps) {
const tone = toneByStatus[status];
return <span className={`core-status-indicator core-status-indicator--${tone} ${className}`.trim()} data-status={status} {...props}>
{icon ? <span aria-hidden="true">{icon}</span> : showDot ? <span className="core-status-indicator__dot" aria-hidden="true" /> : null}<span>{label}</span>
</span>;
}
6 changes: 6 additions & 0 deletions src/experimental/feedback.test.ts
Original file line number Diff line number Diff line change
@@ -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); });
});
6 changes: 6 additions & 0 deletions src/experimental/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from './MotionProvider';
export * from './StatusIndicator';
export * from './AsyncStatus';
export * from './Progress';
export * from './AnimatedNumber';
export * from './AgentStatusOrb';
Loading
Loading