@lotics/ui 13.9.0 → 14.0.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/src/clarify.tsx CHANGED
@@ -1,38 +1,54 @@
1
1
  import { View } from "react-native";
2
2
  import { Text } from "./text";
3
- import { ChoiceList, type ChoiceOption } from "./choice_list";
3
+ import { ChoiceList } from "./choice_list";
4
4
 
5
- export type ClarifyOption = ChoiceOption;
5
+ export interface ClarifyOption {
6
+ value: string;
7
+ label: string;
8
+ /** REQUIRED for a clarify answer: the one-line "what this choice means" so the
9
+ * human can pick without re-deriving it from the question. */
10
+ description: string;
11
+ }
6
12
 
7
13
  export interface ClarifyProps {
8
14
  /** The agent's question — what it needs settled to proceed. */
9
15
  question: string;
16
+ /** A muted eyebrow rendered tight above the question — e.g. a wizard's "1 / 3". */
17
+ eyebrow?: string;
10
18
  options: ClarifyOption[];
11
19
  onAnswer: (value: string) => void;
12
- /** The chosen option's VALUE — the picked choice shows selected; it stays
13
- * switchable so the human can change their mind. */
20
+ /** The chosen option's VALUE — or, with `allowCustom`, a custom answer's free
21
+ * text. The picked choice shows selected; switchable so the human can change. */
14
22
  answer?: string;
23
+ /** Offer an "Other…" free-text row below the options. */
24
+ allowCustom?: boolean;
15
25
  }
16
26
 
17
27
  /**
18
- * The agent asks BACK — a question with selectable answer options the human picks
19
- * before the run continues, freely switchable until committed. A borderless block
20
- * (no card): the question, then divider-separated option rows (`ChoiceList`).
21
- * Human-in-the-loop input: when the agent is unsure, it clarifies instead of
22
- * guessing wrong. Pair with `AgentRun` / `AgentProgress`.
28
+ * The agent asks BACK — a question with selectable answer options (each carrying
29
+ * a one-line description) the human picks before the run continues, freely
30
+ * switchable until committed. A borderless block (no card): the question text,
31
+ * then divider-separated option rows (`ChoiceList`); `allowCustom` adds an
32
+ * "Other…" free-text row. For a SEQUENCE of questions with Back/Next/Submit
33
+ * navigation, use `ClarifyWizard`. Human-in-the-loop: when unsure, the agent
34
+ * clarifies instead of guessing wrong. Pair with `AgentRun` / `AgentProgress`.
23
35
  */
24
36
  export function Clarify(props: ClarifyProps) {
25
37
  return (
26
- <View style={{ gap: 14 }}>
27
- <View style={{ gap: 4 }}>
28
- <Text size="xs" color="muted" weight="medium">
29
- Question
30
- </Text>
38
+ <View style={{ gap: 12 }}>
39
+ {/* paddingHorizontal 8 aligns the eyebrow + question with the option/answer
40
+ text, which sits on the `ChoiceList` rows' 8px inset (CONTROL_RADIUS grid). */}
41
+ <View style={{ gap: 4, paddingHorizontal: 8 }}>
42
+ {props.eyebrow ? (
43
+ <Text size="xs" color="muted" weight="medium">
44
+ {props.eyebrow}
45
+ </Text>
46
+ ) : null}
31
47
  <Text size="sm" weight="medium">
32
48
  {props.question}
33
49
  </Text>
34
50
  </View>
35
- <ChoiceList options={props.options} value={props.answer} onSelect={props.onAnswer} />
51
+ <ChoiceList options={props.options} value={props.answer} onSelect={props.onAnswer} allowCustom={props.allowCustom} />
36
52
  </View>
37
53
  );
38
54
  }
@@ -0,0 +1,84 @@
1
+ import { useState } from "react";
2
+ import { View } from "react-native";
3
+ import { Button } from "./button";
4
+ import { Clarify, type ClarifyOption } from "./clarify";
5
+ import { useLoticsLocale } from "./locale";
6
+
7
+ export interface ClarifyWizardQuestion {
8
+ question: string;
9
+ answers: ClarifyOption[];
10
+ /** Offer an "Other…" free-text answer for this question. */
11
+ allowCustom?: boolean;
12
+ }
13
+
14
+ export interface ClarifyWizardAnswer {
15
+ /** The chosen option's value, or the custom free text. */
16
+ value: string;
17
+ /** True when the value came from the "Other…" row (not one of the options). */
18
+ custom: boolean;
19
+ }
20
+
21
+ export interface ClarifyWizardProps {
22
+ questions: ClarifyWizardQuestion[];
23
+ /** Fires when every question is answered and the human submits — one answer
24
+ * per question, aligned by index. */
25
+ onSubmit: (answers: ClarifyWizardAnswer[]) => void;
26
+ onCancel: () => void;
27
+ }
28
+
29
+ /**
30
+ * A SEQUENCE of `Clarify` questions the human works through one at a time —
31
+ * Back / Next / Cancel / Submit with a position indicator. Each step reuses
32
+ * `Clarify` (question + described answers + optional "Other…"). You can only
33
+ * advance once the current question is answered; Submit fires when the last is.
34
+ * The multi-question form of the agent's ask-back; single questions use `Clarify`.
35
+ */
36
+ export function ClarifyWizard(props: ClarifyWizardProps) {
37
+ const { questions, onSubmit, onCancel } = props;
38
+ const labels = useLoticsLocale().clarify;
39
+ const [index, setIndex] = useState(0);
40
+ const [answers, setAnswers] = useState<(string | undefined)[]>(() => questions.map(() => undefined));
41
+
42
+ const total = questions.length;
43
+ const q = questions[index];
44
+ const current = answers[index];
45
+ // Answered = a non-empty string: an option value, or filled custom text ("" is
46
+ // "Other" picked but not yet typed — not answered).
47
+ const answered = typeof current === "string" && current.length > 0;
48
+ const isLast = index === total - 1;
49
+
50
+ const setAnswer = (v: string) =>
51
+ setAnswers((prev) => {
52
+ const next = [...prev];
53
+ next[index] = v;
54
+ return next;
55
+ });
56
+
57
+ const submit = () =>
58
+ onSubmit(
59
+ questions.map((question, i) => {
60
+ const value = answers[i] ?? "";
61
+ return { value, custom: !question.answers.some((a) => a.value === value) };
62
+ }),
63
+ );
64
+
65
+ return (
66
+ <View style={{ gap: 16 }}>
67
+ {/* The step position is an eyebrow above the question (no progress bar); the
68
+ footer aligns to the same 8px inset as the question and answers. Keyed by
69
+ index so each step's custom-answer draft is its own (no bleed across steps). */}
70
+ <Clarify key={index} eyebrow={`${index + 1} / ${total}`} question={q.question} options={q.answers} answer={current} onAnswer={setAnswer} allowCustom={q.allowCustom} />
71
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8, paddingHorizontal: 8 }}>
72
+ <Button title={labels.cancel} color="muted" onPress={onCancel} />
73
+ <View style={{ flexDirection: "row", gap: 8 }}>
74
+ {index > 0 ? <Button title={labels.back} color="secondary" onPress={() => setIndex(index - 1)} /> : null}
75
+ {isLast ? (
76
+ <Button title={labels.submit} color="primary" onPress={submit} disabled={!answered} />
77
+ ) : (
78
+ <Button title={labels.next} color="primary" onPress={() => setIndex(index + 1)} disabled={!answered} />
79
+ )}
80
+ </View>
81
+ </View>
82
+ </View>
83
+ );
84
+ }
@@ -1,4 +1,4 @@
1
- import { Platform, StyleSheet, View } from "react-native";
1
+ import { Platform, ScrollView, StyleSheet, View } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { colors } from "./colors";
4
4
 
@@ -15,18 +15,22 @@ export function JsonPanel(props: JsonPanelProps) {
15
15
  {title}
16
16
  </Text>
17
17
  <View style={styles.body}>
18
- <Text
19
- size="xs"
20
- style={{
21
- fontFamily:
22
- Platform.OS === "web"
23
- ? "ui-monospace, SFMono-Regular, Menlo, monospace"
24
- : "monospace",
25
- lineHeight: 18,
26
- }}
27
- >
28
- {value}
29
- </Text>
18
+ {/* A real dump can be tens of KB (a streamed tool input) — the panel caps
19
+ its height and scrolls inside, so it can never blow a feed open. */}
20
+ <ScrollView style={styles.scroll} contentContainerStyle={styles.content} nestedScrollEnabled>
21
+ <Text
22
+ size="xs"
23
+ style={{
24
+ fontFamily:
25
+ Platform.OS === "web"
26
+ ? "ui-monospace, SFMono-Regular, Menlo, monospace"
27
+ : "monospace",
28
+ lineHeight: 18,
29
+ }}
30
+ >
31
+ {value}
32
+ </Text>
33
+ </ScrollView>
30
34
  </View>
31
35
  </View>
32
36
  );
@@ -50,6 +54,14 @@ const styles = StyleSheet.create({
50
54
  borderColor: colors.zinc[200],
51
55
  borderRadius: 8,
52
56
  backgroundColor: colors.white,
57
+ // The ScrollView owns the padding (content inset scrolls with the text); the
58
+ // frame just clips so the scrollbar respects the rounded corners.
59
+ overflow: "hidden",
60
+ },
61
+ scroll: {
62
+ maxHeight: 240,
63
+ },
64
+ content: {
53
65
  paddingHorizontal: 10,
54
66
  paddingVertical: 8,
55
67
  },
package/src/locale.tsx CHANGED
@@ -57,6 +57,8 @@ export interface LoticsLocale {
57
57
  /** The `Inline*` editor family: the shared save-error line and the
58
58
  * text-editor Save/Cancel tooltips. */
59
59
  inline: { saveError: string; save: string; cancel: string };
60
+ /** `ChoiceList` custom-answer placeholder + `ClarifyWizard` navigation chrome. */
61
+ clarify: { otherPlaceholder: string; back: string; next: string; cancel: string; submit: string };
60
62
  /** `Ledger`: the screen-reader name of a peekable row. */
61
63
  ledger: { rowDetails: (label: string) => string };
62
64
  /** `SectionHeadingTitle`: the info-popover trigger's screen-reader name. */
@@ -159,6 +161,7 @@ export const en: LoticsLocale = {
159
161
  dangerZone: { title: "Danger zone" },
160
162
  drawer: { previous: "Previous record", next: "Next record", close: "Close" },
161
163
  inline: { saveError: "Couldn't save. Try again.", save: "Save", cancel: "Cancel" },
164
+ clarify: { otherPlaceholder: "Or type your own answer…", back: "Back", next: "Next", cancel: "Cancel", submit: "Submit" },
162
165
  ledger: { rowDetails: (label) => `${label} details` },
163
166
  sectionHeading: { info: "About this data" },
164
167
  chip: { remove: "Remove" },
@@ -254,6 +257,7 @@ export const vi: LoticsLocale = {
254
257
  dangerZone: { title: "Vùng nguy hiểm" },
255
258
  drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
256
259
  inline: { saveError: "Không lưu được. Thử lại.", save: "Lưu", cancel: "Hủy" },
260
+ clarify: { otherPlaceholder: "Hoặc nhập câu trả lời khác…", back: "Quay lại", next: "Tiếp", cancel: "Hủy", submit: "Gửi" },
257
261
  ledger: { rowDetails: (label) => `Chi tiết ${label}` },
258
262
  sectionHeading: { info: "Giải thích dữ liệu" },
259
263
  chip: { remove: "Xóa" },
@@ -21,18 +21,31 @@ import * as path from "node:path";
21
21
  * import `./markdown`. So it's allowed — the guards below remain for the
22
22
  * genuine context couplings.
23
23
  *
24
+ * The ONE allowed `ai` coupling: the `AgentRun` family renders the ai-sdk
25
+ * `UIMessage.parts` shape directly — that IS its contract (both chat and app
26
+ * agents emit it), so there is no bespoke transcript type to keep in sync.
27
+ * `agent_transform.ts` (the single place that folds parts into the render
28
+ * timeline) `import type`s from `ai` — erased at runtime, so no `ai` enters any
29
+ * consumer's bundle. That file alone is exempt from the `ai`-package guard; every
30
+ * other primitive stays ai-free, and the `@ai-sdk/*` runtime packages are never
31
+ * allowed anywhere.
32
+ *
24
33
  * The boundary is enforced by this test — without it primitives drift
25
34
  * back into the parent app's contexts over months.
26
35
  */
27
36
 
37
+ const AI_PACKAGE_PATTERN = /from\s+["']ai["']/;
28
38
  const FORBIDDEN_PATTERNS = [
29
39
  /from\s+["']posthog-js/,
30
40
  /from\s+["']@lingui\//,
31
41
  /from\s+["']@ai-sdk\//,
32
- /from\s+["']ai["']/,
42
+ AI_PACKAGE_PATTERN,
33
43
  /from\s+["']@lotics\/shared/,
34
44
  ];
35
45
 
46
+ // The sole file permitted to `import type` from the `ai` package (see above).
47
+ const AI_PACKAGE_ALLOWED = new Set(["agent_transform.ts"]);
48
+
36
49
  const SRC_DIR = path.resolve(__dirname);
37
50
 
38
51
  function listTopLevelSourceFiles(): string[] {
@@ -49,6 +62,7 @@ describe("packages/ui primitives — purity", () => {
49
62
  for (const file of listTopLevelSourceFiles()) {
50
63
  const src = fs.readFileSync(file, "utf-8");
51
64
  for (const pattern of FORBIDDEN_PATTERNS) {
65
+ if (pattern === AI_PACKAGE_PATTERN && AI_PACKAGE_ALLOWED.has(path.basename(file))) continue;
52
66
  if (pattern.test(src)) {
53
67
  violations.push(`${path.basename(file)} matches ${pattern}`);
54
68
  }
package/src/table.tsx CHANGED
@@ -315,7 +315,11 @@ const styles = StyleSheet.create({
315
315
  // Divider-separated.
316
316
  headerBand: {
317
317
  paddingHorizontal: ROW_GUTTER,
318
- paddingVertical: 10,
318
+ // NO top padding (GAP-85): a Table is borderless, so its container ALWAYS owns
319
+ // the top spacing (a Section's gap, a Card's padding, any `gap` above). A band
320
+ // paddingTop would stack on that and double-gap the column header; the bottom
321
+ // padding is the band's own — it spaces the header off the hairline below.
322
+ paddingBottom: 10,
319
323
  flexDirection: "row",
320
324
  alignItems: "center",
321
325
  gap: COLUMN_GAP,