@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.
package/AGENTS.md CHANGED
@@ -29,14 +29,29 @@ Pick by capability, not by name. (→ the source file for the API.)
29
29
  destination signal; the opposite of `LinkButton` (a quiet in-app action, never underlined).
30
30
  - **Pick from a list** — `Picker` (known options, native typeahead, single/multi/custom-render,
31
31
  no search box). Search-as-you-type / async / create-new → `Combobox`. A selectable card row →
32
- `CardSelectItem`.
32
+ `CardSelectItem`. For a SELECT-FIELD picker, render each option as its colored chip
33
+ (`renderOptionContent={(o) => <OptionBadge value={o} />}`).
34
+ - **Pick member(s)** — `MemberSelect` (a `Picker` that renders each option as a `MemberChip`,
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. To edit a `select_member`
37
+ field IN PLACE (chip at rest → picker on click) use its inline-edit twin `InlineMemberSelect`.
38
+ - **A person / member (display)** — `MemberChip` (avatar + name + optional secondary line) — the
39
+ ONE way to show a member inline: a picker option, an assignee, a `select_member` value. Pure:
40
+ resolve the member from your directory (`useMembers`) and pass `name` / `image`; never hand-roll
41
+ `Avatar` + `Text`. (The product's `MemberBadge` is just `memberId → MemberChip`; `MemberSelect`
42
+ renders these per option.)
43
+ - **A select-field value** — `OptionBadge` (a stored `select` value as its CONFIGURED colored
44
+ badge) — never hand-map option-key → color. Feed it a resolved option (`useFieldOptions` for a
45
+ picker option, or `byKey(readSelect(cell)[0]?.key)` for a stored value); multi-select wraps to
46
+ one badge each; a missing/unknown color token degrades to neutral.
33
47
  - **Tags / multi-select chip box** — `TagInput` (chips + an Add-popover checklist + create), NOT
34
48
  `Combobox multi` (see §Data entry).
35
49
  - **Text & form** — `TextInputField`, `NumberInput`, `SearchInput`; wrap with `FormField`;
36
50
  `Checkbox`, `Switch`, `RadioPicker`; dates via `DatePicker` / `DateRangeFilterField`, times
37
51
  via `TimePicker`.
38
52
  - **Edit a record's fields in place** — the `Inline*` family: `InlineTextInput` ·
39
- `InlineNumberInput` · `InlineSelect` · `InlineDatePicker` · `InlineTimePicker` (see §Data entry).
53
+ `InlineNumberInput` · `InlineSelect` · `InlineMemberSelect` · `InlineDatePicker` ·
54
+ `InlineTimePicker` (see §Data entry).
40
55
  - **Tabular data** — `Table` (columns defined once; sortable headers via `SortHeader`; paired
41
56
  with `Pagination`). Never an HTML `<table>` or a `.map` of rows.
42
57
  - **Numbers & charts** — `KPIStrip` / `KPICard` / `Metric` (headline figures), `TrendChip`
@@ -60,6 +75,12 @@ Pick by capability, not by name. (→ the source file for the API.)
60
75
  - **Specialized work surfaces** — `ScanField` (scan/verify), `StepList` (guided run),
61
76
  `RemainderMeter` + `AllocationRow` (allocation), `Timeline`, `Calendar`, `Gantt`,
62
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.
63
84
 
64
85
  ---
65
86
 
@@ -75,6 +96,7 @@ This is the most common thing to get right. Match the JOB to the pattern:
75
96
  | REPEATING rows you build & revise | **line items** (create→preview→edit) | add / edit / remove, live totals |
76
97
  | CHARGES that bill onto documents | **billing** (`tpl_billing`) | the invoice document is the unit |
77
98
  | a multi-value TAG field | **`TagInput`** | a chip box, not a search input |
99
+ | ONE choice from a small visible set | **`ChipGroup` pills** (or `RadioPicker`) | required single-select, one tap, every option visible |
78
100
  | a STATUS with terminal outcomes | **disposition** (open → resolve → revise) | guides the decision |
79
101
  | FILES | **attachment field** (dropzone + grid + gallery) | add / preview / delete |
80
102
  | many entries FAST | **quick capture** (one row, Enter to add) | repeat-entry speed |
@@ -87,7 +109,9 @@ hover tints it (no pencil — that shifts), click swaps the input in **at the sa
87
109
  reflow, the whole point), and it commits on blur (Enter saves, Escape reverts) or via
88
110
  `controls="buttons"` (✓ primary / ✕). One per type — `InlineTextInput` · `InlineNumberInput`
89
111
  (`format` for currency/units) · `InlineSelect` (plain options OR `renderOptionContent`; floats a
90
- `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
91
115
  on `useInlineEdit` + `InlineEditView` (custom inputs join via those). `onSave` is async: the
92
116
  saving spinner sits INSIDE the control (never a sibling — that reflows); an error shows inline
93
117
  without losing the edit. Pair with `DetailRow` (label left, inline value right). Not every field
@@ -184,6 +208,57 @@ the "this is a gate" framing.
184
208
 
185
209
  ---
186
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
+
187
262
  ## Composition grammar
188
263
 
189
264
  - **Canvas**: full-bleed `colors.zinc[50]` ScrollView; content column `maxWidth` 880–1040,
@@ -326,6 +401,12 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
326
401
  `tpl_timeline` (audit feed).
327
402
  - **Planning & time** — `tpl_calendar` · `tpl_attendance` · `tpl_shifts`.
328
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).
329
410
 
330
411
  ---
331
412
 
@@ -333,12 +414,16 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
333
414
 
334
415
  text · card (Card · CardHeader · CardHeaderTitle · CardHeaderMeta · CardBody · CardFooter) ·
335
416
  section_heading (SectionHeading · SectionHeadingTitle — compound, owns no margin) · badge ·
417
+ option_badge (OptionBadge — a select value as its configured colored badge) ·
418
+ member_chip (MemberChip — avatar + name; the universal person render) ·
419
+ member_select (MemberSelect — a Picker of MemberChip options; the member picker) ·
336
420
  status_badge · button · icon_button · link · link_button · pill_button · tabs · segmented_control ·
337
421
  picker · combobox · tag_input (TagInput — chip box + Add-popover; for tags, not Combobox multi) ·
338
422
  text_input_field · number_input · search_input · form_field · checkbox · checkbox_input · switch ·
339
423
  radio_picker · counter · range_slider · date_picker · date_range_filter_field · time_picker ·
340
- inline_text_input · inline_number_input · inline_select · inline_date_picker · inline_time_picker
341
- (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) ·
342
427
  list · list_item · menu_button · menu_list_item · detail_row · pressable_row · action_menu ·
343
428
  floating_action_bar · filter_pill · sort_header · table · pagination · accordion · stepper ·
344
429
  step_progress · step_list · timeline · drawer (+ DrawerFooter) · dialog · popover · tooltip ·
@@ -349,4 +434,10 @@ status_grid (StatusGrid + StatusLegend) · heatmap · legend_item · remainder_m
349
434
  scan_field · file_dropzone · file_thumbnail · file_thumbnail_grid · file_preview ·
350
435
  file_gallery_modal · image_gallery · avatar · skeleton · activity_indicator · loading · divider ·
351
436
  spacer · stack · section_card · page_header · page_content · calendar (calendar/index.ts) · gantt ·
352
- 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 ·
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
+ }