@lotics/ui 4.2.0 → 4.4.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
@@ -33,7 +33,8 @@ Pick by capability, not by name. (→ the source file for the API.)
33
33
  (`renderOptionContent={(o) => <OptionBadge value={o} />}`).
34
34
  - **Pick member(s)** — `MemberSelect` (a `Picker` that renders each option as a `MemberChip`,
35
35
  single or multi) — the ready member picker; pass it the roster (`members={useMembers().members}`).
36
- Don't re-wire `Picker` + `renderOptionContent` + a directory by hand.
36
+ Don't re-wire `Picker` + `renderOptionContent` + a directory by hand. To edit a `select_member`
37
+ field IN PLACE (chip at rest → picker on click) use its inline-edit twin `InlineMemberSelect`.
37
38
  - **A person / member (display)** — `MemberChip` (avatar + name + optional secondary line) — the
38
39
  ONE way to show a member inline: a picker option, an assignee, a `select_member` value. Pure:
39
40
  resolve the member from your directory (`useMembers`) and pass `name` / `image`; never hand-roll
@@ -49,7 +50,8 @@ Pick by capability, not by name. (→ the source file for the API.)
49
50
  `Checkbox`, `Switch`, `RadioPicker`; dates via `DatePicker` / `DateRangeFilterField`, times
50
51
  via `TimePicker`.
51
52
  - **Edit a record's fields in place** — the `Inline*` family: `InlineTextInput` ·
52
- `InlineNumberInput` · `InlineSelect` · `InlineDatePicker` · `InlineTimePicker` (see §Data entry).
53
+ `InlineNumberInput` · `InlineSelect` · `InlineMemberSelect` · `InlineDatePicker` ·
54
+ `InlineTimePicker` (see §Data entry).
53
55
  - **Tabular data** — `Table` (columns defined once; sortable headers via `SortHeader`; paired
54
56
  with `Pagination`). Never an HTML `<table>` or a `.map` of rows.
55
57
  - **Numbers & charts** — `KPIStrip` / `KPICard` / `Metric` (headline figures), `TrendChip`
@@ -73,6 +75,12 @@ Pick by capability, not by name. (→ the source file for the API.)
73
75
  - **Specialized work surfaces** — `ScanField` (scan/verify), `StepList` (guided run),
74
76
  `RemainderMeter` + `AllocationRow` (allocation), `Timeline`, `Calendar`, `Gantt`,
75
77
  `comments_thread`.
78
+ - **AI surfaces** — `PromptField` (the NL command that triggers agent work — NOT a chat),
79
+ `AgentRun` (the live streaming work feed) + `AgentProgress` (its compact, floating, expandable
80
+ form — a composer's "working" state), `Suggestion` (review-for-approval of a proposal)
81
+ + `Confidence`, `AssistedField` (a confirmable AI estimate), `ChangeReview` (before→after for an
82
+ agent-proposed edit — whole-set OR 1-by-1), `Clarify` (the agent asks back — a question + quick
83
+ replies), `Sources` (provenance chips for AI output). See §AI workflows.
76
84
 
77
85
  ---
78
86
 
@@ -101,7 +109,9 @@ hover tints it (no pencil — that shifts), click swaps the input in **at the sa
101
109
  reflow, the whole point), and it commits on blur (Enter saves, Escape reverts) or via
102
110
  `controls="buttons"` (✓ primary / ✕). One per type — `InlineTextInput` · `InlineNumberInput`
103
111
  (`format` for currency/units) · `InlineSelect` (plain options OR `renderOptionContent`; floats a
104
- `Picker` menu in a popover so the row never grows) · `InlineDatePicker` · `InlineTimePicker` all
112
+ `Picker` menu in a popover so the row never grows; `renderValue` renders the RESTING value as a
113
+ node — a colored `OptionBadge`, not just a label) · `InlineMemberSelect` (a `MemberChip` at rest →
114
+ member picker; the inline twin of `MemberSelect`) · `InlineDatePicker` · `InlineTimePicker` — all
105
115
  on `useInlineEdit` + `InlineEditView` (custom inputs join via those). `onSave` is async: the
106
116
  saving spinner sits INSIDE the control (never a sibling — that reflows); an error shows inline
107
117
  without losing the edit. Pair with `DetailRow` (label left, inline value right). Not every field
@@ -198,6 +208,57 @@ the "this is a gate" framing.
198
208
 
199
209
  ---
200
210
 
211
+ ## AI workflows — AI proposes, the human decides
212
+
213
+ The AI surfaces share ONE law: the agent never commits — it **proposes**, the human
214
+ accepts / edits / dismisses, the deterministic app applies. The agent owns judgment
215
+ (recognition, estimation, intent→parameters); the app owns geometry, math, and the write.
216
+ Compose the surfaces as a loop, and reach for the right one by job:
217
+
218
+ - **Command** — `PromptField`, NOT a chat composer. A prompt TRIGGERS agent work (Enter sends);
219
+ while it runs the field locks and the send spins. The imperative "do this" surface.
220
+ - **Show the work** — `AgentRun`: a live feed of the agent's steps (reasoning, tool calls) on a
221
+ spine, the running node pulsing violet. Transparent work, NEVER a bare spinner — the
222
+ intelligence is legible. On a canvas/composer app reach for `AgentProgress` — `AgentRun`
223
+ collapsed into a floating pill (avatar + current step) that EXPANDS on press; the composer
224
+ morphs into it while running, and reveals again when done.
225
+ - **Review before apply** — `Suggestion` (a NEW proposed value → accept / edit / dismiss, carrying
226
+ a `Confidence` chip) or `ChangeReview` (an EDIT to existing state → before→after, apply / discard).
227
+ For a shortlist, stack ranked `Suggestion`s (highest confidence first). `ChangeReview` also has a
228
+ **1-by-1** mode — pass `onAcceptItem`/`onRejectItem` and each suggested change gets its own ✓/✕,
229
+ Apply committing only the accepted set (the chat copilot). Nothing auto-applies.
230
+ - **Confirmable estimates** — `AssistedField`: an AI-prefilled value reads as a TINTED ESTIMATE
231
+ until a person confirms or overrides it — provenance stays visible. Stack several with one
232
+ form-level "Confirm all".
233
+ - **Ask back** — `Clarify`: when the agent is unsure, it asks a question with quick-reply options
234
+ and PAUSES, instead of guessing wrong. Human-in-the-loop input mid-run.
235
+ - **Provenance** — `Sources`: openable chips saying where the output came FROM (records, a
236
+ document + page, a table), under any answer / summary / extracted value. Makes AI output
237
+ verifiable — show it on anything produced from data the agent read.
238
+ - **Session, not chat** — the outputs accrue as a history the user can CLEAR ("New session"); the
239
+ APP owns the evolving state, each run is a discrete task. (The agent may re-read the session for
240
+ "make it a bit less", but it's a run LOG, not a conversation transcript — that's why it's not a
241
+ chat.)
242
+
243
+ Four shapes prove the range — pick by whether the work is a living artifact, a conversation, a
244
+ single pass, or generated prose:
245
+ - **Canvas** (`tpl_dieline`) — the page IS the design, a FLOATING composer at the bottom that
246
+ morphs into `AgentProgress` while running; the centre goes empty → processing → result, and a
247
+ prompt re-flows the design in place. For a design/document the user shapes over time.
248
+ - **Chat** (`tpl_assistant`) — a conversation where the agent's answer carries suggested edits the
249
+ user approves/rejects **1-by-1** (`ChangeReview` per-item), applying only the accepted set. An AI
250
+ copilot that proposes changes to a record and never commits on its own.
251
+ - **One-shot** (`tpl_extract`) — document → stream → confirm each extracted field → submit. A
252
+ data-capture pass with no iteration.
253
+ - **Draft** (`tpl_draft`) — the agent writes a first draft from context; the OUTPUT itself streams
254
+ (the text fills in live), then becomes an editable surface the human refines before sending.
255
+ Tone/length steer it. For generated prose — a reply, a quote, a listing.
256
+
257
+ The AI accent is **violet** — distinct from the purpose accents (blue=pipeline, emerald=money), it
258
+ marks what came from the agent.
259
+
260
+ ---
261
+
201
262
  ## Composition grammar
202
263
 
203
264
  - **Canvas**: full-bleed `colors.zinc[50]` ScrollView; content column `maxWidth` 880–1040,
@@ -340,6 +401,12 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
340
401
  `tpl_timeline` (audit feed).
341
402
  - **Planning & time** — `tpl_calendar` · `tpl_attendance` · `tpl_shifts`.
342
403
  - **Administration** — `tpl_settings`.
404
+ - **AI workflows** — `tpl_dieline` (the design CANVAS: photo → stream → the dieline reveals at
405
+ centre, the floating composer morphing into `AgentProgress`; prompt to iterate) · `tpl_assistant`
406
+ (a chat copilot: the agent's answer carries suggested edits approved/rejected 1-by-1) ·
407
+ `tpl_extract` (one-shot extract-&-confirm: document → stream → confirm each field → submit) ·
408
+ `tpl_draft` (draft generation: context → the reply text streams into an editable surface →
409
+ refine → send; tone/length steer it).
343
410
 
344
411
  ---
345
412
 
@@ -354,8 +421,9 @@ status_badge · button · icon_button · link · link_button · pill_button · t
354
421
  picker · combobox · tag_input (TagInput — chip box + Add-popover; for tags, not Combobox multi) ·
355
422
  text_input_field · number_input · search_input · form_field · checkbox · checkbox_input · switch ·
356
423
  radio_picker · counter · range_slider · date_picker · date_range_filter_field · time_picker ·
357
- inline_text_input · inline_number_input · inline_select · inline_date_picker · inline_time_picker
358
- (the Inline* family — per-field editors on `inline_edit`'s `useInlineEdit` + `InlineEditView`) ·
424
+ inline_text_input · inline_number_input · inline_select · inline_member_select · inline_date_picker ·
425
+ inline_time_picker (the Inline* family — per-field editors on `inline_edit`'s `useInlineEdit` +
426
+ `InlineEditView`; `InlineSelect`/`InlineMemberSelect` take `renderValue` for a chip/badge at rest) ·
359
427
  list · list_item · menu_button · menu_list_item · detail_row · pressable_row · action_menu ·
360
428
  floating_action_bar · filter_pill · sort_header · table · pagination · accordion · stepper ·
361
429
  step_progress · step_list · timeline · drawer (+ DrawerFooter) · dialog · popover · tooltip ·
@@ -366,5 +434,10 @@ status_grid (StatusGrid + StatusLegend) · heatmap · legend_item · remainder_m
366
434
  scan_field · file_dropzone · file_thumbnail · file_thumbnail_grid · file_preview ·
367
435
  file_gallery_modal · image_gallery · avatar · skeleton · activity_indicator · loading · divider ·
368
436
  spacer · stack · section_card · page_header · page_content · calendar (calendar/index.ts) · gantt ·
369
- comments_thread · format_money · format_date · colors (solid · tint · ramp · ColorName ·
437
+ comments_thread · agent_run (live streaming work feed) · agent_progress (its compact floating
438
+ expandable form — a composer's working state) · prompt_field (the NL command surface + optional
439
+ `onAttach`, not a chat) · suggestion (review-for-approval) · confidence (calibrated high/med/low) ·
440
+ assisted_field (confirmable AI estimate) · change_review (before→after — whole-set OR 1-by-1) ·
441
+ clarify (the agent asks back) · sources (provenance chips) ·
442
+ format_money · format_date · colors (solid · tint · ramp · ColorName ·
370
443
  isColorName · asColorName — coerce a stored option/status token to a ColorName, neutral fallback).
@@ -0,0 +1,141 @@
1
+ import { useRef, useState } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { colors, solid, tint } from "@lotics/ui/colors";
4
+ import { Text } from "@lotics/ui/text";
5
+ import { Icon } from "@lotics/ui/icon";
6
+ import { DotsIndicator } from "@lotics/ui/dots_indicator";
7
+ import { PromptField } from "@lotics/ui/prompt_field";
8
+ import { ChangeReview, type ChangeReviewItem, type ChangeReviewItemStatus } from "@lotics/ui/change_review";
9
+
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+ // Template · AI assistant — a CHAT workflow, a different shape from the design
12
+ // canvas: a conversation where the agent's answer carries SUGGESTED EDITS the
13
+ // human approves or rejects ONE BY ONE (a before→after diff per change, ✓/✕),
14
+ // then applies only the accepted set. The review-each-edit pattern — an AI
15
+ // copilot proposing changes to a record, never committing on its own. All mock.
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+
18
+ interface ChatMsg {
19
+ id: string;
20
+ role: "user" | "agent";
21
+ text?: string;
22
+ /** An agent turn may carry suggested edits to review 1-by-1. */
23
+ changes?: ChangeReviewItem[];
24
+ applied?: boolean;
25
+ typing?: boolean;
26
+ }
27
+
28
+ const SUGGESTED: Omit<ChangeReviewItem, "status">[] = [
29
+ { label: "Company", before: "acme packaging co", after: "Acme Packaging Co." },
30
+ { label: "Phone", before: "0901234567", after: "+84 90 123 4567" },
31
+ { label: "Email", before: "SALES@ACME.VN", after: "sales@acme.vn" },
32
+ { label: "Industry", after: "Packaging" },
33
+ ];
34
+
35
+ export function TplAssistant() {
36
+ const idRef = useRef(1);
37
+ const nextId = () => `m${idRef.current++}`;
38
+ const [messages, setMessages] = useState<ChatMsg[]>([
39
+ { id: "m0", role: "agent", text: "Paste a record or ask me to tidy one up — I'll propose changes you can approve one by one." },
40
+ ]);
41
+ const [decisions, setDecisions] = useState<Record<string, ChangeReviewItemStatus>>({});
42
+ const [prompt, setPrompt] = useState("");
43
+ const [busy, setBusy] = useState(false);
44
+ const [proposed, setProposed] = useState(false);
45
+
46
+ const send = (text: string) => {
47
+ setPrompt("");
48
+ const userMsg: ChatMsg = { id: nextId(), role: "user", text };
49
+ const typingId = nextId();
50
+ setMessages((m) => [...m, userMsg, { id: typingId, role: "agent", typing: true }]);
51
+ setBusy(true);
52
+ setTimeout(() => {
53
+ setBusy(false);
54
+ setMessages((m) =>
55
+ m.map((msg) =>
56
+ msg.id === typingId
57
+ ? proposed
58
+ ? { id: msg.id, role: "agent", text: "Done — anything else to adjust?" }
59
+ : { id: msg.id, role: "agent", text: "Here's what I'd standardize on this contact. Review each:", changes: SUGGESTED.map((c) => ({ ...c, status: "pending" })) }
60
+ : msg,
61
+ ),
62
+ );
63
+ setProposed(true);
64
+ }, 1300);
65
+ };
66
+
67
+ const decide = (msgId: string, index: number, status: ChangeReviewItemStatus) => {
68
+ setDecisions((d) => ({ ...d, [`${msgId}:${index}`]: status }));
69
+ };
70
+
71
+ const applyMsg = (msgId: string) => {
72
+ const msg = messages.find((m) => m.id === msgId);
73
+ const n = (msg?.changes ?? []).filter((_, i) => decisions[`${msgId}:${i}`] === "accepted").length;
74
+ setMessages((m) => [
75
+ ...m.map((x) => (x.id === msgId ? { ...x, applied: true } : x)),
76
+ { id: nextId(), role: "agent", text: `Applied ${n} change${n === 1 ? "" : "s"} to the contact.` },
77
+ ]);
78
+ };
79
+
80
+ return (
81
+ <View style={{ flex: 1, backgroundColor: colors.zinc[50] }}>
82
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.zinc[200], backgroundColor: colors.background }}>
83
+ <View style={{ width: 24, height: 24, borderRadius: 7, alignItems: "center", justifyContent: "center", backgroundColor: tint("violet", 0.12) }}>
84
+ <Icon name="sparkles" size={13} color={solid("violet")} />
85
+ </View>
86
+ <Text size="sm" weight="semibold">Record assistant</Text>
87
+ </View>
88
+
89
+ <ScrollView contentContainerStyle={{ padding: 20, paddingBottom: 140, gap: 16, maxWidth: 760, width: "100%", alignSelf: "center" }}>
90
+ {messages.map((m) => {
91
+ if (m.role === "user") {
92
+ return (
93
+ <View key={m.id} style={{ alignItems: "flex-end" }}>
94
+ <View style={{ maxWidth: "82%", backgroundColor: colors.zinc[900], borderRadius: 14, paddingHorizontal: 14, paddingVertical: 10 }}>
95
+ <Text size="sm" color="inverted">{m.text}</Text>
96
+ </View>
97
+ </View>
98
+ );
99
+ }
100
+ const active = m.changes != null && !m.applied;
101
+ return (
102
+ <View key={m.id} style={{ flexDirection: "row", gap: 10, alignItems: "flex-start" }}>
103
+ <View style={{ width: 26, height: 26, borderRadius: 8, alignItems: "center", justifyContent: "center", backgroundColor: tint("violet", 0.12), marginTop: 1 }}>
104
+ <Icon name="sparkles" size={14} color={solid("violet")} />
105
+ </View>
106
+ <View style={{ flex: 1, gap: 10 }}>
107
+ {m.typing ? (
108
+ <View style={{ paddingVertical: 6 }}><DotsIndicator size={6} color={colors.zinc[400]} /></View>
109
+ ) : null}
110
+ {m.text ? <Text size="sm">{m.text}</Text> : null}
111
+ {m.changes ? (
112
+ <ChangeReview
113
+ changes={m.changes.map((c, i) => ({ ...c, status: decisions[`${m.id}:${i}`] ?? "pending" }))}
114
+ status={m.applied ? "applied" : "open"}
115
+ applyLabel="Apply accepted"
116
+ onAcceptItem={active ? (i) => decide(m.id, i, "accepted") : undefined}
117
+ onRejectItem={active ? (i) => decide(m.id, i, "rejected") : undefined}
118
+ onApply={active ? () => applyMsg(m.id) : undefined}
119
+ onDiscard={active ? () => setMessages((ms) => ms.map((x) => (x.id === m.id ? { ...x, applied: true } : x))) : undefined}
120
+ />
121
+ ) : null}
122
+ </View>
123
+ </View>
124
+ );
125
+ })}
126
+ </ScrollView>
127
+
128
+ <View style={{ position: "absolute", left: 0, right: 0, bottom: 20, alignItems: "center", paddingHorizontal: 16 }}>
129
+ <View style={{ width: "100%", maxWidth: 720 }}>
130
+ <PromptField
131
+ value={prompt}
132
+ onChangeText={setPrompt}
133
+ onSubmit={send}
134
+ busy={busy}
135
+ placeholder={proposed ? "Ask for another change…" : "Try: “Tidy up this contact”"}
136
+ />
137
+ </View>
138
+ </View>
139
+ </View>
140
+ );
141
+ }
@@ -0,0 +1,252 @@
1
+ import { useEffect, useState } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { colors, solid, tint } from "@lotics/ui/colors";
4
+ import { Text } from "@lotics/ui/text";
5
+ import { Button } from "@lotics/ui/button";
6
+ import { Badge } from "@lotics/ui/badge";
7
+ import { Icon } from "@lotics/ui/icon";
8
+ import { KPIStrip } from "@lotics/ui/kpi_strip";
9
+ import { DotsIndicator } from "@lotics/ui/dots_indicator";
10
+ import { AgentProgress } from "@lotics/ui/agent_progress";
11
+ import { PromptField } from "@lotics/ui/prompt_field";
12
+ import { type AgentRunStep } from "@lotics/ui/agent_run";
13
+
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+ // Template · AI design canvas (the carton "dieline" app) — the FLAGSHIP shape:
16
+ // the whole page is a canvas with the design at its centre and a FLOATING
17
+ // composer at the bottom. Empty → drop a photo in the composer → the centre
18
+ // shows a processing state while the composer MORPHS into a progress pill
19
+ // (AgentProgress — press it to watch the stream) → the dieline reveals and the
20
+ // composer returns → prompt to adjust, the design re-flows in place. The
21
+ // geometry is deterministic (the app owns it); the agent only proposes
22
+ // parameters. All mock — the recipe, not a CAD engine.
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+
25
+ type ScriptStep = Omit<AgentRunStep, "status">;
26
+
27
+ const RECOGNIZE: ScriptStep[] = [
28
+ { id: "r1", label: "Reading the photo", detail: "A single brown carton on a white sweep, card left for scale." },
29
+ { id: "r2", label: "reference_dielines.search", kind: "tool" },
30
+ { id: "r3", label: "Identifying the box style", detail: "Four flaps meet at the centre seam → Regular Slotted Container, FEFCO 0201." },
31
+ { id: "r4", label: "Estimating dimensions", detail: "Scaling against the reference card — about 320 × 230 × 150 mm." },
32
+ { id: "r5", label: "Drafting the dieline", kind: "tool" },
33
+ ];
34
+
35
+ const editScript = (prompt: string): ScriptStep[] => [
36
+ { id: "e1", label: "Reading the request", detail: prompt },
37
+ { id: "e2", label: "Recomputing the dieline", kind: "tool" },
38
+ ];
39
+
40
+ export function TplDieline() {
41
+ const [phase, setPhase] = useState<"empty" | "working" | "done">("empty");
42
+ const [designReady, setDesignReady] = useState(false);
43
+ const [dims, setDims] = useState({ L: 320, W: 230, H: 150 });
44
+ const [prompt, setPrompt] = useState("");
45
+
46
+ // Streaming engine — reveal a run's steps over time, then fire onDone.
47
+ const [run, setRun] = useState<{ script: ScriptStep[]; onDone: () => void } | null>(null);
48
+ const [revealed, setRevealed] = useState(0);
49
+ useEffect(() => {
50
+ if (!run) return;
51
+ if (revealed >= run.script.length) {
52
+ const t = setTimeout(() => {
53
+ run.onDone();
54
+ setRun(null);
55
+ setRevealed(0);
56
+ }, 650);
57
+ return () => clearTimeout(t);
58
+ }
59
+ const t = setTimeout(() => setRevealed((r) => r + 1), revealed === 0 ? 60 : 780);
60
+ return () => clearTimeout(t);
61
+ }, [run, revealed]);
62
+
63
+ const liveSteps: AgentRunStep[] = run
64
+ ? run.script.slice(0, revealed).map((s, i) => ({
65
+ ...s,
66
+ status: revealed < run.script.length && i === revealed - 1 ? "running" : "done",
67
+ }))
68
+ : [];
69
+
70
+ const startRecognize = () => {
71
+ setPhase("working");
72
+ setRevealed(0);
73
+ setRun({
74
+ script: RECOGNIZE,
75
+ onDone: () => {
76
+ setDesignReady(true);
77
+ setPhase("done");
78
+ },
79
+ });
80
+ };
81
+
82
+ const submitPrompt = (text: string) => {
83
+ setPrompt("");
84
+ if (!designReady) {
85
+ startRecognize();
86
+ return;
87
+ }
88
+ setPhase("working");
89
+ setRevealed(0);
90
+ setRun({
91
+ script: editScript(text),
92
+ onDone: () => {
93
+ const m = /(\d+)\s*mm/i.exec(text);
94
+ const delta = m ? Number(m[1]) : 5;
95
+ setDims((d) => ({ ...d, H: /short|less|low/i.test(text) ? Math.max(20, d.H - delta) : d.H + delta }));
96
+ setPhase("done");
97
+ },
98
+ });
99
+ };
100
+
101
+ const newSession = () => {
102
+ setRun(null);
103
+ setRevealed(0);
104
+ setDesignReady(false);
105
+ setDims({ L: 320, W: 230, H: 150 });
106
+ setPhase("empty");
107
+ };
108
+
109
+ return (
110
+ <View style={{ flex: 1, backgroundColor: colors.zinc[50] }}>
111
+ {/* slim top bar */}
112
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.zinc[200], backgroundColor: colors.background }}>
113
+ <Text size="sm" weight="semibold" style={{ flex: 1 }}>Carton design</Text>
114
+ {designReady ? <Badge color="violet" variant="dot" label="FEFCO 0201 · AI-assisted" /> : null}
115
+ {phase !== "empty" ? <Button title="New" color="muted" shape="rounded" icon="rotate-ccw" onPress={newSession} /> : null}
116
+ </View>
117
+
118
+ {/* canvas — the design lives at the centre */}
119
+ <ScrollView contentContainerStyle={{ flexGrow: 1, alignItems: "center", justifyContent: "center", padding: 32, paddingBottom: 180 }}>
120
+ {phase === "empty" ? (
121
+ <EmptyFrame />
122
+ ) : phase === "working" && !designReady ? (
123
+ <BuildingFrame />
124
+ ) : (
125
+ <View style={{ gap: 16, alignItems: "center", opacity: phase === "working" ? 0.45 : 1 }}>
126
+ <DielinePreview L={dims.L} W={dims.W} H={dims.H} />
127
+ <View style={{ width: 540, maxWidth: "100%" }}>
128
+ <KPIStrip
129
+ items={[
130
+ { label: "Blank length", value: `${2 * (dims.L + dims.W) + 40} mm` },
131
+ { label: "Blank width", value: `${dims.H + dims.W} mm` },
132
+ { label: "Board", value: "B-flute" },
133
+ { label: "Height", value: `${dims.H} mm` },
134
+ ]}
135
+ />
136
+ </View>
137
+ <View style={{ flexDirection: "row", gap: 8 }}>
138
+ <Button title="Download DXF" color="secondary" shape="rounded" icon="file-down" onPress={() => {}} />
139
+ <Button title="Download PDF" color="secondary" shape="rounded" icon="file-down" onPress={() => {}} />
140
+ </View>
141
+ </View>
142
+ )}
143
+ </ScrollView>
144
+
145
+ {/* floating composer — morphs into the progress pill while the agent runs */}
146
+ <View style={{ position: "absolute", left: 0, right: 0, bottom: 24, alignItems: "center", paddingHorizontal: 16 }}>
147
+ <View style={{ width: "100%", maxWidth: 620 }}>
148
+ {phase === "working" ? (
149
+ <AgentProgress steps={liveSteps} state="streaming" />
150
+ ) : (
151
+ <PromptField
152
+ value={prompt}
153
+ onChangeText={setPrompt}
154
+ onSubmit={submitPrompt}
155
+ onAttach={startRecognize}
156
+ placeholder={designReady ? "Describe a change — “make it 5 mm taller”" : "Attach a photo of the carton to start…"}
157
+ />
158
+ )}
159
+ </View>
160
+ </View>
161
+ </View>
162
+ );
163
+ }
164
+
165
+ function EmptyFrame() {
166
+ return (
167
+ <View style={{ width: 540, maxWidth: "100%", height: 300, borderRadius: 16, borderWidth: 1.5, borderColor: colors.zinc[300], borderStyle: "dashed", backgroundColor: colors.background, alignItems: "center", justifyContent: "center", gap: 12 }}>
168
+ <View style={{ width: 44, height: 44, borderRadius: 12, alignItems: "center", justifyContent: "center", backgroundColor: tint("violet", 0.1) }}>
169
+ <Icon name="image" size={22} color={solid("violet")} />
170
+ </View>
171
+ <View style={{ alignItems: "center", gap: 2 }}>
172
+ <Text size="md" weight="medium">Your dieline appears here</Text>
173
+ <Text size="sm" color="muted">Attach a photo of the carton in the composer below.</Text>
174
+ </View>
175
+ </View>
176
+ );
177
+ }
178
+
179
+ function BuildingFrame() {
180
+ return (
181
+ <View style={{ width: 540, maxWidth: "100%", height: 300, borderRadius: 16, borderWidth: 1.5, borderColor: tint("violet", 0.4), borderStyle: "dashed", backgroundColor: tint("violet", 0.03), alignItems: "center", justifyContent: "center", gap: 14 }}>
182
+ <DotsIndicator size={9} color={solid("violet")} />
183
+ <Text size="sm" color="muted">Designing your dieline…</Text>
184
+ </View>
185
+ );
186
+ }
187
+
188
+ // A schematic RSC dieline, View-based, scaled from the dimensions. Solid edges =
189
+ // cut; dashed = crease. Re-flows whenever a dimension changes.
190
+ function DielinePreview({ L, W, H }: { L: number; W: number; H: number }) {
191
+ const TAB = 40;
192
+ const blankW = 2 * (L + W) + TAB;
193
+ const flap = W / 2;
194
+ const scale = 520 / blankW;
195
+ const px = (mm: number) => Math.max(2, mm * scale);
196
+ const walls = [
197
+ { w: W, label: "Side" },
198
+ { w: L, label: "Front" },
199
+ { w: W, label: "Side" },
200
+ { w: L, label: "Back" },
201
+ ];
202
+ const flapH = px(flap);
203
+ const wallH = px(H);
204
+ const crease = { borderColor: colors.zinc[300], borderStyle: "dashed" as const };
205
+
206
+ return (
207
+ <View style={{ alignItems: "flex-start", gap: 10 }}>
208
+ <View style={{ borderWidth: 1.5, borderColor: colors.zinc[500], backgroundColor: colors.background }}>
209
+ <View style={{ flexDirection: "row", height: flapH }}>
210
+ {walls.map((p, i) => (
211
+ <View key={`tf-${i}`} style={{ width: px(p.w), borderLeftWidth: i === 0 ? 0 : 1, ...crease, alignItems: "center", justifyContent: "center", backgroundColor: tint("violet", 0.04) }}>
212
+ <Text size="xs" color="muted">flap</Text>
213
+ </View>
214
+ ))}
215
+ <View style={{ width: px(TAB) }} />
216
+ </View>
217
+ <View style={{ flexDirection: "row", height: wallH, borderTopWidth: 1, borderBottomWidth: 1, ...crease }}>
218
+ {walls.map((p, i) => (
219
+ <View key={`w-${i}`} style={{ width: px(p.w), borderLeftWidth: i === 0 ? 0 : 1, ...crease, alignItems: "center", justifyContent: "center" }}>
220
+ <Text size="xs" weight="medium">{p.label}</Text>
221
+ <Text size="xs" color="muted" tabular>{p.w} mm</Text>
222
+ </View>
223
+ ))}
224
+ <View style={{ width: px(TAB), borderLeftWidth: 1, ...crease, alignItems: "center", justifyContent: "center", backgroundColor: colors.zinc[100] }}>
225
+ <Text size="xs" color="muted">glue</Text>
226
+ </View>
227
+ </View>
228
+ <View style={{ flexDirection: "row", height: flapH }}>
229
+ {walls.map((p, i) => (
230
+ <View key={`bf-${i}`} style={{ width: px(p.w), borderLeftWidth: i === 0 ? 0 : 1, ...crease, alignItems: "center", justifyContent: "center", backgroundColor: tint("violet", 0.04) }}>
231
+ <Text size="xs" color="muted">flap</Text>
232
+ </View>
233
+ ))}
234
+ <View style={{ width: px(TAB) }} />
235
+ </View>
236
+ </View>
237
+ <View style={{ flexDirection: "row", gap: 16 }}>
238
+ <Legend dashed label="Crease (score)" />
239
+ <Legend label="Cut" />
240
+ </View>
241
+ </View>
242
+ );
243
+ }
244
+
245
+ function Legend({ dashed, label }: { dashed?: boolean; label: string }) {
246
+ return (
247
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
248
+ <View style={{ width: 18, borderTopWidth: 1.5, borderColor: dashed ? colors.zinc[300] : colors.zinc[500], borderStyle: dashed ? "dashed" : "solid" }} />
249
+ <Text size="xs" color="muted">{label}</Text>
250
+ </View>
251
+ );
252
+ }