@lotics/ui 4.1.0 → 4.3.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.
@@ -0,0 +1,51 @@
1
+ import React from "react";
2
+ import { View, StyleSheet, StyleProp, ViewStyle } from "react-native";
3
+ import { Avatar } from "./avatar";
4
+ import { Text } from "./text";
5
+
6
+ interface MemberChipProps {
7
+ /** Display name. Empty/blank falls back to a neutral label — pass
8
+ * `name={member.name || member.email}` to prefer email when unnamed. */
9
+ name?: string | null;
10
+ /** Avatar image URL (a member's `image` from `useMembers`). Absent → initials. */
11
+ image?: string | null;
12
+ /** Optional secondary line under the name — e.g. email, role, department. */
13
+ secondary?: string | null;
14
+ /** Avatar diameter in px. Default 28. */
15
+ size?: number;
16
+ style?: StyleProp<ViewStyle>;
17
+ }
18
+
19
+ /**
20
+ * Render a member/person as an avatar + name (+ optional secondary line) — the
21
+ * one canonical way to show a person inline: a member-picker option, an assignee,
22
+ * a `select_member` cell in a register or detail. The {@link Avatar} falls back
23
+ * to initials when there's no image.
24
+ *
25
+ * Pure: pass the member's fields in (from `useMembers`, or a resolved
26
+ * `select_member` cell joined against that roster); this component fetches
27
+ * nothing and carries no domain types.
28
+ */
29
+ export function MemberChip({ name, image, secondary, size = 28, style }: MemberChipProps) {
30
+ const displayName = name?.trim() || "Unknown";
31
+ return (
32
+ <View style={[styles.row, style]}>
33
+ <Avatar size={size} name={displayName} source={image ? { uri: image } : undefined} />
34
+ <View style={styles.text}>
35
+ <Text userSelect="none" numberOfLines={1}>
36
+ {displayName}
37
+ </Text>
38
+ {secondary ? (
39
+ <Text userSelect="none" size="sm" color="zinc-500" numberOfLines={1}>
40
+ {secondary}
41
+ </Text>
42
+ ) : null}
43
+ </View>
44
+ </View>
45
+ );
46
+ }
47
+
48
+ const styles = StyleSheet.create({
49
+ row: { flexDirection: "row", alignItems: "center", gap: 6 },
50
+ text: { flexShrink: 1 },
51
+ });
@@ -0,0 +1,81 @@
1
+ import React, { useCallback, useMemo } from "react";
2
+ import { StyleProp, ViewStyle } from "react-native";
3
+ import {
4
+ Picker,
5
+ type PickerOption,
6
+ type PickerValue,
7
+ type PickerOnValueChange,
8
+ type PickerOnClose,
9
+ } from "./picker";
10
+ import { MemberChip } from "./member_chip";
11
+
12
+ /** A candidate member for {@link MemberSelect}. Shaped to accept a `useMembers`
13
+ * row from `@lotics/app-sdk` (or any roster) directly. */
14
+ export interface MemberSelectMember {
15
+ id: string;
16
+ name?: string | null;
17
+ image?: string | null;
18
+ email?: string | null;
19
+ }
20
+
21
+ interface MemberSelectProps<MULTI extends boolean = false> {
22
+ /**
23
+ * The candidate roster. An app feeds `useMembers()`; the product feeds any
24
+ * directory. Each member carries its own avatar (`image`), so options render
25
+ * with no side lookup.
26
+ */
27
+ members: MemberSelectMember[];
28
+ value?: PickerValue<string, MULTI> | null;
29
+ onValueChange?: PickerOnValueChange<string, MULTI>;
30
+ onClose?: PickerOnClose<string, MULTI>;
31
+ multi?: MULTI;
32
+ placeholder?: string;
33
+ disabled?: boolean;
34
+ autoFocus?: boolean;
35
+ includeEmptyOption?: boolean;
36
+ style?: StyleProp<ViewStyle>;
37
+ testID?: string;
38
+ accessibilityLabel?: string;
39
+ }
40
+
41
+ /**
42
+ * Choose member(s) — a {@link Picker} whose every option renders as a
43
+ * {@link MemberChip} (avatar + name). The one member-picker shape, so an app
44
+ * never re-wires `Picker` + `renderOptionContent` + a directory by hand. PURE:
45
+ * pass the candidate `members` (an app feeds `useMembers`, the product any
46
+ * roster); this fetches nothing. Single or multi via `multi`. Options are
47
+ * single-line by design — a picker row identifies a person by name + avatar;
48
+ * for a denser identity (email/role), render {@link MemberChip} with `secondary`
49
+ * on a roomier surface (a register row, a detail), not in a picker.
50
+ *
51
+ * ```tsx
52
+ * const { members } = useMembers();
53
+ * <MemberSelect members={members} value={assignee} onValueChange={setAssignee} />
54
+ * ```
55
+ */
56
+ export function MemberSelect<MULTI extends boolean = false>(props: MemberSelectProps<MULTI>) {
57
+ const { members, ...picker } = props;
58
+
59
+ const byId = useMemo(() => new Map(members.map((m) => [m.id, m])), [members]);
60
+ const options = useMemo<PickerOption<string>[]>(
61
+ () => members.map((m) => ({ value: m.id, label: m.name || m.email || m.id })),
62
+ [members],
63
+ );
64
+
65
+ const renderOptionContent = useCallback(
66
+ (option: PickerOption<string>) => {
67
+ const member = byId.get(option.value);
68
+ if (!member) return null;
69
+ return <MemberChip name={member.name || member.email} image={member.image} />;
70
+ },
71
+ [byId],
72
+ );
73
+
74
+ return (
75
+ <Picker<string, MULTI>
76
+ options={options}
77
+ renderOptionContent={renderOptionContent}
78
+ {...picker}
79
+ />
80
+ );
81
+ }
@@ -0,0 +1,58 @@
1
+ import React from "react";
2
+ import { View, StyleSheet, StyleProp, ViewStyle } from "react-native";
3
+ import { Badge } from "./badge";
4
+ import { asColorName } from "./colors";
5
+
6
+ /**
7
+ * One select option, as carried by a query CELL (`readSelect`) or the option
8
+ * LIST (`useFieldOptions`) in `@lotics/app-sdk`. `key` is optional — used only
9
+ * as a stable React key. `color` is a palette token; it may be absent (a cell
10
+ * carries only key + label) or a token this UI build doesn't recognize — either
11
+ * way the badge degrades to a neutral color.
12
+ */
13
+ export interface OptionValue {
14
+ key?: string;
15
+ label: string;
16
+ color?: string | null;
17
+ }
18
+
19
+ interface OptionBadgeProps {
20
+ /**
21
+ * A single option, an array (a multi-select cell → one badge each), or
22
+ * null/empty (renders nothing — pair with your own placeholder).
23
+ */
24
+ value?: OptionValue | OptionValue[] | null;
25
+ /** Badge weight — see {@link Badge}. Default "tonal". */
26
+ variant?: "tonal" | "dot";
27
+ style?: StyleProp<ViewStyle>;
28
+ }
29
+
30
+ /**
31
+ * Render a select-field value as colored {@link Badge}(s) with the option's
32
+ * CONFIGURED color resolved automatically — so an app never hand-maintains an
33
+ * option-key → color map, and a freshly added or renamed option just renders.
34
+ * Multi-select wraps to one badge per option; an empty value renders nothing; an
35
+ * option with a missing/unrecognized color token falls back to a neutral badge.
36
+ *
37
+ * Feed it straight from `@lotics/app-sdk`: a `useFieldOptions` option for a
38
+ * picker, or `byKey(readSelect(cell)[0]?.key)` for a stored value.
39
+ */
40
+ export function OptionBadge({ value, variant, style }: OptionBadgeProps) {
41
+ const options = value == null ? [] : Array.isArray(value) ? value : [value];
42
+ if (options.length === 0) return null;
43
+ if (options.length === 1) {
44
+ const o = options[0];
45
+ return <Badge label={o.label} color={asColorName(o.color)} variant={variant} style={style} />;
46
+ }
47
+ return (
48
+ <View style={[styles.wrap, style]}>
49
+ {options.map((o, i) => (
50
+ <Badge key={o.key ?? String(i)} label={o.label} color={asColorName(o.color)} variant={variant} />
51
+ ))}
52
+ </View>
53
+ );
54
+ }
55
+
56
+ const styles = StyleSheet.create({
57
+ wrap: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 4 },
58
+ });
@@ -0,0 +1,150 @@
1
+ import { useCallback, useState } from "react";
2
+ import {
3
+ NativeSyntheticEvent,
4
+ StyleSheet,
5
+ TextInput as RNTextInput,
6
+ TextInputKeyPressEventData,
7
+ View,
8
+ } from "react-native";
9
+ import { colors, solid, tint } from "./colors";
10
+ import { Icon } from "./icon";
11
+ import { IconButton } from "./icon_button";
12
+ import { ActivityIndicator } from "./activity_indicator";
13
+ import { ACTIVE_RING } from "./control_surface";
14
+ import { getInputTextStyle, fontFamilyRegular } from "./text_utils";
15
+
16
+ export interface PromptFieldProps {
17
+ value: string;
18
+ onChangeText: (value: string) => void;
19
+ /** Fired on Enter (without Shift) or the send button. Receives the trimmed
20
+ * text; never fires empty or while busy. */
21
+ onSubmit: (value: string) => void;
22
+ placeholder?: string;
23
+ /** The agent is working — the field locks and the send slot shows a spinner.
24
+ * This is the imperative command surface: one prompt does work, you watch it
25
+ * stream (see `AgentRun`), you don't chat. */
26
+ busy?: boolean;
27
+ disabled?: boolean;
28
+ autoFocus?: boolean;
29
+ accessibilityLabel?: string;
30
+ /** When set, a paperclip appears at the left of the footer — the composer can
31
+ * attach files (e.g. the photo that starts a run). The host opens the picker. */
32
+ onAttach?: () => void;
33
+ }
34
+
35
+ /**
36
+ * The natural-language command surface for an AI app — a prompt box that
37
+ * triggers agent work, NOT a chat composer. Enter sends (Shift+Enter for a
38
+ * newline); while the agent runs, the field locks and the send slot spins. The
39
+ * violet sparkle marks it as the AI input. Pair with `AgentRun` (the streamed
40
+ * work it kicks off) and a session history of the outputs it produces.
41
+ */
42
+ export function PromptField(props: PromptFieldProps) {
43
+ const { value, onChangeText, onSubmit, placeholder, busy, disabled, autoFocus, accessibilityLabel, onAttach } = props;
44
+ const [focused, setFocused] = useState(false);
45
+
46
+ const canSubmit = value.trim().length > 0 && !busy && !disabled;
47
+
48
+ const submit = useCallback(() => {
49
+ if (value.trim().length === 0 || busy || disabled) return;
50
+ onSubmit(value.trim());
51
+ }, [value, busy, disabled, onSubmit]);
52
+
53
+ // Web: Enter sends, Shift+Enter inserts a newline. RN's key event wraps the
54
+ // DOM event, so shiftKey/preventDefault are read defensively off nativeEvent.
55
+ const onKeyPress = useCallback(
56
+ (e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
57
+ const ne = e.nativeEvent as unknown as { key: string; shiftKey?: boolean };
58
+ if (ne.key === "Enter" && !ne.shiftKey) {
59
+ (e as unknown as { preventDefault?: () => void }).preventDefault?.();
60
+ submit();
61
+ }
62
+ },
63
+ [submit],
64
+ );
65
+
66
+ return (
67
+ <View
68
+ style={[
69
+ styles.box,
70
+ focused ? { borderColor: colors.zinc[900], boxShadow: ACTIVE_RING } : null,
71
+ disabled ? { backgroundColor: colors.zinc[50] } : null,
72
+ ]}
73
+ >
74
+ <View style={styles.inputRow}>
75
+ <View style={styles.glyph}>
76
+ <Icon name="sparkles" size={14} color={solid("violet")} />
77
+ </View>
78
+ <RNTextInput
79
+ accessibilityLabel={accessibilityLabel ?? placeholder}
80
+ style={[styles.input, getInputTextStyle()]}
81
+ value={value}
82
+ onChangeText={onChangeText}
83
+ onKeyPress={onKeyPress}
84
+ onFocus={() => setFocused(true)}
85
+ onBlur={() => setFocused(false)}
86
+ placeholder={placeholder}
87
+ placeholderTextColor={colors.zinc[400]}
88
+ editable={!busy && !disabled}
89
+ autoFocus={autoFocus}
90
+ multiline
91
+ submitBehavior="newline"
92
+ />
93
+ </View>
94
+ <View style={styles.footer}>
95
+ {onAttach ? (
96
+ <IconButton icon="paperclip" color="none" size="sm" accessibilityLabel="Attach files" onPress={onAttach} disabled={busy || disabled} />
97
+ ) : null}
98
+ <View style={{ flex: 1 }} />
99
+ {busy ? (
100
+ <View style={styles.sendSlot}>
101
+ <ActivityIndicator size={18} color={solid("violet")} />
102
+ </View>
103
+ ) : (
104
+ <IconButton
105
+ icon="arrow-up"
106
+ color="primary"
107
+ accessibilityLabel="Send"
108
+ onPress={submit}
109
+ disabled={!canSubmit}
110
+ />
111
+ )}
112
+ </View>
113
+ </View>
114
+ );
115
+ }
116
+
117
+ const styles = StyleSheet.create({
118
+ box: {
119
+ borderWidth: 1,
120
+ borderColor: colors.border,
121
+ borderRadius: 14,
122
+ backgroundColor: colors.white,
123
+ paddingHorizontal: 12,
124
+ paddingTop: 10,
125
+ paddingBottom: 8,
126
+ gap: 6,
127
+ },
128
+ inputRow: { flexDirection: "row", gap: 8, alignItems: "flex-start" },
129
+ glyph: {
130
+ width: 24,
131
+ height: 24,
132
+ borderRadius: 7,
133
+ alignItems: "center",
134
+ justifyContent: "center",
135
+ backgroundColor: tint("violet", 0.1),
136
+ marginTop: 2,
137
+ },
138
+ input: {
139
+ flex: 1,
140
+ minHeight: 48,
141
+ maxHeight: 160,
142
+ paddingTop: 4,
143
+ paddingBottom: 0,
144
+ fontFamily: fontFamilyRegular,
145
+ // RN-Web focus outline is handled by the box ring, not the raw input.
146
+ outlineStyle: "none",
147
+ } as object,
148
+ footer: { flexDirection: "row", alignItems: "center" },
149
+ sendSlot: { width: 28, height: 28, alignItems: "center", justifyContent: "center" },
150
+ });
@@ -0,0 +1,92 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { colors } from "./colors";
3
+ import { Text } from "./text";
4
+ import { Icon, type IconName } from "./icon";
5
+ import { PressableHighlight } from "./pressable_highlight";
6
+
7
+ export type SourceKind = "record" | "document" | "table" | "web" | "knowledge";
8
+
9
+ export interface SourceRef {
10
+ id: string;
11
+ label: string;
12
+ kind?: SourceKind;
13
+ /** A locator under the label — "page 2", a record code, a column. */
14
+ detail?: string;
15
+ }
16
+
17
+ export interface SourcesProps {
18
+ sources: SourceRef[];
19
+ /** Eyebrow above the chips. Default "Sources". */
20
+ label?: string;
21
+ /** Pass to make each chip openable (jump to the record / document / page). */
22
+ onOpen?: (source: SourceRef) => void;
23
+ }
24
+
25
+ const KIND_ICON: Record<SourceKind, IconName> = {
26
+ record: "box",
27
+ document: "file-text",
28
+ table: "table-2",
29
+ web: "external-link",
30
+ knowledge: "book-open",
31
+ };
32
+
33
+ /**
34
+ * Where the AI's output came FROM — a row of source chips under a generated
35
+ * answer, summary, or extracted value, each openable. Provenance is what makes
36
+ * AI output verifiable; show it on anything the agent produced from data it
37
+ * read (extraction, summarization, Q&A). Renders nothing for an empty list.
38
+ */
39
+ export function Sources(props: SourcesProps) {
40
+ if (props.sources.length === 0) return null;
41
+ return (
42
+ <View style={{ gap: 6 }}>
43
+ <Text size="xs" color="muted" transform="uppercase">
44
+ {props.label ?? "Sources"}
45
+ </Text>
46
+ <View style={styles.row}>
47
+ {props.sources.map((s) =>
48
+ props.onOpen ? (
49
+ <PressableHighlight key={s.id} onPress={() => props.onOpen?.(s)} style={styles.chip}>
50
+ <SourceChip source={s} />
51
+ </PressableHighlight>
52
+ ) : (
53
+ <View key={s.id} style={styles.chip}>
54
+ <SourceChip source={s} />
55
+ </View>
56
+ ),
57
+ )}
58
+ </View>
59
+ </View>
60
+ );
61
+ }
62
+
63
+ function SourceChip({ source }: { source: SourceRef }) {
64
+ return (
65
+ <>
66
+ <Icon name={KIND_ICON[source.kind ?? "document"]} size={12} color={colors.zinc[500]} />
67
+ <Text size="xs" weight="medium" numberOfLines={1}>
68
+ {source.label}
69
+ </Text>
70
+ {source.detail ? (
71
+ <Text size="xs" color="muted" numberOfLines={1}>
72
+ {source.detail}
73
+ </Text>
74
+ ) : null}
75
+ </>
76
+ );
77
+ }
78
+
79
+ const styles = StyleSheet.create({
80
+ row: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
81
+ chip: {
82
+ flexDirection: "row",
83
+ alignItems: "center",
84
+ gap: 6,
85
+ paddingHorizontal: 10,
86
+ paddingVertical: 5,
87
+ borderRadius: 8,
88
+ borderWidth: 1,
89
+ borderColor: colors.border,
90
+ backgroundColor: colors.white,
91
+ },
92
+ });
@@ -0,0 +1,102 @@
1
+ import { ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { colors, solid, tint } from "./colors";
4
+ import { Text } from "./text";
5
+ import { Icon } from "./icon";
6
+ import { Button } from "./button";
7
+ import { LinkButton } from "./link_button";
8
+ import { Confidence, type ConfidenceLevel } from "./confidence";
9
+
10
+ export interface SuggestionProps {
11
+ /** The proposal headline — what the AI suggests. */
12
+ title: string;
13
+ /** One line of WHY — the rationale the human is weighing. */
14
+ rationale?: string;
15
+ confidence?: ConfidenceLevel;
16
+ confidenceScore?: number;
17
+ /** The proposal body — render the proposed value/preview however fits (a
18
+ * field stack, a chip row, a diagram). Omit for a title-only suggestion. */
19
+ children?: ReactNode;
20
+ onAccept?: () => void;
21
+ /** Tweak before accepting — the host opens its editor. */
22
+ onEdit?: () => void;
23
+ onDismiss?: () => void;
24
+ acceptLabel?: string;
25
+ /** Once resolved the card settles to a quiet outcome line (no actions). */
26
+ status?: "open" | "accepted" | "dismissed";
27
+ }
28
+
29
+ /**
30
+ * The core review-for-approval surface: an AI proposal the human accepts,
31
+ * edits, or dismisses — never auto-applied. The violet sparkle marks it as
32
+ * AI-proposed; a `Confidence` chip says how sure; the body shows the proposed
33
+ * value. The decision is the human's. For a shortlist, stack several (rank by
34
+ * confidence, the top one first). Pair with `AgentRun` (what produced it) and
35
+ * `ChangeReview` (when the proposal is an edit to existing state).
36
+ */
37
+ export function Suggestion(props: SuggestionProps) {
38
+ const status = props.status ?? "open";
39
+ const hasConfidence = props.confidence != null || props.confidenceScore != null;
40
+ return (
41
+ <View style={[styles.card, status !== "open" ? styles.resolved : null]}>
42
+ <View style={styles.header}>
43
+ <Icon name="sparkles" size={14} color={solid("violet")} />
44
+ <Text size="sm" weight="semibold" style={{ flex: 1 }} numberOfLines={2}>
45
+ {props.title}
46
+ </Text>
47
+ {status === "open" && hasConfidence ? (
48
+ <Confidence level={props.confidence} score={props.confidenceScore} />
49
+ ) : null}
50
+ </View>
51
+
52
+ {props.rationale ? (
53
+ <Text size="xs" color="muted">
54
+ {props.rationale}
55
+ </Text>
56
+ ) : null}
57
+
58
+ {props.children ? <View>{props.children}</View> : null}
59
+
60
+ {status !== "open" ? (
61
+ <View style={styles.outcome}>
62
+ <Icon
63
+ name={status === "accepted" ? "circle-check" : "x"}
64
+ size={14}
65
+ color={status === "accepted" ? solid("emerald") : colors.zinc[400]}
66
+ />
67
+ <Text size="xs" color="muted">
68
+ {status === "accepted" ? "Accepted" : "Dismissed"}
69
+ </Text>
70
+ </View>
71
+ ) : (
72
+ <View style={styles.footer}>
73
+ {props.onDismiss ? <LinkButton title="Dismiss" onPress={props.onDismiss} /> : <View />}
74
+ <View style={styles.actions}>
75
+ {props.onEdit ? (
76
+ <Button title="Edit" color="secondary" shape="rounded" icon="square-pen" onPress={props.onEdit} />
77
+ ) : null}
78
+ {props.onAccept ? (
79
+ <Button title={props.acceptLabel ?? "Accept"} color="primary" shape="rounded" onPress={props.onAccept} />
80
+ ) : null}
81
+ </View>
82
+ </View>
83
+ )}
84
+ </View>
85
+ );
86
+ }
87
+
88
+ const styles = StyleSheet.create({
89
+ card: {
90
+ borderWidth: 1,
91
+ borderColor: tint("violet", 0.35),
92
+ backgroundColor: colors.white,
93
+ borderRadius: 12,
94
+ padding: 14,
95
+ gap: 10,
96
+ },
97
+ resolved: { borderColor: colors.border, backgroundColor: colors.zinc[50] },
98
+ header: { flexDirection: "row", alignItems: "center", gap: 8 },
99
+ outcome: { flexDirection: "row", alignItems: "center", gap: 6 },
100
+ footer: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
101
+ actions: { flexDirection: "row", alignItems: "center", gap: 8 },
102
+ });