@paul-portfolio/react 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -146,6 +146,126 @@ Renders content that's available to screen readers but hidden visually.
146
146
  <VisuallyHidden>Loading</VisuallyHidden>
147
147
  ```
148
148
 
149
+ ## AI / LLM app components
150
+
151
+ A set of interaction-heavy components for building assistant and chat surfaces.
152
+ All are keyboard-operable and carry an axe test like the rest of the package.
153
+
154
+ ### RichTextEditor
155
+
156
+ A small rich-text editor on a `contentEditable` region. The toolbar is
157
+ configurable (`bold`, `italic`, `underline`, `h2`, `bulletList`, `orderedList`,
158
+ `code`, `link`), Ctrl/Cmd+B/I/U work from the keyboard, and it emits HTML on
159
+ every edit.
160
+
161
+ ```tsx
162
+ <RichTextEditor
163
+ label="Prompt"
164
+ toolbar={["bold", "italic", "code"]}
165
+ onChange={(html) => setDraft(html)}
166
+ />
167
+ ```
168
+
169
+ ### ChatMessage
170
+
171
+ A chat bubble aligned and coloured by `role` (`user` | `assistant` | `system`),
172
+ with optional `avatar`, `name`, and `timestamp`. Pass `pending` while a reply is
173
+ streaming to show the typing indicator.
174
+
175
+ ```tsx
176
+ <ChatMessage role="assistant" name="Assistant" timestamp="10:30">
177
+ Here's the summary you asked for.
178
+ </ChatMessage>
179
+ <ChatMessage role="assistant" pending />
180
+ ```
181
+
182
+ ### ChatComposer
183
+
184
+ An auto-growing prompt field. Enter sends, Shift+Enter inserts a newline, empty
185
+ messages don't send, and the control locks while `busy`.
186
+
187
+ ```tsx
188
+ <ChatComposer label="Message" onSubmit={send} busy={waiting} maxLength={2000} />
189
+ ```
190
+
191
+ ### StreamingText
192
+
193
+ Reveals text a few characters at a time, the way a streamed model reply arrives,
194
+ with a caret and a polite live region. Honours `prefers-reduced-motion` by
195
+ showing the whole string at once.
196
+
197
+ ```tsx
198
+ <StreamingText text={reply} speed={2} interval={30} onDone={scrollToEnd} />
199
+ ```
200
+
201
+ ### TypingDots
202
+
203
+ The three-dot "assistant is typing" indicator. The animation is decorative and
204
+ hidden from assistive tech; the `label` carries the meaning.
205
+
206
+ ```tsx
207
+ <TypingDots label="Assistant is typing" />
208
+ ```
209
+
210
+ ### CodeBlock
211
+
212
+ A read-only code panel with a language label and a copy button that reports
213
+ success to assistive tech. Optional decorative line numbers.
214
+
215
+ ```tsx
216
+ <CodeBlock code={snippet} language="ts" filename="stream.ts" showLineNumbers />
217
+ ```
218
+
219
+ ### CommandPalette
220
+
221
+ A ⌘K command menu. Type to filter (label + `keywords`), arrow keys to move, Enter
222
+ to run, Escape to close, with optional group headings. Follows the
223
+ combobox/listbox pattern with `aria-activedescendant`.
224
+
225
+ ```tsx
226
+ <CommandPalette
227
+ open={open}
228
+ onClose={() => setOpen(false)}
229
+ commands={[{ id: "new", label: "New chat", onSelect: startChat }]}
230
+ />
231
+ ```
232
+
233
+ ### Combobox
234
+
235
+ An accessible autocomplete — a filtering text input with a listbox popup and
236
+ full keyboard support. Handy for model/tool pickers.
237
+
238
+ ```tsx
239
+ <Combobox label="Model" options={models} value={model} onChange={setModel} />
240
+ ```
241
+
242
+ ### Toast
243
+
244
+ Wrap the app in `ToastProvider` and raise notifications with `useToast()`. Toasts
245
+ stack in a live region (errors announce assertively) and auto-dismiss unless
246
+ `duration` is `0`.
247
+
248
+ ```tsx
249
+ const { toast } = useToast();
250
+ toast({ title: "Saved", description: "Your changes are safe.", variant: "success" });
251
+ ```
252
+
253
+ ### TokenUsageMeter
254
+
255
+ A budget bar for LLM token usage: prompt and completion tokens as two segments of
256
+ a track sized against `maxTokens`, with the used total, percent, and an optional
257
+ cost estimate. Shifts to a warning tone near the budget and an over tone past it.
258
+
259
+ ```tsx
260
+ <TokenUsageMeter
261
+ label="Context window"
262
+ promptTokens={3200}
263
+ completionTokens={1400}
264
+ maxTokens={8000}
265
+ costPerMTok={3}
266
+ />
267
+ ```
268
+
149
269
  ### cx
150
270
 
151
271
  A tiny classname joiner used internally, exported for convenience.
@@ -0,0 +1,23 @@
1
+ type ChatComposerProps = {
2
+ /** Accessible name for the message field. */
3
+ label: string;
4
+ /** Visually hide the label; keep it for screen readers. */
5
+ hideLabel?: boolean;
6
+ /** Fires with the trimmed message when the user sends. */
7
+ onSubmit: (message: string) => void;
8
+ placeholder?: string;
9
+ /** Disable the field and button, e.g. while a reply is in flight. */
10
+ busy?: boolean;
11
+ disabled?: boolean;
12
+ /** Label for the send button. */
13
+ submitLabel?: string;
14
+ maxLength?: number;
15
+ className?: string;
16
+ };
17
+ /**
18
+ * A prompt composer for chat/AI surfaces: an auto-growing textarea that sends
19
+ * on Enter and inserts a newline on Shift+Enter. Empty messages don't send, and
20
+ * the whole control locks while `busy`.
21
+ */
22
+ export declare function ChatComposer({ label, hideLabel, onSubmit, placeholder, busy, disabled, submitLabel, maxLength, className, }: ChatComposerProps): import("react").JSX.Element;
23
+ export {};
@@ -0,0 +1,46 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId, useRef, useState, } from 'react';
3
+ import { cx } from './cx';
4
+ const MAX_ROWS_HEIGHT = 200;
5
+ /**
6
+ * A prompt composer for chat/AI surfaces: an auto-growing textarea that sends
7
+ * on Enter and inserts a newline on Shift+Enter. Empty messages don't send, and
8
+ * the whole control locks while `busy`.
9
+ */
10
+ export function ChatComposer({ label, hideLabel = true, onSubmit, placeholder = 'Send a message…', busy = false, disabled = false, submitLabel = 'Send', maxLength, className, }) {
11
+ const id = useId();
12
+ const [value, setValue] = useState('');
13
+ const textareaRef = useRef(null);
14
+ const locked = busy || disabled;
15
+ function grow() {
16
+ const el = textareaRef.current;
17
+ if (!el)
18
+ return;
19
+ el.style.height = 'auto';
20
+ el.style.height = `${Math.min(el.scrollHeight, MAX_ROWS_HEIGHT)}px`;
21
+ }
22
+ function send() {
23
+ const message = value.trim();
24
+ if (!message || locked)
25
+ return;
26
+ onSubmit(message);
27
+ setValue('');
28
+ const el = textareaRef.current;
29
+ if (el)
30
+ el.style.height = 'auto';
31
+ }
32
+ function handleSubmit(e) {
33
+ e.preventDefault();
34
+ send();
35
+ }
36
+ function handleKeyDown(e) {
37
+ if (e.key === 'Enter' && !e.shiftKey) {
38
+ e.preventDefault();
39
+ send();
40
+ }
41
+ }
42
+ return (_jsxs("form", { className: cx('chat-composer', className), onSubmit: handleSubmit, children: [_jsx("label", { className: hideLabel ? 'sr-only' : 'chat-composer__label', htmlFor: id, children: label }), _jsxs("div", { className: "chat-composer__row", children: [_jsx("textarea", { ref: textareaRef, id: id, className: "chat-composer__field", rows: 1, value: value, placeholder: placeholder, disabled: locked, maxLength: maxLength, onChange: (e) => {
43
+ setValue(e.target.value);
44
+ grow();
45
+ }, onKeyDown: handleKeyDown }), _jsx("button", { type: "submit", className: "chat-composer__send", disabled: locked || value.trim().length === 0, children: submitLabel })] }), maxLength != null && (_jsxs("span", { className: "chat-composer__count", "aria-live": "polite", children: [value.length, " / ", maxLength] }))] }));
46
+ }
@@ -0,0 +1,22 @@
1
+ import { type ReactNode } from 'react';
2
+ export type ChatRole = 'user' | 'assistant' | 'system';
3
+ type ChatMessageProps = {
4
+ role: ChatRole;
5
+ /** Display name for the sender. */
6
+ name?: string;
7
+ /** Rendered timestamp string (already formatted by the caller). */
8
+ timestamp?: string;
9
+ /** Optional avatar node (e.g. an <Avatar />). */
10
+ avatar?: ReactNode;
11
+ /** Show a typing indicator instead of content. */
12
+ pending?: boolean;
13
+ className?: string;
14
+ children?: ReactNode;
15
+ };
16
+ /**
17
+ * A single chat bubble, aligned and coloured by role. Renders as an article so
18
+ * each turn is a navigable landmark, with the role in its accessible name.
19
+ * Pass `pending` while an assistant reply is streaming to show TypingDots.
20
+ */
21
+ export declare function ChatMessage({ role, name, timestamp, avatar, pending, className, children, }: ChatMessageProps): import("react").JSX.Element;
22
+ export {};
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from './cx';
3
+ import { TypingDots } from './TypingDots';
4
+ const ROLE_LABEL = {
5
+ user: 'You',
6
+ assistant: 'Assistant',
7
+ system: 'System',
8
+ };
9
+ /**
10
+ * A single chat bubble, aligned and coloured by role. Renders as an article so
11
+ * each turn is a navigable landmark, with the role in its accessible name.
12
+ * Pass `pending` while an assistant reply is streaming to show TypingDots.
13
+ */
14
+ export function ChatMessage({ role, name, timestamp, avatar, pending = false, className, children, }) {
15
+ const roleLabel = ROLE_LABEL[role];
16
+ return (_jsxs("article", { className: cx('chat-message', `chat-message--${role}`, className), "aria-label": `${roleLabel} message`, children: [avatar && _jsx("div", { className: "chat-message__avatar", children: avatar }), _jsxs("div", { className: "chat-message__main", children: [(name || timestamp) && (_jsxs("div", { className: "chat-message__meta", children: [name && _jsx("span", { className: "chat-message__name", children: name }), timestamp && _jsx("span", { className: "chat-message__time", children: timestamp })] })), _jsx("div", { className: "chat-message__bubble", children: pending ? _jsx(TypingDots, { label: `${roleLabel} is typing` }) : children })] })] }));
17
+ }
@@ -0,0 +1,17 @@
1
+ type CodeBlockProps = {
2
+ code: string;
3
+ /** Language label shown in the header (display only). */
4
+ language?: string;
5
+ /** Optional filename shown alongside the language. */
6
+ filename?: string;
7
+ /** Prefix each line with a line number. */
8
+ showLineNumbers?: boolean;
9
+ className?: string;
10
+ };
11
+ /**
12
+ * A read-only code panel with a language label and a copy button that reports
13
+ * success back to assistive tech. Line numbers are decorative and hidden from
14
+ * the a11y tree so a screen reader reads the code, not the gutter.
15
+ */
16
+ export declare function CodeBlock({ code, language, filename, showLineNumbers, className, }: CodeBlockProps): import("react").JSX.Element;
17
+ export {};
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef, useState } from 'react';
3
+ import { cx } from './cx';
4
+ /**
5
+ * A read-only code panel with a language label and a copy button that reports
6
+ * success back to assistive tech. Line numbers are decorative and hidden from
7
+ * the a11y tree so a screen reader reads the code, not the gutter.
8
+ */
9
+ export function CodeBlock({ code, language, filename, showLineNumbers = false, className, }) {
10
+ const [copied, setCopied] = useState(false);
11
+ const timer = useRef(null);
12
+ async function copy() {
13
+ try {
14
+ await navigator.clipboard.writeText(code);
15
+ setCopied(true);
16
+ if (timer.current)
17
+ clearTimeout(timer.current);
18
+ timer.current = setTimeout(() => setCopied(false), 2000);
19
+ }
20
+ catch {
21
+ // Clipboard blocked (permissions/insecure context) — leave state as is.
22
+ }
23
+ }
24
+ const lines = code.split('\n');
25
+ return (_jsxs("div", { className: cx('code-block', className), children: [_jsxs("div", { className: "code-block__head", children: [_jsxs("span", { className: "code-block__lang", children: [filename && _jsx("span", { className: "code-block__file", children: filename }), language] }), _jsx("button", { type: "button", className: "code-block__copy", "aria-label": copied ? 'Copied' : 'Copy code', onClick: copy, children: copied ? 'Copied' : 'Copy' })] }), _jsx("pre", { className: "code-block__pre", children: _jsx("code", { children: showLineNumbers
26
+ ? lines.map((line, i) => (_jsxs("span", { className: "code-block__line", children: [_jsx("span", { className: "code-block__ln", "aria-hidden": "true", children: i + 1 }), _jsx("span", { className: "code-block__code", children: line })] }, i)))
27
+ : code }) })] }));
28
+ }
@@ -0,0 +1,22 @@
1
+ export type ComboboxOption = {
2
+ value: string;
3
+ label: string;
4
+ };
5
+ type ComboboxProps = {
6
+ label: string;
7
+ hideLabel?: boolean;
8
+ options: ComboboxOption[];
9
+ /** Controlled selected value. */
10
+ value?: string;
11
+ onChange: (value: string) => void;
12
+ placeholder?: string;
13
+ className?: string;
14
+ };
15
+ /**
16
+ * An accessible autocomplete — a text input that filters a list of options and
17
+ * commits one on Enter or click. Great for model/tool pickers. Implements the
18
+ * ARIA combobox pattern with aria-expanded and aria-activedescendant so the
19
+ * active option is announced while focus stays in the input.
20
+ */
21
+ export declare function Combobox({ label, hideLabel, options, value, onChange, placeholder, className, }: ComboboxProps): import("react").JSX.Element;
22
+ export {};
@@ -0,0 +1,78 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useId, useMemo, useState, } from 'react';
3
+ import { cx } from './cx';
4
+ /**
5
+ * An accessible autocomplete — a text input that filters a list of options and
6
+ * commits one on Enter or click. Great for model/tool pickers. Implements the
7
+ * ARIA combobox pattern with aria-expanded and aria-activedescendant so the
8
+ * active option is announced while focus stays in the input.
9
+ */
10
+ export function Combobox({ label, hideLabel = false, options, value, onChange, placeholder, className, }) {
11
+ const baseId = useId();
12
+ const listId = `${baseId}-list`;
13
+ const selectedLabel = options.find((o) => o.value === value)?.label ?? '';
14
+ const [query, setQuery] = useState(selectedLabel);
15
+ const [open, setOpen] = useState(false);
16
+ const [active, setActive] = useState(-1);
17
+ // Reflect an externally-changed value back into the field.
18
+ useEffect(() => {
19
+ setQuery(selectedLabel);
20
+ }, [selectedLabel]);
21
+ // While closed the field shows the selected label, so don't filter by it.
22
+ const filtered = useMemo(() => {
23
+ if (!open)
24
+ return options;
25
+ const q = query.trim().toLowerCase();
26
+ if (!q)
27
+ return options;
28
+ return options.filter((o) => o.label.toLowerCase().includes(q));
29
+ }, [options, query, open]);
30
+ const optionId = (index) => `${baseId}-opt-${index}`;
31
+ function commit(option) {
32
+ if (!option)
33
+ return;
34
+ onChange(option.value);
35
+ setQuery(option.label);
36
+ setOpen(false);
37
+ setActive(-1);
38
+ }
39
+ function handleKeyDown(e) {
40
+ switch (e.key) {
41
+ case 'ArrowDown':
42
+ e.preventDefault();
43
+ if (!open) {
44
+ setOpen(true);
45
+ return;
46
+ }
47
+ setActive((i) => Math.min(i + 1, filtered.length - 1));
48
+ break;
49
+ case 'ArrowUp':
50
+ e.preventDefault();
51
+ setActive((i) => Math.max(i - 1, 0));
52
+ break;
53
+ case 'Enter':
54
+ if (open && active >= 0) {
55
+ e.preventDefault();
56
+ commit(filtered[active]);
57
+ }
58
+ break;
59
+ case 'Escape':
60
+ e.preventDefault();
61
+ setOpen(false);
62
+ setActive(-1);
63
+ break;
64
+ }
65
+ }
66
+ // Close when focus leaves the whole control.
67
+ function handleBlur(e) {
68
+ if (!e.currentTarget.contains(e.relatedTarget)) {
69
+ setOpen(false);
70
+ setActive(-1);
71
+ }
72
+ }
73
+ return (_jsxs("div", { className: cx('combobox', className), onBlur: handleBlur, children: [_jsx("label", { className: hideLabel ? 'sr-only' : 'combobox__label', htmlFor: baseId, children: label }), _jsx("input", { id: baseId, type: "text", role: "combobox", "aria-expanded": open, "aria-controls": open && filtered.length > 0 ? listId : undefined, "aria-autocomplete": "list", "aria-activedescendant": open && active >= 0 ? optionId(active) : undefined, className: "combobox__input", placeholder: placeholder, value: query, onChange: (e) => {
74
+ setQuery(e.target.value);
75
+ setOpen(true);
76
+ setActive(-1);
77
+ }, onClick: () => setOpen(true), onFocus: () => setOpen(true), onKeyDown: handleKeyDown }), open && filtered.length > 0 && (_jsx("ul", { id: listId, role: "listbox", "aria-label": label, className: "combobox__list", children: filtered.map((option, index) => (_jsx("li", { id: optionId(index), role: "option", "aria-selected": option.value === value, className: cx('combobox__option', index === active && 'combobox__option--active'), onMouseEnter: () => setActive(index), onMouseDown: (e) => e.preventDefault(), onClick: () => commit(option), children: option.label }, option.value))) }))] }));
78
+ }
@@ -0,0 +1,32 @@
1
+ import { type ReactNode } from 'react';
2
+ export type Command = {
3
+ id: string;
4
+ label: string;
5
+ /** Runs when the command is chosen. */
6
+ onSelect: () => void;
7
+ /** Extra terms to match against, beyond the label. */
8
+ keywords?: string[];
9
+ /** Optional group heading to bucket the command under. */
10
+ group?: string;
11
+ /** Decorative leading glyph/icon. */
12
+ icon?: ReactNode;
13
+ /** Trailing hint, e.g. a shortcut. */
14
+ hint?: string;
15
+ };
16
+ type CommandPaletteProps = {
17
+ open: boolean;
18
+ onClose: () => void;
19
+ commands: Command[];
20
+ placeholder?: string;
21
+ emptyMessage?: string;
22
+ /** Accessible name for the dialog. */
23
+ label?: string;
24
+ className?: string;
25
+ };
26
+ /**
27
+ * A ⌘K-style command menu. Type to filter, arrow keys to move, Enter to run,
28
+ * Escape to close. Follows the combobox/listbox pattern: the input owns
29
+ * aria-activedescendant so the active option is announced without moving focus.
30
+ */
31
+ export declare function CommandPalette({ open, onClose, commands, placeholder, emptyMessage, label, className, }: CommandPaletteProps): import("react").ReactPortal | null;
32
+ export {};
@@ -0,0 +1,78 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useId, useMemo, useRef, useState, } from 'react';
3
+ import { createPortal } from 'react-dom';
4
+ import { cx } from './cx';
5
+ function matches(command, query) {
6
+ const q = query.trim().toLowerCase();
7
+ if (!q)
8
+ return true;
9
+ const haystack = [command.label, ...(command.keywords ?? [])].join(' ').toLowerCase();
10
+ return haystack.includes(q);
11
+ }
12
+ /**
13
+ * A ⌘K-style command menu. Type to filter, arrow keys to move, Enter to run,
14
+ * Escape to close. Follows the combobox/listbox pattern: the input owns
15
+ * aria-activedescendant so the active option is announced without moving focus.
16
+ */
17
+ export function CommandPalette({ open, onClose, commands, placeholder = 'Type a command…', emptyMessage = 'No commands found', label = 'Command palette', className, }) {
18
+ const baseId = useId();
19
+ const listId = `${baseId}-list`;
20
+ const inputRef = useRef(null);
21
+ const [query, setQuery] = useState('');
22
+ const [active, setActive] = useState(0);
23
+ const filtered = useMemo(() => commands.filter((c) => matches(c, query)), [commands, query]);
24
+ // Reset and focus each time the palette opens.
25
+ useEffect(() => {
26
+ if (!open)
27
+ return;
28
+ setQuery('');
29
+ setActive(0);
30
+ inputRef.current?.focus();
31
+ }, [open]);
32
+ // Keep the active index in range as the filtered set shrinks.
33
+ useEffect(() => {
34
+ setActive((i) => Math.min(i, Math.max(0, filtered.length - 1)));
35
+ }, [filtered.length]);
36
+ if (!open)
37
+ return null;
38
+ const optionId = (index) => `${baseId}-opt-${index}`;
39
+ function choose(index) {
40
+ const command = filtered[index];
41
+ if (!command)
42
+ return;
43
+ command.onSelect();
44
+ onClose();
45
+ }
46
+ function handleKeyDown(e) {
47
+ switch (e.key) {
48
+ case 'ArrowDown':
49
+ e.preventDefault();
50
+ setActive((i) => Math.min(i + 1, filtered.length - 1));
51
+ break;
52
+ case 'ArrowUp':
53
+ e.preventDefault();
54
+ setActive((i) => Math.max(i - 1, 0));
55
+ break;
56
+ case 'Enter':
57
+ e.preventDefault();
58
+ choose(active);
59
+ break;
60
+ case 'Escape':
61
+ e.preventDefault();
62
+ onClose();
63
+ break;
64
+ }
65
+ }
66
+ // Preserve command order while grouping under optional headings.
67
+ const groups = [];
68
+ filtered.forEach((command, index) => {
69
+ const last = groups[groups.length - 1];
70
+ if (last && last.name === command.group) {
71
+ last.items.push({ command, index });
72
+ }
73
+ else {
74
+ groups.push({ name: command.group, items: [{ command, index }] });
75
+ }
76
+ });
77
+ return createPortal(_jsx("div", { className: "command-palette__backdrop", onMouseDown: onClose, children: _jsxs("div", { role: "dialog", "aria-modal": "true", "aria-label": label, className: cx('command-palette', className), onMouseDown: (e) => e.stopPropagation(), children: [_jsx("input", { ref: inputRef, type: "text", role: "combobox", "aria-expanded": filtered.length > 0, "aria-controls": filtered.length ? listId : undefined, "aria-activedescendant": filtered.length ? optionId(active) : undefined, "aria-autocomplete": "list", "aria-label": label, className: "command-palette__input", placeholder: placeholder, value: query, onChange: (e) => setQuery(e.target.value), onKeyDown: handleKeyDown }), filtered.length === 0 ? (_jsx("p", { className: "command-palette__empty", children: emptyMessage })) : (_jsx("ul", { id: listId, role: "listbox", "aria-label": label, className: "command-palette__list", children: groups.map((group, gi) => (_jsxs("li", { role: "presentation", children: [group.name && (_jsx("p", { className: "command-palette__group", role: "presentation", children: group.name })), _jsx("ul", { role: "presentation", className: "command-palette__group-items", children: group.items.map(({ command, index }) => (_jsxs("li", { id: optionId(index), role: "option", "aria-selected": index === active, className: cx('command-palette__option', index === active && 'command-palette__option--active'), onMouseEnter: () => setActive(index), onMouseDown: (e) => e.preventDefault(), onClick: () => choose(index), children: [command.icon && (_jsx("span", { className: "command-palette__icon", "aria-hidden": "true", children: command.icon })), _jsx("span", { className: "command-palette__label", children: command.label }), command.hint && (_jsx("span", { className: "command-palette__hint", children: command.hint }))] }, command.id))) })] }, group.name ?? gi))) }))] }) }), document.body);
78
+ }
@@ -0,0 +1,27 @@
1
+ /** The formatting controls the toolbar can offer, in render order. */
2
+ export type RichTextControl = 'bold' | 'italic' | 'underline' | 'h2' | 'bulletList' | 'orderedList' | 'code' | 'link';
3
+ type RichTextEditorProps = {
4
+ /** Accessible name for the editable region. */
5
+ label: string;
6
+ /** Visually hide the label while keeping it for screen readers. */
7
+ hideLabel?: boolean;
8
+ /** Initial HTML for an uncontrolled editor. */
9
+ defaultValue?: string;
10
+ /** Controlled HTML value. Pair with onChange. */
11
+ value?: string;
12
+ /** Fires with the editor's HTML whenever the content changes. */
13
+ onChange?: (html: string) => void;
14
+ placeholder?: string;
15
+ /** Which controls to show, and in what order. */
16
+ toolbar?: RichTextControl[];
17
+ disabled?: boolean;
18
+ className?: string;
19
+ };
20
+ /**
21
+ * A small rich-text editor built on a contentEditable region and the browser's
22
+ * execCommand formatting. The toolbar is configurable, every button carries an
23
+ * accessible label, and Ctrl/Cmd+B/I/U work from the keyboard. Emits HTML on
24
+ * every edit so it drops straight into an AI compose surface.
25
+ */
26
+ export declare function RichTextEditor({ label, hideLabel, defaultValue, value, onChange, placeholder, toolbar, disabled, className, }: RichTextEditorProps): import("react").JSX.Element;
27
+ export {};
@@ -0,0 +1,80 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useId, useRef, } from 'react';
3
+ import { cx } from './cx';
4
+ const CONTROLS = {
5
+ bold: { label: 'Bold', command: 'bold', shortcut: 'b', glyph: 'B' },
6
+ italic: { label: 'Italic', command: 'italic', shortcut: 'i', glyph: 'I' },
7
+ underline: { label: 'Underline', command: 'underline', shortcut: 'u', glyph: 'U' },
8
+ h2: { label: 'Heading', command: 'formatBlock', value: 'H2', glyph: 'H2' },
9
+ bulletList: { label: 'Bullet list', command: 'insertUnorderedList', glyph: '•' },
10
+ orderedList: { label: 'Numbered list', command: 'insertOrderedList', glyph: '1.' },
11
+ code: { label: 'Code block', command: 'formatBlock', value: 'PRE', glyph: '</>' },
12
+ link: { label: 'Link', command: 'createLink', prompt: true, glyph: '🔗' },
13
+ };
14
+ const DEFAULT_TOOLBAR = [
15
+ 'bold',
16
+ 'italic',
17
+ 'underline',
18
+ 'h2',
19
+ 'bulletList',
20
+ 'code',
21
+ 'link',
22
+ ];
23
+ const SHORTCUTS = {
24
+ b: 'bold',
25
+ i: 'italic',
26
+ u: 'underline',
27
+ };
28
+ /**
29
+ * A small rich-text editor built on a contentEditable region and the browser's
30
+ * execCommand formatting. The toolbar is configurable, every button carries an
31
+ * accessible label, and Ctrl/Cmd+B/I/U work from the keyboard. Emits HTML on
32
+ * every edit so it drops straight into an AI compose surface.
33
+ */
34
+ export function RichTextEditor({ label, hideLabel = false, defaultValue, value, onChange, placeholder, toolbar = DEFAULT_TOOLBAR, disabled = false, className, }) {
35
+ const id = useId();
36
+ const editorRef = useRef(null);
37
+ const controlled = value !== undefined;
38
+ // Seed the initial HTML once. React never re-renders contentEditable content,
39
+ // so mutating innerHTML by hand is the supported path here.
40
+ useEffect(() => {
41
+ const el = editorRef.current;
42
+ if (!el)
43
+ return;
44
+ const next = controlled ? value : defaultValue;
45
+ if (next != null && el.innerHTML !== next) {
46
+ el.innerHTML = next;
47
+ }
48
+ // eslint-disable-next-line react-hooks/exhaustive-deps
49
+ }, [controlled ? value : undefined]);
50
+ function exec(spec) {
51
+ const el = editorRef.current;
52
+ if (!el || disabled)
53
+ return;
54
+ el.focus();
55
+ let commandValue = spec.value;
56
+ if (spec.prompt) {
57
+ const url = window.prompt('Link URL');
58
+ if (!url)
59
+ return;
60
+ commandValue = url;
61
+ }
62
+ document.execCommand(spec.command, false, commandValue);
63
+ onChange?.(el.innerHTML);
64
+ }
65
+ function handleKeyDown(e) {
66
+ if (!(e.ctrlKey || e.metaKey))
67
+ return;
68
+ const control = SHORTCUTS[e.key.toLowerCase()];
69
+ if (!control || !toolbar.includes(control))
70
+ return;
71
+ e.preventDefault();
72
+ exec(CONTROLS[control]);
73
+ }
74
+ return (_jsxs("div", { className: cx('rich-text', disabled && 'rich-text--disabled', className), children: [_jsx("label", { className: hideLabel ? 'sr-only' : 'rich-text__label', htmlFor: id, children: label }), _jsx("div", { className: "rich-text__toolbar", role: "toolbar", "aria-label": `${label} formatting`, children: toolbar.map((key) => {
75
+ const spec = CONTROLS[key];
76
+ return (_jsx("button", { type: "button", className: "rich-text__tool", "aria-label": spec.label, title: spec.label, disabled: disabled,
77
+ // Keep the selection while the button takes the click.
78
+ onMouseDown: (e) => e.preventDefault(), onClick: () => exec(spec), children: _jsx("span", { "aria-hidden": "true", children: spec.glyph }) }, key));
79
+ }) }), _jsx("div", { ref: editorRef, id: id, role: "textbox", "aria-multiline": "true", "aria-label": label, "aria-disabled": disabled || undefined, contentEditable: !disabled, suppressContentEditableWarning: true, "data-placeholder": placeholder, className: "rich-text__editable", onInput: (e) => onChange?.(e.currentTarget.innerHTML), onKeyDown: handleKeyDown })] }));
80
+ }
@@ -0,0 +1,20 @@
1
+ type StreamingTextProps = {
2
+ /** The full text to reveal. */
3
+ text: string;
4
+ /** Characters revealed per tick. */
5
+ speed?: number;
6
+ /** Milliseconds between ticks. */
7
+ interval?: number;
8
+ /** Show a blinking caret while streaming. */
9
+ cursor?: boolean;
10
+ /** Called once when the whole string has been revealed. */
11
+ onDone?: () => void;
12
+ className?: string;
13
+ };
14
+ /**
15
+ * Reveals text a few characters at a time, the way a streamed model response
16
+ * arrives, with an optional caret. Honours prefers-reduced-motion by showing
17
+ * the whole string at once, and announces itself through a polite live region.
18
+ */
19
+ export declare function StreamingText({ text, speed, interval, cursor, onDone, className, }: StreamingTextProps): import("react").JSX.Element;
20
+ export {};
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { cx } from './cx';
4
+ function detectReducedMotion() {
5
+ return (typeof window !== 'undefined' &&
6
+ typeof window.matchMedia === 'function' &&
7
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches);
8
+ }
9
+ /**
10
+ * Reveals text a few characters at a time, the way a streamed model response
11
+ * arrives, with an optional caret. Honours prefers-reduced-motion by showing
12
+ * the whole string at once, and announces itself through a polite live region.
13
+ */
14
+ export function StreamingText({ text, speed = 2, interval = 30, cursor = true, onDone, className, }) {
15
+ // Read the preference synchronously so a reduced-motion user never sees the
16
+ // animation start, then keep it live for the rare mid-session change.
17
+ const [reduced, setReduced] = useState(detectReducedMotion);
18
+ const [count, setCount] = useState(() => (detectReducedMotion() ? text.length : 0));
19
+ const onDoneRef = useRef(onDone);
20
+ onDoneRef.current = onDone;
21
+ useEffect(() => {
22
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
23
+ return;
24
+ }
25
+ const query = window.matchMedia('(prefers-reduced-motion: reduce)');
26
+ const onChange = (e) => setReduced(e.matches);
27
+ query.addEventListener('change', onChange);
28
+ return () => query.removeEventListener('change', onChange);
29
+ }, []);
30
+ useEffect(() => {
31
+ if (reduced) {
32
+ setCount(text.length);
33
+ onDoneRef.current?.();
34
+ return;
35
+ }
36
+ setCount(0);
37
+ let current = 0;
38
+ const id = setInterval(() => {
39
+ current = Math.min(current + speed, text.length);
40
+ setCount(current);
41
+ if (current >= text.length) {
42
+ clearInterval(id);
43
+ onDoneRef.current?.();
44
+ }
45
+ }, interval);
46
+ return () => clearInterval(id);
47
+ }, [text, speed, interval, reduced]);
48
+ const streaming = count < text.length;
49
+ return (_jsxs("span", { role: "status", "aria-live": "polite", className: cx('streaming-text', className), children: [text.slice(0, count), cursor && !reduced && streaming && (_jsx("span", { className: "streaming-text__cursor", "aria-hidden": "true" }))] }));
50
+ }
package/dist/Ticker.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ReactNode } from 'react';
1
+ import { type ReactNode } from "react";
2
2
  type TickerProps = {
3
3
  /** Accessible name for the strip. Scroll mode renders a labelled region. */
4
4
  label: string;
@@ -7,11 +7,11 @@ type TickerProps = {
7
7
  * auto-scroll — every item stays reachable. `marquee` is a decorative,
8
8
  * aria-hidden CSS loop for pure flavour.
9
9
  */
10
- mode?: 'scroll' | 'marquee';
10
+ mode?: "scroll" | "marquee";
11
11
  /** Which edge the strip sits on; picks the border side. */
12
- edge?: 'top' | 'bottom';
12
+ edge?: "top" | "bottom";
13
13
  /** Which way the ambient motion travels. */
14
- direction?: 'left' | 'right';
14
+ direction?: "left" | "right";
15
15
  /** Ambient auto-scroll speed for scroll mode, in px/sec. */
16
16
  speed?: number;
17
17
  className?: string;
package/dist/Ticker.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from 'react';
3
- import { cx } from './cx';
4
- import { usePrefersReducedMotion } from './usePrefersReducedMotion';
2
+ import { useEffect, useLayoutEffect, useRef, useState, } from "react";
3
+ import { cx } from "./cx";
4
+ import { usePrefersReducedMotion } from "./usePrefersReducedMotion";
5
5
  /** How long a touch keeps the strip frozen before the ambient scroll resumes. */
6
6
  const TOUCH_RESUME_MS = 4000;
7
7
  /**
@@ -11,17 +11,17 @@ const TOUCH_RESUME_MS = 4000;
11
11
  * children, so the strip stays content-agnostic.
12
12
  */
13
13
  export function Ticker(props) {
14
- return props.mode === 'marquee' ? (_jsx(MarqueeTicker, { ...props })) : (_jsx(ScrollTicker, { ...props }));
14
+ return props.mode === "marquee" ? (_jsx(MarqueeTicker, { ...props })) : (_jsx(ScrollTicker, { ...props }));
15
15
  }
16
16
  function edgeClassFor(edge) {
17
- return edge === 'top' ? 'ticker--top' : 'ticker--bottom';
17
+ return edge === "top" ? "ticker--top" : "ticker--bottom";
18
18
  }
19
19
  /** Decorative marquee: aria-hidden, CSS-driven, content duplicated for the loop. */
20
- function MarqueeTicker({ edge = 'top', direction = 'left', className, children, }) {
21
- return (_jsx("div", { "aria-hidden": "true", "data-direction": direction, className: cx('ticker', 'ticker--marquee', edgeClassFor(edge), className), children: _jsxs("div", { className: "ticker__track", children: [_jsx("div", { className: "ticker__group", children: children }), _jsx("div", { className: "ticker__group", children: children })] }) }));
20
+ function MarqueeTicker({ edge = "top", direction = "left", className, children, }) {
21
+ return (_jsx("div", { "aria-hidden": "true", "data-direction": direction, className: cx("ticker", "ticker--marquee", edgeClassFor(edge), className), children: _jsxs("div", { className: "ticker__track", children: [_jsx("div", { className: "ticker__group", children: children }), _jsx("div", { className: "ticker__group", children: children })] }) }));
22
22
  }
23
23
  /** Accessible scroll container with an ambient JS auto-scroll loop. */
24
- function ScrollTicker({ label, edge = 'top', direction = 'left', speed = 40, className, children, }) {
24
+ function ScrollTicker({ label, edge = "top", direction = "left", speed = 40, className, children, }) {
25
25
  const reduced = usePrefersReducedMotion();
26
26
  const scrollerRef = useRef(null);
27
27
  const cloneRef = useRef(null);
@@ -32,19 +32,25 @@ function ScrollTicker({ label, edge = 'top', direction = 'left', speed = 40, cla
32
32
  useEffect(() => {
33
33
  pausedRef.current = paused;
34
34
  }, [paused]);
35
- // Fallback for browsers without `inert`.
35
+ // Take the clone out of the tab order, but leave it clickable.
36
36
  //
37
- // The clone carries `inert` in the markup, which drops its descendants from
38
- // the tab order and the accessibility tree together, before first paint. This
39
- // effect used to be the only mechanism, and it left a gap: an effect runs
40
- // after the DOM exists, so between render and this call the duplicate held
41
- // tabbable controls inside an aria-hidden container. Hiding something from
42
- // assistive tech while leaving it reachable by keyboard is worse than not
43
- // hiding it the user lands on a control a screen reader insists is absent.
44
- useEffect(() => {
45
- if ('inert' in HTMLElement.prototype)
46
- return;
47
- const focusables = cloneRef.current?.querySelectorAll('a[href], button, input, select, textarea, [tabindex]');
37
+ // The clone used to carry `inert`, which was the right instinct aimed one
38
+ // notch too broadly: `inert` removes a subtree from the accessibility tree
39
+ // AND from the pointer. Since the loop wraps at half the scroll width,
40
+ // roughly half of what is on screen at any moment is the clone, so half the
41
+ // strip silently ignored clicks.
42
+ //
43
+ // What is actually wanted is narrower: hidden from assistive tech
44
+ // (`aria-hidden` on the element), not tabbable (`tabIndex = -1` here), and
45
+ // still interactive with a pointer. Both copies render the same children, so
46
+ // clicking either runs the same handler and the reader cannot tell which one
47
+ // they hit which is the point.
48
+ //
49
+ // A layout effect rather than a passive one: it runs before the browser
50
+ // paints, so there is no frame in which the duplicate holds tabbable controls
51
+ // inside an aria-hidden container, which axe rates serious.
52
+ useLayoutEffect(() => {
53
+ const focusables = cloneRef.current?.querySelectorAll("a[href], button, input, select, textarea, [tabindex]");
48
54
  focusables?.forEach((el) => {
49
55
  el.tabIndex = -1;
50
56
  });
@@ -57,7 +63,7 @@ function ScrollTicker({ label, edge = 'top', direction = 'left', speed = 40, cla
57
63
  const el = scrollerRef.current;
58
64
  if (!el)
59
65
  return;
60
- const dir = direction === 'left' ? 1 : -1;
66
+ const dir = direction === "left" ? 1 : -1;
61
67
  // Start the rightward strip one copy in, so it has somewhere to scroll back.
62
68
  if (dir < 0)
63
69
  el.scrollLeft = el.scrollWidth / 2;
@@ -87,10 +93,10 @@ function ScrollTicker({ label, edge = 'top', direction = 'left', speed = 40, cla
87
93
  clearTimeout(resumeTimer.current);
88
94
  resumeTimer.current = setTimeout(() => setPaused(false), TOUCH_RESUME_MS);
89
95
  };
90
- const classes = cx('ticker', edgeClassFor(edge), className);
96
+ const classes = cx("ticker", edgeClassFor(edge), className);
91
97
  // Reduced motion: a plain, single-copy scrollable row. No clone, no loop.
92
98
  if (reduced) {
93
99
  return (_jsx("section", { "aria-label": label, className: classes, children: _jsx("div", { className: "ticker__group", children: children }) }));
94
100
  }
95
- return (_jsx("section", { ref: scrollerRef, "aria-label": label, "data-direction": direction, className: classes, onMouseEnter: () => setPaused(true), onMouseLeave: () => setPaused(false), onTouchStart: freezeForTouch, children: _jsxs("div", { className: "ticker__track", "data-paused": paused || undefined, children: [_jsx("div", { className: "ticker__group", children: children }), _jsx("div", { ref: cloneRef, "aria-hidden": "true", inert: true, className: "ticker__group", children: children })] }) }));
101
+ return (_jsx("section", { ref: scrollerRef, "aria-label": label, "data-direction": direction, className: classes, onMouseEnter: () => setPaused(true), onMouseLeave: () => setPaused(false), onTouchStart: freezeForTouch, children: _jsxs("div", { className: "ticker__track", "data-paused": paused || undefined, children: [_jsx("div", { className: "ticker__group", children: children }), _jsx("div", { ref: cloneRef, "aria-hidden": "true", className: "ticker__group", children: children })] }) }));
96
102
  }
@@ -0,0 +1,25 @@
1
+ import { type ReactNode } from 'react';
2
+ export type ToastVariant = 'default' | 'success' | 'error' | 'warning';
3
+ export type ToastOptions = {
4
+ title: string;
5
+ description?: string;
6
+ variant?: ToastVariant;
7
+ /** Auto-dismiss delay in ms. Use 0 to keep it until dismissed. */
8
+ duration?: number;
9
+ };
10
+ type ToastContextValue = {
11
+ toast: (options: ToastOptions) => number;
12
+ dismiss: (id: number) => void;
13
+ };
14
+ /**
15
+ * Wrap an app in ToastProvider and call `useToast().toast(...)` to raise a
16
+ * notification. Toasts stack in a live region so screen readers announce them —
17
+ * errors assertively, everything else politely — and auto-dismiss unless
18
+ * `duration` is 0.
19
+ */
20
+ export declare function ToastProvider({ children }: {
21
+ children: ReactNode;
22
+ }): import("react").JSX.Element;
23
+ /** Access the toast API. Must be called inside a ToastProvider. */
24
+ export declare function useToast(): ToastContextValue;
25
+ export {};
package/dist/Toast.js ADDED
@@ -0,0 +1,52 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, useCallback, useContext, useEffect, useRef, useState, } from 'react';
3
+ import { createPortal } from 'react-dom';
4
+ import { cx } from './cx';
5
+ const ToastContext = createContext(null);
6
+ const DEFAULT_DURATION = 5000;
7
+ /**
8
+ * Wrap an app in ToastProvider and call `useToast().toast(...)` to raise a
9
+ * notification. Toasts stack in a live region so screen readers announce them —
10
+ * errors assertively, everything else politely — and auto-dismiss unless
11
+ * `duration` is 0.
12
+ */
13
+ export function ToastProvider({ children }) {
14
+ const [toasts, setToasts] = useState([]);
15
+ const nextId = useRef(0);
16
+ const timers = useRef(new Map());
17
+ const dismiss = useCallback((id) => {
18
+ setToasts((list) => list.filter((t) => t.id !== id));
19
+ const timer = timers.current.get(id);
20
+ if (timer) {
21
+ clearTimeout(timer);
22
+ timers.current.delete(id);
23
+ }
24
+ }, []);
25
+ const toast = useCallback((options) => {
26
+ const id = nextId.current++;
27
+ setToasts((list) => [...list, { ...options, id }]);
28
+ const duration = options.duration ?? DEFAULT_DURATION;
29
+ if (duration > 0) {
30
+ timers.current.set(id, setTimeout(() => dismiss(id), duration));
31
+ }
32
+ return id;
33
+ }, [dismiss]);
34
+ // Clear any pending timers on unmount.
35
+ useEffect(() => {
36
+ const map = timers.current;
37
+ return () => {
38
+ map.forEach((t) => clearTimeout(t));
39
+ map.clear();
40
+ };
41
+ }, []);
42
+ return (_jsxs(ToastContext.Provider, { value: { toast, dismiss }, children: [children, typeof document !== 'undefined' &&
43
+ createPortal(_jsx("div", { className: "toast-region", role: "region", "aria-label": "Notifications", children: toasts.map((t) => (_jsxs("div", { role: t.variant === 'error' ? 'alert' : 'status', "aria-live": t.variant === 'error' ? 'assertive' : 'polite', className: cx('toast', `toast--${t.variant ?? 'default'}`), children: [_jsxs("div", { className: "toast__body", children: [_jsx("p", { className: "toast__title", children: t.title }), t.description && _jsx("p", { className: "toast__description", children: t.description })] }), _jsx("button", { type: "button", className: "toast__dismiss", "aria-label": "Dismiss notification", onClick: () => dismiss(t.id), children: _jsx("span", { "aria-hidden": "true", children: "\u2715" }) })] }, t.id))) }), document.body)] }));
44
+ }
45
+ /** Access the toast API. Must be called inside a ToastProvider. */
46
+ export function useToast() {
47
+ const context = useContext(ToastContext);
48
+ if (!context) {
49
+ throw new Error('useToast must be used within a ToastProvider');
50
+ }
51
+ return context;
52
+ }
@@ -0,0 +1,21 @@
1
+ type TokenUsageMeterProps = {
2
+ /** Accessible name for the meter, e.g. "Context window". */
3
+ label: string;
4
+ promptTokens: number;
5
+ completionTokens: number;
6
+ /** The budget the usage is measured against (e.g. the context window). */
7
+ maxTokens: number;
8
+ /** Price per million tokens, in dollars. Shows an estimated cost when set. */
9
+ costPerMTok?: number;
10
+ /** Fraction (0–1) of the budget at which the "near limit" tone kicks in. */
11
+ warnAt?: number;
12
+ className?: string;
13
+ };
14
+ /**
15
+ * A budget bar for LLM token usage: prompt and completion tokens as two
16
+ * segments of a track sized against `maxTokens`, with the used total, percent,
17
+ * and an optional cost estimate. Exposes progressbar semantics and switches to
18
+ * a warning/over tone as usage approaches or passes the budget.
19
+ */
20
+ export declare function TokenUsageMeter({ label, promptTokens, completionTokens, maxTokens, costPerMTok, warnAt, className, }: TokenUsageMeterProps): import("react").JSX.Element;
21
+ export {};
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from './cx';
3
+ const fmt = (n) => n.toLocaleString('en-US');
4
+ /**
5
+ * A budget bar for LLM token usage: prompt and completion tokens as two
6
+ * segments of a track sized against `maxTokens`, with the used total, percent,
7
+ * and an optional cost estimate. Exposes progressbar semantics and switches to
8
+ * a warning/over tone as usage approaches or passes the budget.
9
+ */
10
+ export function TokenUsageMeter({ label, promptTokens, completionTokens, maxTokens, costPerMTok, warnAt = 0.9, className, }) {
11
+ const used = promptTokens + completionTokens;
12
+ const safeMax = maxTokens > 0 ? maxTokens : 1;
13
+ const fraction = used / safeMax;
14
+ const percent = Math.round(fraction * 100);
15
+ const clamped = Math.min(used, maxTokens);
16
+ const over = used > maxTokens;
17
+ const warn = !over && fraction >= warnAt;
18
+ const promptPct = Math.min((promptTokens / safeMax) * 100, 100);
19
+ const completionPct = Math.min((completionTokens / safeMax) * 100, 100 - promptPct);
20
+ const cost = costPerMTok != null ? (used / 1_000_000) * costPerMTok : null;
21
+ return (_jsxs("div", { className: cx('token-meter', warn && 'token-meter--warn', over && 'token-meter--over', className), children: [_jsxs("div", { className: "token-meter__head", children: [_jsx("span", { className: "token-meter__label", children: label }), _jsxs("span", { className: "token-meter__stat", children: [fmt(used), " / ", fmt(maxTokens), " (", percent, "%)"] })] }), _jsxs("div", { role: "progressbar", "aria-label": label, "aria-valuemin": 0, "aria-valuemax": maxTokens, "aria-valuenow": clamped, "aria-valuetext": `${fmt(used)} of ${fmt(maxTokens)} tokens (${percent}%)`, className: "token-meter__track", children: [_jsx("div", { className: "token-meter__seg token-meter__seg--prompt", style: { width: `${promptPct}%` } }), _jsx("div", { className: "token-meter__seg token-meter__seg--completion", style: { width: `${completionPct}%` } })] }), _jsxs("div", { className: "token-meter__footer", children: [_jsxs("span", { className: "token-meter__legend", children: [_jsx("span", { className: "token-meter__key token-meter__key--prompt", "aria-hidden": "true" }), fmt(promptTokens), " prompt", _jsx("span", { className: "token-meter__key token-meter__key--completion", "aria-hidden": "true" }), fmt(completionTokens), " completion"] }), cost != null && _jsxs("span", { className: "token-meter__cost", children: ["$", cost.toFixed(2)] })] })] }));
22
+ }
@@ -0,0 +1,12 @@
1
+ type TypingDotsProps = {
2
+ /** Announced to screen readers; the dots themselves are decorative. */
3
+ label?: string;
4
+ className?: string;
5
+ };
6
+ /**
7
+ * A three-dot "typing" indicator for chat surfaces. The animation is purely
8
+ * visual and hidden from assistive tech; the `label` carries the meaning and is
9
+ * exposed through a polite live region.
10
+ */
11
+ export declare function TypingDots({ label, className }: TypingDotsProps): import("react").JSX.Element;
12
+ export {};
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from './cx';
3
+ /**
4
+ * A three-dot "typing" indicator for chat surfaces. The animation is purely
5
+ * visual and hidden from assistive tech; the `label` carries the meaning and is
6
+ * exposed through a polite live region.
7
+ */
8
+ export function TypingDots({ label = 'Typing…', className }) {
9
+ return (_jsx("span", { role: "status", "aria-label": label, className: cx('typing-dots', className), children: _jsxs("span", { className: "typing-dots__dots", "aria-hidden": "true", children: [_jsx("span", { className: "typing-dots__dot" }), _jsx("span", { className: "typing-dots__dot" }), _jsx("span", { className: "typing-dots__dot" })] }) }));
10
+ }
package/dist/index.d.ts CHANGED
@@ -31,6 +31,16 @@ export { GaugeChart, type GaugeTone } from './GaugeChart';
31
31
  export { StackedLineChart, type LineSeries } from './StackedLineChart';
32
32
  export { WordCloud, type WordCloudDatum } from './WordCloud';
33
33
  export * as chartGeometry from './chartGeometry';
34
+ export { RichTextEditor, type RichTextControl } from './RichTextEditor';
35
+ export { ChatMessage, type ChatRole } from './ChatMessage';
36
+ export { ChatComposer } from './ChatComposer';
37
+ export { StreamingText } from './StreamingText';
38
+ export { TypingDots } from './TypingDots';
39
+ export { CodeBlock } from './CodeBlock';
40
+ export { CommandPalette, type Command } from './CommandPalette';
41
+ export { Combobox, type ComboboxOption } from './Combobox';
42
+ export { ToastProvider, useToast, type ToastOptions, type ToastVariant, } from './Toast';
43
+ export { TokenUsageMeter } from './TokenUsageMeter';
34
44
  export { VisuallyHidden } from './VisuallyHidden';
35
45
  export { usePrefersReducedMotion } from './usePrefersReducedMotion';
36
46
  export { cx } from './cx';
package/dist/index.js CHANGED
@@ -31,6 +31,16 @@ export { GaugeChart } from './GaugeChart';
31
31
  export { StackedLineChart } from './StackedLineChart';
32
32
  export { WordCloud } from './WordCloud';
33
33
  export * as chartGeometry from './chartGeometry';
34
+ export { RichTextEditor } from './RichTextEditor';
35
+ export { ChatMessage } from './ChatMessage';
36
+ export { ChatComposer } from './ChatComposer';
37
+ export { StreamingText } from './StreamingText';
38
+ export { TypingDots } from './TypingDots';
39
+ export { CodeBlock } from './CodeBlock';
40
+ export { CommandPalette } from './CommandPalette';
41
+ export { Combobox } from './Combobox';
42
+ export { ToastProvider, useToast, } from './Toast';
43
+ export { TokenUsageMeter } from './TokenUsageMeter';
34
44
  export { VisuallyHidden } from './VisuallyHidden';
35
45
  export { usePrefersReducedMotion } from './usePrefersReducedMotion';
36
46
  export { cx } from './cx';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paul-portfolio/react",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "React components for the Paul Design System",
5
5
  "license": "MIT",
6
6
  "repository": {