@startsimpli/ui-native 0.1.0 → 0.1.2

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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -2
  3. package/package.json +21 -9
  4. package/src/__tests__/forms.test.tsx +169 -0
  5. package/src/__tests__/interactive.test.tsx +117 -0
  6. package/src/__tests__/lists.test.tsx +190 -0
  7. package/src/__tests__/logic-list.test.ts +88 -0
  8. package/src/__tests__/logic-markdown.test.ts +39 -0
  9. package/src/__tests__/logic-misc.test.ts +87 -0
  10. package/src/__tests__/styles-overlay.test.ts +14 -0
  11. package/src/components/ChatComposer.tsx +65 -0
  12. package/src/components/Chip.tsx +5 -1
  13. package/src/components/DataList.tsx +52 -0
  14. package/src/components/EmptyState.tsx +6 -1
  15. package/src/components/FormField.tsx +38 -0
  16. package/src/components/Header.tsx +46 -0
  17. package/src/components/ListControls.tsx +85 -0
  18. package/src/components/Markdown.tsx +93 -0
  19. package/src/components/MessageBubble.tsx +49 -0
  20. package/src/components/SearchBar.tsx +52 -0
  21. package/src/components/SectionedList.tsx +55 -0
  22. package/src/components/SegmentedControl.tsx +4 -1
  23. package/src/components/Select.tsx +95 -0
  24. package/src/components/Sheet.tsx +41 -0
  25. package/src/components/StatusChip.tsx +27 -0
  26. package/src/components/SwipeRow.tsx +85 -0
  27. package/src/components/TextArea.tsx +38 -0
  28. package/src/components/Toast.tsx +61 -0
  29. package/src/components/serverListTypes.ts +53 -0
  30. package/src/components/useServerList.tsx +58 -0
  31. package/src/index.ts +20 -0
  32. package/src/logic/chat.ts +3 -0
  33. package/src/logic/listState.ts +77 -0
  34. package/src/logic/markdown.ts +112 -0
  35. package/src/logic/select.ts +12 -0
  36. package/src/logic/status.ts +27 -0
  37. package/src/logic/swipe.ts +13 -0
  38. package/src/logic/toast.ts +28 -0
  39. package/src/styles-chat.ts +51 -0
  40. package/src/styles-overlay.ts +100 -0
@@ -0,0 +1,53 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { FilterGroup } from '../logic/listState';
3
+
4
+ export type { FilterGroup, FilterOption } from '../logic/listState';
5
+
6
+ export interface ListSearch {
7
+ /** Controlled text shown in the field. */
8
+ value: string;
9
+ /** Fires on every keystroke (update your input state here). */
10
+ onChange: (text: string) => void;
11
+ /** Fires debounced, and immediately on submit / clear. Run the server query here. */
12
+ onSearch: (text: string) => void;
13
+ debounceMs?: number;
14
+ placeholder?: string;
15
+ }
16
+
17
+ export interface ListSort {
18
+ value: string;
19
+ options: Array<{ value: string; label: string }>;
20
+ onChange: (value: string) => void;
21
+ }
22
+
23
+ /**
24
+ * Props shared by DataList and SectionedList. Everything is server-driven: the
25
+ * list never filters, sorts or slices rows itself, it only reports what the user
26
+ * asked for and renders what it is given.
27
+ */
28
+ export interface ServerListProps {
29
+ loading?: boolean;
30
+ refreshing?: boolean;
31
+ onRefresh?: () => void;
32
+ /** Error message. With no rows it is the full-screen error; with rows it is the paging footer error. */
33
+ error?: string | null;
34
+ onRetry?: () => void;
35
+ hasMore?: boolean;
36
+ loadingMore?: boolean;
37
+ /** Called at most once per loaded length when the end is reached. */
38
+ onLoadMore?: () => void;
39
+ search?: ListSearch;
40
+ filters?: FilterGroup[];
41
+ /** value null clears that group. */
42
+ onFilterChange?: (key: string, value: string | null) => void;
43
+ /** Clears search text and every filter. Shown in the summary and the no-results state. */
44
+ onClearFilters?: () => void;
45
+ sort?: ListSort;
46
+ emptyTitle?: string;
47
+ emptyDescription?: string;
48
+ noResultsTitle?: string;
49
+ noResultsDescription?: string;
50
+ /** Rendered above the rows inside the scroll area. */
51
+ ListHeaderComponent?: ReactNode;
52
+ testID?: string;
53
+ }
@@ -0,0 +1,58 @@
1
+ import { useEffect, useRef, type ReactElement } from 'react';
2
+ import { RefreshControl, Text as RNText, View } from 'react-native';
3
+ import { Button } from './Button';
4
+ import { EmptyState } from './EmptyState';
5
+ import { SkeletonList } from './SkeletonList';
6
+ import { Spinner } from './Spinner';
7
+ import { useTheme } from './ThemeProvider';
8
+ import { errorTextColor } from '../styles-overlay';
9
+ import { createLoadMoreGuard, resolveListState, summarizeFilters } from '../logic/listState';
10
+ import type { ServerListProps } from './serverListTypes';
11
+
12
+ /** Behaviour shared by DataList and SectionedList: state views, the paging guard, footer and refresh control. */
13
+ export function useServerList(p: ServerListProps, itemCount: number) {
14
+ const theme = useTheme();
15
+ const guard = useRef(createLoadMoreGuard()).current;
16
+ const id = (s: string) => (p.testID ? `${p.testID}-${s}` : undefined);
17
+ const summary = summarizeFilters(p.filters ?? [], p.search?.value ?? '');
18
+ const signature = `${summary.labels.join('|')}#${p.sort?.value ?? ''}`;
19
+
20
+ // A new query, refresh or initial load starts a new paging sequence.
21
+ useEffect(() => { guard.reset(); }, [guard, signature, p.loading, p.refreshing]);
22
+
23
+ const state = resolveListState({ itemCount, loading: p.loading, refreshing: p.refreshing, error: p.error, hasActiveQuery: summary.count > 0 });
24
+ const retry = () => { guard.reset(); p.onRetry?.(); };
25
+
26
+ let empty: ReactElement | null = null;
27
+ if (state === 'loading') {
28
+ empty = <View testID={id('loading')} style={{ paddingTop: theme.spacing.sm }}><SkeletonList count={4} height={64} /></View>;
29
+ } else if (state === 'error') {
30
+ empty = <EmptyState testID={id('error')} icon="⚠️" title="Something went wrong" description={p.error ?? undefined} actionLabel={p.onRetry ? 'Retry' : undefined} onAction={retry} actionTestID={id('retry')} />;
31
+ } else if (state === 'noResults') {
32
+ empty = <EmptyState testID={id('no-results')} icon="🔍" title={p.noResultsTitle ?? 'No results'} description={p.noResultsDescription ?? 'Try a different search or clear the filters.'} actionLabel={p.onClearFilters ? 'Clear filters' : undefined} onAction={p.onClearFilters} actionTestID={id('empty-clear')} />;
33
+ } else if (state === 'empty') {
34
+ empty = <EmptyState testID={id('empty')} title={p.emptyTitle ?? 'Nothing here yet'} description={p.emptyDescription} />;
35
+ }
36
+
37
+ let footer: ReactElement | null = null;
38
+ if (state === 'content') {
39
+ if (p.error) {
40
+ footer = (
41
+ <View testID={id('footer-error')} style={{ alignItems: 'center', gap: theme.spacing.sm, paddingVertical: theme.spacing.lg }}>
42
+ <RNText accessibilityRole="alert" style={{ color: errorTextColor(theme), fontSize: theme.fontSizes.sm }}>{p.error}</RNText>
43
+ {p.onRetry ? <Button testID={id('footer-retry')} title="Retry" variant="outline" size="sm" onPress={retry} /> : null}
44
+ </View>
45
+ );
46
+ } else if (p.loadingMore) {
47
+ footer = <View testID={id('footer-loading')} style={{ paddingVertical: theme.spacing.lg }}><Spinner /></View>;
48
+ }
49
+ }
50
+
51
+ const onEndReached = () => {
52
+ if (guard.tryStart({ hasMore: !!p.hasMore, loading: !!p.loading, loadingMore: !!p.loadingMore, error: !!p.error }, itemCount)) p.onLoadMore?.();
53
+ };
54
+
55
+ const refreshControl = p.onRefresh ? <RefreshControl refreshing={!!p.refreshing} onRefresh={p.onRefresh} tintColor={theme.colors.mutedForeground} /> : undefined;
56
+
57
+ return { empty, footer, onEndReached, refreshControl, state };
58
+ }
package/src/index.ts CHANGED
@@ -62,3 +62,23 @@ export { GradientBox, type GradientBoxProps } from './components/GradientBox';
62
62
  export { HeroBanner, type HeroBannerProps } from './components/HeroBanner';
63
63
  export { AccentProvider, useAccent } from './components/AccentContext';
64
64
  export { DataTable, type DataTableProps, type DataColumn, type DataRow } from './components/DataTable';
65
+
66
+ // Tandem-screen primitives: rich text, overlays, forms, server-driven lists, chat
67
+ export { Markdown, type MarkdownProps } from './components/Markdown';
68
+ export { parseMarkdown, parseInline, type Block as MarkdownBlock, type Inline as MarkdownInline } from './logic/markdown';
69
+ export { Sheet, type SheetProps } from './components/Sheet';
70
+ export { Select, type SelectProps, type SelectOption } from './components/Select';
71
+ export { FormField, type FormFieldProps } from './components/FormField';
72
+ export { TextArea, type TextAreaProps } from './components/TextArea';
73
+ export { SearchBar, type SearchBarProps } from './components/SearchBar';
74
+ export { Header, type HeaderProps } from './components/Header';
75
+ export { StatusChip, type StatusChipProps } from './components/StatusChip';
76
+ export { statusTone, formatProgress } from './logic/status';
77
+ export { SwipeRow, type SwipeRowProps, type SwipeAction } from './components/SwipeRow';
78
+ export { ToastProvider, useToast, type ToastApi, type ToastOptions, type ToastTone } from './components/Toast';
79
+ export { MessageBubble, type MessageBubbleProps } from './components/MessageBubble';
80
+ export { ChatComposer, type ChatComposerProps } from './components/ChatComposer';
81
+ export { DataList, type DataListProps } from './components/DataList';
82
+ export { SectionedList, type SectionedListProps } from './components/SectionedList';
83
+ export type { ServerListProps, ListSearch, ListSort, FilterGroup, FilterOption } from './components/serverListTypes';
84
+ export { resolveListState, createLoadMoreGuard, createDebouncer, summarizeFilters, type ListViewState } from './logic/listState';
@@ -0,0 +1,3 @@
1
+ export function canSend(text: string, sending: boolean): boolean {
2
+ return !sending && text.trim().length > 0;
3
+ }
@@ -0,0 +1,77 @@
1
+ /** Pure state logic for server-driven lists. Nothing here ever sees the full
2
+ * dataset: search, filter, sort and paging all belong to the server. */
3
+
4
+ export type ListViewState = 'loading' | 'error' | 'noResults' | 'empty' | 'content';
5
+
6
+ export interface ListStateInput {
7
+ itemCount: number;
8
+ loading?: boolean;
9
+ refreshing?: boolean;
10
+ error?: unknown;
11
+ /** True when a search query or filter is applied (empty then means "no results"). */
12
+ hasActiveQuery?: boolean;
13
+ }
14
+
15
+ export function resolveListState(i: ListStateInput): ListViewState {
16
+ if (i.itemCount > 0) return 'content';
17
+ if (i.error) return 'error';
18
+ if (i.loading && !i.refreshing) return 'loading';
19
+ return i.hasActiveQuery ? 'noResults' : 'empty';
20
+ }
21
+
22
+ export interface LoadMoreState {
23
+ hasMore: boolean;
24
+ loading: boolean;
25
+ loadingMore: boolean;
26
+ error: boolean;
27
+ }
28
+
29
+ /** Stops onEndReached (which fires repeatedly) from requesting the same page
30
+ * twice: one request per data length until the data grows or reset() is called. */
31
+ export function createLoadMoreGuard() {
32
+ let lastToken: number | null = null;
33
+ return {
34
+ tryStart(s: LoadMoreState, token: number): boolean {
35
+ if (!s.hasMore || s.loading || s.loadingMore || s.error) return false;
36
+ if (lastToken === token) return false;
37
+ lastToken = token;
38
+ return true;
39
+ },
40
+ reset() { lastToken = null; },
41
+ };
42
+ }
43
+
44
+ export function createDebouncer<T>(fn: (v: T) => void, ms: number) {
45
+ let timer: ReturnType<typeof setTimeout> | null = null;
46
+ let pending: { v: T } | null = null;
47
+ const clear = () => { if (timer) clearTimeout(timer); timer = null; };
48
+ const fire = () => { clear(); if (pending) { const { v } = pending; pending = null; fn(v); } };
49
+ return {
50
+ call(v: T) { pending = { v }; clear(); timer = setTimeout(fire, ms); },
51
+ flush: fire,
52
+ cancel() { clear(); pending = null; },
53
+ };
54
+ }
55
+
56
+ export interface FilterOption { value: string; label: string }
57
+ export interface FilterGroup {
58
+ key: string;
59
+ label: string;
60
+ options: FilterOption[];
61
+ /** Selected option value, or null when the filter is off. */
62
+ value: string | null;
63
+ /** 'chips' (default) for few options, 'select' opens a Sheet picker. */
64
+ display?: 'chips' | 'select';
65
+ }
66
+
67
+ export function summarizeFilters(groups: FilterGroup[], query = ''): { count: number; labels: string[] } {
68
+ const labels: string[] = [];
69
+ for (const g of groups) {
70
+ if (g.value == null) continue;
71
+ const opt = g.options.find((o) => o.value === g.value);
72
+ labels.push(`${g.label}: ${opt?.label ?? g.value}`);
73
+ }
74
+ const q = query.trim();
75
+ if (q) labels.push(`"${q}"`);
76
+ return { count: labels.length, labels };
77
+ }
@@ -0,0 +1,112 @@
1
+ /** Small dependency-free markdown subset for summaries, answers and transcripts:
2
+ * headings, paragraphs, bullet/numbered lists, quotes, rules, fenced code, and
3
+ * inline bold / italic / code / links. Unknown syntax stays literal text. */
4
+
5
+ export type Inline =
6
+ | { type: 'text'; text: string }
7
+ | { type: 'bold'; children: Inline[] }
8
+ | { type: 'italic'; children: Inline[] }
9
+ | { type: 'code'; text: string }
10
+ | { type: 'link'; href: string; children: Inline[] };
11
+
12
+ export type Block =
13
+ | { type: 'heading'; level: 1 | 2 | 3; inlines: Inline[] }
14
+ | { type: 'paragraph'; inlines: Inline[] }
15
+ | { type: 'bullets'; items: Inline[][] }
16
+ | { type: 'numbered'; items: Inline[][] }
17
+ | { type: 'quote'; inlines: Inline[] }
18
+ | { type: 'code'; text: string }
19
+ | { type: 'rule' };
20
+
21
+ const SAFE_HREF = /^(https?:\/\/|mailto:|tel:)/i;
22
+
23
+ function pushText(out: Inline[], text: string) {
24
+ if (!text) return;
25
+ const last = out[out.length - 1];
26
+ if (last && last.type === 'text') last.text += text;
27
+ else out.push({ type: 'text', text });
28
+ }
29
+
30
+ export function parseInline(src: string): Inline[] {
31
+ const out: Inline[] = [];
32
+ let i = 0;
33
+ while (i < src.length) {
34
+ const rest = src.slice(i);
35
+ let m: RegExpMatchArray | null;
36
+ if ((m = rest.match(/^`([^`\n]+)`/))) {
37
+ out.push({ type: 'code', text: m[1] });
38
+ i += m[0].length;
39
+ } else if ((m = rest.match(/^\[([^\]\n]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/))) {
40
+ const children = parseInline(m[1]);
41
+ if (SAFE_HREF.test(m[2])) out.push({ type: 'link', href: m[2], children });
42
+ else children.forEach((c) => (c.type === 'text' ? pushText(out, c.text) : out.push(c)));
43
+ i += m[0].length;
44
+ } else if ((m = rest.match(/^\*\*(?=\S)([\s\S]+?)(?<=\S)\*\*(?!\*)/)) || (m = rest.match(/^\*\*(?=\S)([\s\S]+\*)(?<=\S)\*\*/))) {
45
+ out.push({ type: 'bold', children: parseInline(m[1]) });
46
+ i += m[0].length;
47
+ } else if ((m = rest.match(/^\*(?=[^\s*])([^*\n]+?)(?<=\S)\*/))) {
48
+ out.push({ type: 'italic', children: parseInline(m[1]) });
49
+ i += m[0].length;
50
+ } else {
51
+ // consume up to the next possible marker
52
+ const next = rest.slice(1).search(/[`[*]/);
53
+ const len = next === -1 ? rest.length : next + 1;
54
+ pushText(out, rest.slice(0, len));
55
+ i += len;
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ const BULLET = /^\s*[-*+]\s+(.*)$/;
62
+ const NUMBERED = /^\s*\d+[.)]\s+(.*)$/;
63
+ const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
64
+ const RULE = /^\s*([-*_])(\s*\1){2,}\s*$/;
65
+ const FENCE = /^\s*```/;
66
+
67
+ export function parseMarkdown(src: string): Block[] {
68
+ const lines = src.replace(/\r\n?/g, '\n').split('\n');
69
+ const blocks: Block[] = [];
70
+ let i = 0;
71
+ const startsBlock = (l: string) =>
72
+ HEADING.test(l) || BULLET.test(l) || NUMBERED.test(l) || FENCE.test(l) || /^\s*>/.test(l) || RULE.test(l);
73
+
74
+ while (i < lines.length) {
75
+ const line = lines[i];
76
+ if (!line.trim()) { i++; continue; }
77
+ if (FENCE.test(line)) {
78
+ const body: string[] = [];
79
+ i++;
80
+ while (i < lines.length && !FENCE.test(lines[i])) body.push(lines[i++]);
81
+ i++; // closing fence (or end)
82
+ blocks.push({ type: 'code', text: body.join('\n') });
83
+ continue;
84
+ }
85
+ let m: RegExpMatchArray | null;
86
+ if ((m = line.match(HEADING))) {
87
+ blocks.push({ type: 'heading', level: Math.min(3, m[1].length) as 1 | 2 | 3, inlines: parseInline(m[2]) });
88
+ i++; continue;
89
+ }
90
+ if (RULE.test(line)) { blocks.push({ type: 'rule' }); i++; continue; }
91
+ if (BULLET.test(line)) {
92
+ const items: Inline[][] = [];
93
+ while (i < lines.length && (m = lines[i].match(BULLET))) { items.push(parseInline(m[1])); i++; }
94
+ blocks.push({ type: 'bullets', items }); continue;
95
+ }
96
+ if (NUMBERED.test(line)) {
97
+ const items: Inline[][] = [];
98
+ while (i < lines.length && (m = lines[i].match(NUMBERED))) { items.push(parseInline(m[1])); i++; }
99
+ blocks.push({ type: 'numbered', items }); continue;
100
+ }
101
+ if (/^\s*>/.test(line)) {
102
+ const body: string[] = [];
103
+ while (i < lines.length && /^\s*>/.test(lines[i])) body.push(lines[i++].replace(/^\s*>\s?/, ''));
104
+ blocks.push({ type: 'quote', inlines: parseInline(body.join('\n')) });
105
+ continue;
106
+ }
107
+ const para: string[] = [];
108
+ while (i < lines.length && lines[i].trim() && !(para.length && startsBlock(lines[i]))) para.push(lines[i++]);
109
+ blocks.push({ type: 'paragraph', inlines: parseInline(para.join('\n')) });
110
+ }
111
+ return blocks;
112
+ }
@@ -0,0 +1,12 @@
1
+ export interface SelectOption { value: string; label: string; description?: string }
2
+
3
+ export function filterOptions(options: SelectOption[], query: string): SelectOption[] {
4
+ const q = query.trim().toLowerCase();
5
+ if (!q) return options;
6
+ return options.filter((o) => o.label.toLowerCase().includes(q) || (o.description ?? '').toLowerCase().includes(q));
7
+ }
8
+
9
+ export function selectedLabel(options: SelectOption[], value: string | null | undefined): string | null {
10
+ if (value == null) return null;
11
+ return options.find((o) => o.value === value)?.label ?? null;
12
+ }
@@ -0,0 +1,27 @@
1
+ import type { BadgeVariant } from '../styles';
2
+
3
+ const GROUPS: Array<[BadgeVariant, string[]]> = [
4
+ ['secondary', ['queued', 'pending', 'waiting', 'draft', 'new']],
5
+ ['default', ['processing', 'running', 'transcribing', 'in_progress', 'uploading', 'syncing', 'active']],
6
+ ['success', ['done', 'complete', 'completed', 'ready', 'success', 'synced', 'connected']],
7
+ ['destructive', ['failed', 'error', 'rejected', 'disconnected']],
8
+ ['warning', ['partial', 'warning', 'stale', 'needs_attention', 'paused']],
9
+ ];
10
+
11
+ const norm = (s: string) => s.trim().toLowerCase().replace(/[\s-]+/g, '_');
12
+ const title = (s: string) => {
13
+ const t = s.trim().replace(/[_-]+/g, ' ');
14
+ return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
15
+ };
16
+
17
+ /** Domain status string to a Badge variant + display label. Unknown statuses are outlined. */
18
+ export function statusTone(status: string): { variant: BadgeVariant; label: string } {
19
+ const key = norm(status);
20
+ for (const [variant, names] of GROUPS) if (names.includes(key)) return { variant, label: title(status) };
21
+ return { variant: 'outline', label: title(status) };
22
+ }
23
+
24
+ export function formatProgress(value: number): string {
25
+ const v = Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;
26
+ return `${Math.round(v * 100)}%`;
27
+ }
@@ -0,0 +1,13 @@
1
+ /** Swipe-row offset math. Offsets are <= 0: 0 is closed, -actionsWidth is open. */
2
+ export function clampSwipe(offset: number, actionsWidth: number): number {
3
+ return Math.min(0, Math.max(-actionsWidth, offset));
4
+ }
5
+
6
+ const FLING = 0.5;
7
+
8
+ /** Where a released row should settle. `velocity` is px/ms, negative = leftward. */
9
+ export function settleSwipe(offset: number, velocity: number, actionsWidth: number): 'open' | 'closed' {
10
+ if (velocity <= -FLING) return 'open';
11
+ if (velocity >= FLING) return 'closed';
12
+ return -offset > actionsWidth / 2 ? 'open' : 'closed';
13
+ }
@@ -0,0 +1,28 @@
1
+ export type ToastTone = 'info' | 'success' | 'error' | 'warning';
2
+ export interface ToastItem { id: string; message: string; tone: ToastTone; durationMs: number }
3
+ export interface ToastOptions { tone?: ToastTone; durationMs?: number }
4
+
5
+ export const DEFAULT_TOAST_MS = 3000;
6
+
7
+ export function createToastStore() {
8
+ let toasts: ToastItem[] = [];
9
+ let seq = 0;
10
+ const subs = new Set<() => void>();
11
+ const emit = () => subs.forEach((f) => f());
12
+ return {
13
+ getToasts: () => toasts,
14
+ show(message: string, opts: ToastOptions = {}): string {
15
+ const id = `toast-${++seq}`;
16
+ toasts = [...toasts, { id, message, tone: opts.tone ?? 'info', durationMs: opts.durationMs ?? DEFAULT_TOAST_MS }];
17
+ emit();
18
+ return id;
19
+ },
20
+ dismiss(id: string) {
21
+ if (!toasts.some((t) => t.id === id)) return;
22
+ toasts = toasts.filter((t) => t.id !== id);
23
+ emit();
24
+ },
25
+ clear() { toasts = []; emit(); },
26
+ subscribe(f: () => void) { subs.add(f); return () => { subs.delete(f); }; },
27
+ };
28
+ }
@@ -0,0 +1,51 @@
1
+ /** Pure style builders for the chat surfaces: message bubble and composer. */
2
+ import type { TextStyle, ViewStyle } from 'react-native';
3
+ import type { Theme } from './theme';
4
+
5
+ export type ChatRole = 'user' | 'assistant' | 'system';
6
+
7
+ export function bubbleStyle(theme: Theme, role: ChatRole): ViewStyle {
8
+ const base: ViewStyle = {
9
+ maxWidth: '85%',
10
+ paddingHorizontal: theme.spacing.md,
11
+ paddingVertical: theme.spacing.sm + 2,
12
+ borderRadius: theme.radii.xl,
13
+ };
14
+ if (role === 'user') return { ...base, alignSelf: 'flex-end', backgroundColor: theme.colors.primary, borderBottomRightRadius: theme.radii.sm };
15
+ if (role === 'system') return { ...base, alignSelf: 'center', backgroundColor: 'transparent', maxWidth: '95%' };
16
+ return { ...base, alignSelf: 'flex-start', backgroundColor: theme.colors.muted, borderBottomLeftRadius: theme.radii.sm };
17
+ }
18
+
19
+ export function bubbleTextColor(theme: Theme, role: ChatRole): string {
20
+ if (role === 'user') return theme.colors.primaryForeground;
21
+ if (role === 'system') return theme.colors.mutedForeground;
22
+ return theme.colors.foreground;
23
+ }
24
+
25
+ export function composerStyle(theme: Theme): ViewStyle {
26
+ return {
27
+ flexDirection: 'row',
28
+ alignItems: 'flex-end',
29
+ gap: theme.spacing.sm,
30
+ paddingHorizontal: theme.spacing.md,
31
+ paddingTop: theme.spacing.sm,
32
+ borderTopWidth: 1,
33
+ borderTopColor: theme.colors.border,
34
+ backgroundColor: theme.colors.background,
35
+ };
36
+ }
37
+
38
+ export function sendButtonStyle(theme: Theme, enabled: boolean): ViewStyle {
39
+ return {
40
+ width: 44,
41
+ height: 44,
42
+ borderRadius: theme.radii.full,
43
+ alignItems: 'center',
44
+ justifyContent: 'center',
45
+ backgroundColor: enabled ? theme.colors.primary : theme.colors.muted,
46
+ };
47
+ }
48
+
49
+ export function sendGlyphStyle(theme: Theme, enabled: boolean): TextStyle {
50
+ return { fontSize: 20, fontWeight: '700', color: enabled ? theme.colors.primaryForeground : theme.colors.mutedForeground };
51
+ }
@@ -0,0 +1,100 @@
1
+ /** Pure style builders for overlays and chrome: sheet, toast, header, swipe actions. */
2
+ import type { TextStyle, ViewStyle } from 'react-native';
3
+ import type { Theme } from './theme';
4
+
5
+ export type ToneName = 'info' | 'success' | 'error' | 'warning';
6
+
7
+ export function sheetBackdropStyle(): ViewStyle {
8
+ return { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.45)' };
9
+ }
10
+
11
+ export function sheetPanelStyle(theme: Theme, maxHeight: number, bottomInset: number): ViewStyle {
12
+ return {
13
+ maxHeight,
14
+ backgroundColor: theme.colors.background,
15
+ borderTopLeftRadius: theme.radii.xl,
16
+ borderTopRightRadius: theme.radii.xl,
17
+ paddingTop: theme.spacing.sm,
18
+ paddingHorizontal: theme.spacing.lg,
19
+ paddingBottom: bottomInset + theme.spacing.lg,
20
+ borderWidth: 1,
21
+ borderBottomWidth: 0,
22
+ borderColor: theme.colors.border,
23
+ };
24
+ }
25
+
26
+ export function sheetGrabberStyle(theme: Theme): ViewStyle {
27
+ return { alignSelf: 'center', width: 36, height: 5, borderRadius: 3, backgroundColor: theme.colors.border, marginBottom: theme.spacing.md };
28
+ }
29
+
30
+ export function toastStyle(theme: Theme, tone: ToneName): ViewStyle {
31
+ const bg: Record<ToneName, string> = {
32
+ info: theme.colors.foreground,
33
+ success: theme.colors.success,
34
+ error: theme.colors.destructive,
35
+ warning: theme.colors.warning,
36
+ };
37
+ return {
38
+ backgroundColor: bg[tone],
39
+ borderRadius: theme.radii.lg,
40
+ paddingVertical: theme.spacing.md,
41
+ paddingHorizontal: theme.spacing.lg,
42
+ shadowColor: '#000',
43
+ shadowOpacity: 0.2,
44
+ shadowRadius: 8,
45
+ shadowOffset: { width: 0, height: 2 },
46
+ elevation: 4,
47
+ };
48
+ }
49
+
50
+ export function toastTextStyle(theme: Theme, tone: ToneName): TextStyle {
51
+ const fg: Record<ToneName, string> = {
52
+ info: theme.colors.background,
53
+ success: theme.colors.successForeground,
54
+ error: theme.colors.destructiveForeground,
55
+ warning: theme.colors.warningForeground,
56
+ };
57
+ return { color: fg[tone], fontSize: theme.fontSizes.sm, fontWeight: theme.fontWeights.medium as TextStyle['fontWeight'] };
58
+ }
59
+
60
+ export function headerStyle(theme: Theme, topInset: number): ViewStyle {
61
+ return {
62
+ flexDirection: 'row',
63
+ alignItems: 'center',
64
+ gap: theme.spacing.sm,
65
+ paddingTop: topInset + theme.spacing.sm,
66
+ paddingBottom: theme.spacing.sm,
67
+ paddingHorizontal: theme.spacing.lg,
68
+ backgroundColor: theme.colors.background,
69
+ borderBottomWidth: 1,
70
+ borderBottomColor: theme.colors.border,
71
+ minHeight: 52 + topInset,
72
+ };
73
+ }
74
+
75
+ export type SwipeTone = 'primary' | 'success' | 'destructive' | 'neutral';
76
+
77
+ export function swipeActionStyle(theme: Theme, tone: SwipeTone, width: number): ViewStyle {
78
+ const bg: Record<SwipeTone, string> = {
79
+ primary: theme.colors.primary,
80
+ success: theme.colors.success,
81
+ destructive: theme.colors.destructive,
82
+ neutral: theme.colors.muted,
83
+ };
84
+ return { width, alignItems: 'center', justifyContent: 'center', backgroundColor: bg[tone] };
85
+ }
86
+
87
+ export function swipeActionTextStyle(theme: Theme, tone: SwipeTone): TextStyle {
88
+ const fg: Record<SwipeTone, string> = {
89
+ primary: theme.colors.primaryForeground,
90
+ success: theme.colors.successForeground,
91
+ destructive: theme.colors.destructiveForeground,
92
+ neutral: theme.colors.foreground,
93
+ };
94
+ return { color: fg[tone], fontSize: theme.fontSizes.sm, fontWeight: theme.fontWeights.semibold as TextStyle['fontWeight'] };
95
+ }
96
+
97
+ /** Readable red for error TEXT: the dark-scheme destructive token is a fill colour and is too dim on a dark background. */
98
+ export function errorTextColor(theme: Theme): string {
99
+ return theme.scheme === 'dark' ? 'hsl(0 90% 70%)' : theme.colors.destructive;
100
+ }