@lotics/ui 5.6.1 → 5.7.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/AGENTS.md CHANGED
@@ -270,9 +270,9 @@ Compose the surfaces as a loop, and reach for the right one by job:
270
270
  attaching HOLDS the file for review; the human presses Send. The canvas (`tpl_dieline`) uses it this
271
271
  way; while running the host swaps `Composer` for `AgentProgress` (same pill geometry, so it reads as
272
272
  a morph).
273
- - **Show the work** — `AgentRun`: a live feed of the agent's work — its streamed narration (with a
274
- blinking caret) + tool calls as a vertical `Stepper` of neutral dots (the latest pulsing while live,
275
- a white-check dot once done). No title, no avatar — just the work. A terminal "Done" step (the
273
+ - **Show the work** — `AgentRun`: a live feed of the agent's work — its streamed narration (rendered
274
+ as `Markdown`, with a trailing ▍ while live) + tool calls as a vertical `Stepper` of neutral dots (the
275
+ latest pulsing while live, a white-check dot once done). No title, no avatar — just the work. A terminal "Done" step (the
276
276
  `complete` node) appears ONLY once the run finishes. A step can carry `peek` content to glance into
277
277
  via a popover. Tool labels resolve from the raw tool name via a built-in default map + an optional
278
278
  `labelForTool` override (localize THERE — the kit stays English). `doneLabel` localizes the terminal.
@@ -581,7 +581,9 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
581
581
 
582
582
  ## Full component inventory (import as `@lotics/ui/<module>`)
583
583
 
584
- text · card (Card · CardHeader · CardHeaderTitle · CardHeaderMeta · CardBody · CardFooter) ·
584
+ text ·
585
+ markdown (Markdown — the single canonical markdown renderer for chat, apps, and `AgentRun`; rich on web via react-markdown/remark-gfm with copyable tables, plain-text on native; takes a markdown `children` string; import `@lotics/ui/markdown.css` once for styling) ·
586
+ card (Card · CardHeader · CardHeaderTitle · CardHeaderMeta · CardBody · CardFooter) ·
585
587
  section_heading (SectionHeading · SectionHeadingTitle — compound, owns no margin) · badge ·
586
588
  option_badge (OptionBadge — a select value as its configured colored badge) ·
587
589
  member_chip (MemberChip — avatar + name; the universal person render) ·
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "5.6.1",
3
+ "version": "5.7.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -63,6 +63,11 @@
63
63
  "./activity_indicator": "./src/activity_indicator.tsx",
64
64
  "./agent_run": "./src/agent_run.tsx",
65
65
  "./agent_progress": "./src/agent_progress.tsx",
66
+ "./markdown": {
67
+ "react-native": "./src/markdown.tsx",
68
+ "default": "./src/markdown.web.tsx"
69
+ },
70
+ "./markdown.css": "./src/markdown.css",
66
71
  "./confidence": "./src/confidence.tsx",
67
72
  "./review_card": "./src/review_card.tsx",
68
73
  "./change_review": "./src/change_review.tsx",
@@ -217,6 +222,10 @@
217
222
  "directory": "packages/ui"
218
223
  },
219
224
  "license": "MIT",
225
+ "dependencies": {
226
+ "react-markdown": "^10.0.0",
227
+ "remark-gfm": "^4.0.0"
228
+ },
220
229
  "peerDependencies": {
221
230
  "@lotics/docx": "^0.1.0",
222
231
  "@lotics/xlsx": "^0.1.0",
package/src/agent_run.tsx CHANGED
@@ -1,8 +1,8 @@
1
- import { useEffect, useRef, type ReactNode } from "react";
2
- import { Animated, StyleSheet, View } from "react-native";
3
- import { colors, solid } from "./colors";
1
+ import { type ReactNode } from "react";
2
+ import { View } from "react-native";
4
3
  import { Text } from "./text";
5
4
  import { type IconName } from "./icon";
5
+ import { Markdown } from "./markdown";
6
6
  import { Peek } from "./peek";
7
7
  import { Stepper, Step, type StepStatus } from "./stepper";
8
8
 
@@ -25,10 +25,10 @@ export interface AgentRunStep {
25
25
 
26
26
  export interface AgentRunProps {
27
27
  steps: AgentRunStep[];
28
- /** The agent's streamed reasoning / answer text — shown as a narration block
29
- * above the feed, with a live caret while streaming. */
28
+ /** The agent's streamed reasoning / answer text — rendered as markdown in a
29
+ * narration block above the feed, with a trailing while streaming. */
30
30
  text?: string;
31
- /** Whole-run state. `streaming` keeps the caret blinking + the latest step
31
+ /** Whole-run state. `streaming` keeps the trailing + the latest step
32
32
  * pulsing; `done`/`error` settle it. Defaults to `streaming` while a step runs. */
33
33
  state?: "streaming" | "done" | "error";
34
34
  /** Localize / override a TOOL step's display label by its raw tool name
@@ -96,12 +96,7 @@ export function AgentRun(props: AgentRunProps) {
96
96
 
97
97
  return (
98
98
  <View accessibilityLabel={accessibilityLabel} style={{ gap: 12 }}>
99
- {text ? (
100
- <Text size="sm" style={styles.narration}>
101
- {text}
102
- {streaming ? <Caret /> : null}
103
- </Text>
104
- ) : null}
99
+ {text ? <Markdown>{streaming ? `${text} ▍` : text}</Markdown> : null}
105
100
 
106
101
  <Stepper orientation="vertical" live={streaming}>
107
102
  {steps.map((s, i) => {
@@ -147,23 +142,3 @@ export function AgentRun(props: AgentRunProps) {
147
142
  </View>
148
143
  );
149
144
  }
150
-
151
- // A blinking caret that trails the streamed text — the signature of live writing.
152
- function Caret() {
153
- const opacity = useRef(new Animated.Value(1)).current;
154
- useEffect(() => {
155
- const loop = Animated.loop(
156
- Animated.sequence([
157
- Animated.timing(opacity, { toValue: 0, duration: 480, useNativeDriver: false }),
158
- Animated.timing(opacity, { toValue: 1, duration: 480, useNativeDriver: false }),
159
- ]),
160
- );
161
- loop.start();
162
- return () => loop.stop();
163
- }, [opacity]);
164
- return <Animated.Text style={{ opacity, color: solid("blue") }}>▍</Animated.Text>;
165
- }
166
-
167
- const styles = StyleSheet.create({
168
- narration: { lineHeight: 21, color: colors.zinc[700] },
169
- });
@@ -0,0 +1,209 @@
1
+ .ui-markdown {
2
+ font-family: Inter_400Regular, "apple-system", "BlinkMacSystemFont", "Segoe UI",
3
+ "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
4
+ "Helvetica Neue", sans-serif;
5
+ font-size: 14px;
6
+ line-height: 24px;
7
+ letter-spacing: -0.4px;
8
+ color: rgba(24, 24, 27, 1);
9
+ }
10
+
11
+ /* Headings */
12
+
13
+ .ui-markdown h1 {
14
+ font-family: Inter_600SemiBold, "apple-system", "BlinkMacSystemFont", "Segoe UI",
15
+ "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
16
+ "Helvetica Neue", sans-serif;
17
+ font-size: 24px;
18
+ line-height: 30px;
19
+ font-weight: 600;
20
+ padding-top: 12px;
21
+ padding-bottom: 4px;
22
+ margin: 0;
23
+ }
24
+
25
+ .ui-markdown h2 {
26
+ font-family: Inter_600SemiBold, "apple-system", "BlinkMacSystemFont", "Segoe UI",
27
+ "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
28
+ "Helvetica Neue", sans-serif;
29
+ font-size: 18px;
30
+ line-height: 24px;
31
+ font-weight: 600;
32
+ padding-top: 12px;
33
+ padding-bottom: 4px;
34
+ margin: 0;
35
+ }
36
+
37
+ .ui-markdown h3 {
38
+ font-family: Inter_600SemiBold, "apple-system", "BlinkMacSystemFont", "Segoe UI",
39
+ "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
40
+ "Helvetica Neue", sans-serif;
41
+ font-size: 16px;
42
+ line-height: 22px;
43
+ font-weight: 600;
44
+ padding-top: 8px;
45
+ padding-bottom: 4px;
46
+ margin: 0;
47
+ }
48
+
49
+ .ui-markdown h4,
50
+ .ui-markdown h5,
51
+ .ui-markdown h6 {
52
+ font-family: Inter_500Medium, "apple-system", "BlinkMacSystemFont", "Segoe UI",
53
+ "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
54
+ "Helvetica Neue", sans-serif;
55
+ font-size: 14px;
56
+ line-height: 20px;
57
+ font-weight: 500;
58
+ padding-top: 8px;
59
+ padding-bottom: 4px;
60
+ margin: 0;
61
+ }
62
+
63
+ /* Block elements */
64
+
65
+ .ui-markdown p {
66
+ margin: 0;
67
+ padding: 4px 0;
68
+ }
69
+
70
+ .ui-markdown blockquote {
71
+ border-left: 3px solid rgba(228, 228, 231, 1);
72
+ margin: 4px 0;
73
+ padding-left: 12px;
74
+ color: rgba(113, 113, 122, 1);
75
+ }
76
+
77
+ .ui-markdown hr {
78
+ border: none;
79
+ border-top: 1px solid rgba(228, 228, 231, 1);
80
+ margin: 8px 0;
81
+ }
82
+
83
+ /* Lists */
84
+
85
+ .ui-markdown ul,
86
+ .ui-markdown ol {
87
+ padding-left: 24px;
88
+ margin: 4px 0;
89
+ }
90
+
91
+ .ui-markdown li {
92
+ padding-top: 2px;
93
+ padding-bottom: 2px;
94
+ }
95
+
96
+ /* Inline */
97
+
98
+ .ui-markdown strong {
99
+ font-family: Inter_500Medium, "apple-system", "BlinkMacSystemFont", "Segoe UI",
100
+ "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
101
+ "Helvetica Neue", sans-serif;
102
+ font-weight: 500;
103
+ }
104
+
105
+ .ui-markdown a {
106
+ color: rgba(59, 130, 246, 1);
107
+ text-decoration: underline;
108
+ cursor: pointer;
109
+ }
110
+
111
+ .ui-markdown del {
112
+ color: rgba(113, 113, 122, 1);
113
+ }
114
+
115
+ /* Code */
116
+
117
+ .ui-markdown code {
118
+ padding: 1px 5px;
119
+ color: rgba(127, 29, 29, 1);
120
+ background-color: rgba(244, 244, 245, 1);
121
+ border-radius: 4px;
122
+ font-size: 13px;
123
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
124
+ }
125
+
126
+ .ui-markdown pre {
127
+ background-color: rgba(244, 244, 245, 1);
128
+ padding: 12px;
129
+ border-radius: 8px;
130
+ margin: 4px 0;
131
+ overflow-x: auto;
132
+ }
133
+
134
+ .ui-markdown pre code {
135
+ padding: 0;
136
+ color: inherit;
137
+ background-color: transparent;
138
+ border-radius: 0;
139
+ font-size: 13px;
140
+ line-height: 20px;
141
+ }
142
+
143
+ /* Tables */
144
+
145
+ .ui-markdown table {
146
+ border-collapse: collapse;
147
+ margin: 4px 0;
148
+ width: max-content;
149
+ min-width: 100%;
150
+ }
151
+
152
+ .ui-markdown th,
153
+ .ui-markdown td {
154
+ border: 1px solid rgba(228, 228, 231, 1);
155
+ padding: 6px 12px;
156
+ text-align: left;
157
+ white-space: nowrap;
158
+ }
159
+
160
+ .ui-markdown th {
161
+ background-color: rgba(244, 244, 245, 1);
162
+ font-family: Inter_500Medium, "apple-system", "BlinkMacSystemFont", "Segoe UI",
163
+ "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
164
+ "Helvetica Neue", sans-serif;
165
+ font-weight: 500;
166
+ }
167
+
168
+ /* Table copy button */
169
+
170
+ .ui-markdown-table-wrapper {
171
+ position: relative;
172
+ margin: 16px 0;
173
+ max-width: 100%;
174
+ }
175
+
176
+ .ui-markdown-table-scroll {
177
+ overflow-x: auto;
178
+ max-width: 100%;
179
+ }
180
+
181
+ .ui-markdown-table-wrapper table {
182
+ margin: 0;
183
+ }
184
+
185
+ .ui-markdown-table-copy {
186
+ position: absolute;
187
+ top: 1px;
188
+ right: 1px;
189
+ opacity: 0;
190
+ transition: opacity 0.15s ease-out;
191
+ background: rgba(244, 244, 245, 0.9);
192
+ border: 1px solid rgba(228, 228, 231, 1);
193
+ border-radius: 0 0 0 6px;
194
+ width: 28px;
195
+ height: 28px;
196
+ padding: 0;
197
+ cursor: pointer;
198
+ display: flex;
199
+ align-items: center;
200
+ justify-content: center;
201
+ }
202
+
203
+ .ui-markdown-table-wrapper:hover .ui-markdown-table-copy {
204
+ opacity: 1;
205
+ }
206
+
207
+ .ui-markdown-table-copy:hover {
208
+ background: rgba(228, 228, 231, 1);
209
+ }
@@ -0,0 +1,17 @@
1
+ import { View } from "react-native";
2
+ import { Text } from "./text";
3
+
4
+ /**
5
+ * Native fallback. The rich renderer (`markdown.web.tsx`) is react-markdown over
6
+ * the DOM (`<div>`/`<table>`/clipboard) — web-only. `@lotics/ui` also builds for
7
+ * native, so this renders the source as plain text. Rich markdown shows on web
8
+ * (custom-code apps + the frontend on web), which is where it's used; the web
9
+ * variant is picked via the conditional `exports` in package.json.
10
+ */
11
+ export function Markdown({ children }: { children: string }) {
12
+ return (
13
+ <View>
14
+ <Text size="sm">{children}</Text>
15
+ </View>
16
+ );
17
+ }
@@ -0,0 +1,69 @@
1
+ import "./markdown.css";
2
+ import React, { useCallback, useRef, useState } from "react";
3
+ import { View } from "react-native";
4
+ import ReactMarkdown from "react-markdown";
5
+ import remarkGfm from "remark-gfm";
6
+ import { Icon } from "./icon";
7
+ import { colors } from "./colors";
8
+
9
+ export function Markdown({ children }: { children: string }) {
10
+ return (
11
+ <View>
12
+ <div className="ui-markdown">
13
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
14
+ {children}
15
+ </ReactMarkdown>
16
+ </div>
17
+ </View>
18
+ );
19
+ }
20
+
21
+ const markdownComponents = {
22
+ table: CopyableTable,
23
+ };
24
+
25
+ function CopyableTable(props: React.ComponentPropsWithoutRef<"table"> & { node?: unknown }) {
26
+ const { node: _node, children, ...rest } = props;
27
+ const tableRef = useRef<HTMLTableElement>(null);
28
+ const [copied, setCopied] = useState(false);
29
+
30
+ const handleCopy = useCallback(async () => {
31
+ const table = tableRef.current;
32
+ if (!table) return;
33
+
34
+ const rows = table.querySelectorAll("tr");
35
+ const tsv = Array.from(rows)
36
+ .map((row) =>
37
+ Array.from(row.querySelectorAll("th, td"))
38
+ .map((cell) => (cell.textContent ?? "").replace(/\t/g, " ").replace(/\n/g, " ").trim())
39
+ .join("\t"),
40
+ )
41
+ .join("\n");
42
+
43
+ await navigator.clipboard.writeText(tsv);
44
+ setCopied(true);
45
+ setTimeout(() => setCopied(false), 2000);
46
+ }, []);
47
+
48
+ return (
49
+ <div className="ui-markdown-table-wrapper">
50
+ <div className="ui-markdown-table-scroll">
51
+ <table ref={tableRef} {...rest}>
52
+ {children}
53
+ </table>
54
+ </div>
55
+ <button
56
+ className="ui-markdown-table-copy"
57
+ onClick={handleCopy}
58
+ type="button"
59
+ title={copied ? "Copied!" : "Copy table"}
60
+ >
61
+ <Icon
62
+ name={copied ? "check" : "copy"}
63
+ size={14}
64
+ color={copied ? colors.green[600] : colors.zinc[500]}
65
+ />
66
+ </button>
67
+ </div>
68
+ );
69
+ }
@@ -8,10 +8,18 @@ import * as path from "node:path";
8
8
  * `@lotics/ui` is the public, published, universal-rendering primitives
9
9
  * package. Every file in `src/` is a primitive. It must not import any
10
10
  * dependency that ties it to a specific Lotics runtime context: no chat
11
- * SDK, no analytics, no i18n, no markdown libraries, and no Lotics
12
- * domain types (which live in `@lotics/shared`). Lotics-coupled UI is
13
- * in the workspace-private `@lotics/ui-internal` package, which may
14
- * import from here and from `@lotics/shared` freely.
11
+ * SDK, no analytics, no i18n, and no Lotics domain types (which live in
12
+ * `@lotics/shared`). Lotics-coupled UI is in the workspace-private
13
+ * `@lotics/ui-internal` package, which may import from here and from
14
+ * `@lotics/shared` freely.
15
+ *
16
+ * Markdown (react-markdown/remark) is the one heavy third-party dependency
17
+ * the kit carries on purpose — it's the single canonical renderer for chat,
18
+ * apps, and `AgentRun`. It's generic, not a Lotics coupling; it stays
19
+ * universal via the `markdown.web.tsx` (DOM) / `markdown.tsx` (native
20
+ * plain-text) split and is tree-shaken out of any consumer that doesn't
21
+ * import `./markdown`. So it's allowed — the guards below remain for the
22
+ * genuine context couplings.
15
23
  *
16
24
  * The boundary is enforced by this test — without it primitives drift
17
25
  * back into the parent app's contexts over months.
@@ -22,8 +30,6 @@ const FORBIDDEN_PATTERNS = [
22
30
  /from\s+["']@lingui\//,
23
31
  /from\s+["']@ai-sdk\//,
24
32
  /from\s+["']ai["']/,
25
- /from\s+["']react-markdown/,
26
- /from\s+["']remark-/,
27
33
  /from\s+["']@lotics\/shared/,
28
34
  ];
29
35
 
@@ -38,7 +44,7 @@ function listTopLevelSourceFiles(): string[] {
38
44
  }
39
45
 
40
46
  describe("packages/ui primitives — purity", () => {
41
- it("no primitive imports posthog, lingui, ai-sdk, markdown, or @lotics/shared", () => {
47
+ it("no primitive imports posthog, lingui, ai-sdk, or @lotics/shared", () => {
42
48
  const violations: string[] = [];
43
49
  for (const file of listTopLevelSourceFiles()) {
44
50
  const src = fs.readFileSync(file, "utf-8");