@lotics/ui 4.4.0 → 4.5.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.
@@ -1,28 +1,31 @@
1
- import { useEffect, useState } from "react";
2
- import { ScrollView, View } from "react-native";
3
- import { colors, solid, tint } from "@lotics/ui/colors";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { PanResponder, StyleSheet, View, type ViewStyle } from "react-native";
3
+ import { colors } from "@lotics/ui/colors";
4
4
  import { Text } from "@lotics/ui/text";
5
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";
6
+ import { IconButton } from "@lotics/ui/icon_button";
7
+ import { PressableHighlight } from "@lotics/ui/pressable_highlight";
9
8
  import { DotsIndicator } from "@lotics/ui/dots_indicator";
9
+ import { InlineTextInput } from "@lotics/ui/inline_text_input";
10
10
  import { AgentProgress } from "@lotics/ui/agent_progress";
11
11
  import { PromptField } from "@lotics/ui/prompt_field";
12
12
  import { type AgentRunStep } from "@lotics/ui/agent_run";
13
13
 
14
14
  // ─────────────────────────────────────────────────────────────────────────────
15
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.
16
+ // the page IS the design. The dieline fills the centre, a FLOATING composer sits
17
+ // at the bottom, and a pinned PARAMS PANEL (the RecordReview field-card, here in
18
+ // live-edit mode) floats at the centre-right. Empty drop a photo in the
19
+ // composer the centre processes while the composer MORPHS into a progress pill
20
+ // (AgentProgress)the dieline reveals, the params panel appears, the composer
21
+ // returns. Two ways to change it: prompt the agent ("5 mm taller"), or edit a
22
+ // param directlyeither re-flows the design in place. The geometry is
23
+ // deterministic (the app owns it); the agent only proposes parameters. The panel
24
+ // carries the single Download. All mock — the recipe, not a CAD engine.
23
25
  // ─────────────────────────────────────────────────────────────────────────────
24
26
 
25
27
  type ScriptStep = Omit<AgentRunStep, "status">;
28
+ type Dims = { L: number; W: number; H: number };
26
29
 
27
30
  const RECOGNIZE: ScriptStep[] = [
28
31
  { id: "r1", label: "Reading the photo", detail: "A single brown carton on a white sweep, card left for scale." },
@@ -37,12 +40,43 @@ const editScript = (prompt: string): ScriptStep[] => [
37
40
  { id: "e2", label: "Recomputing the dieline", kind: "tool" },
38
41
  ];
39
42
 
43
+ const ZOOM_MIN = 0.4;
44
+ const ZOOM_MAX = 2.5;
45
+ const ZOOM_STEP = 0.25;
46
+
47
+ // `grab`/`grabbing` are web-only cursors outside RN's typed `CursorValue` union,
48
+ // so this needs a boundary cast (the kit casts `cursor` the same way).
49
+ const grabCursor = (grabbing: boolean): ViewStyle => ({ cursor: grabbing ? "grabbing" : "grab" } as unknown as ViewStyle);
50
+
40
51
  export function TplDieline() {
41
52
  const [phase, setPhase] = useState<"empty" | "working" | "done">("empty");
42
53
  const [designReady, setDesignReady] = useState(false);
43
- const [dims, setDims] = useState({ L: 320, W: 230, H: 150 });
54
+ const [dims, setDims] = useState<Dims>({ L: 320, W: 230, H: 150 });
55
+ const [zoom, setZoom] = useState(1);
56
+ const [pan, setPan] = useState({ x: 0, y: 0 });
57
+ const [dragging, setDragging] = useState(false);
44
58
  const [prompt, setPrompt] = useState("");
45
59
 
60
+ // Drag-to-pan — like a design tool: grab anywhere on the canvas and move it.
61
+ // The committed pan is read at grant via a ref (the responder is built once).
62
+ const panRef = useRef(pan);
63
+ panRef.current = pan;
64
+ const grantRef = useRef({ x: 0, y: 0 });
65
+ const panResponder = useMemo(
66
+ () =>
67
+ PanResponder.create({
68
+ onMoveShouldSetPanResponder: (_e, g) => Math.abs(g.dx) > 3 || Math.abs(g.dy) > 3,
69
+ onPanResponderGrant: () => {
70
+ grantRef.current = panRef.current;
71
+ setDragging(true);
72
+ },
73
+ onPanResponderMove: (_e, g) => setPan({ x: grantRef.current.x + g.dx, y: grantRef.current.y + g.dy }),
74
+ onPanResponderRelease: () => setDragging(false),
75
+ onPanResponderTerminate: () => setDragging(false),
76
+ }),
77
+ [],
78
+ );
79
+
46
80
  // Streaming engine — reveal a run's steps over time, then fire onDone.
47
81
  const [run, setRun] = useState<{ script: ScriptStep[]; onDone: () => void } | null>(null);
48
82
  const [revealed, setRevealed] = useState(0);
@@ -98,53 +132,68 @@ export function TplDieline() {
98
132
  });
99
133
  };
100
134
 
135
+ const editParam = (key: keyof Dims, value: number) => setDims((d) => ({ ...d, [key]: value }));
136
+
101
137
  const newSession = () => {
102
138
  setRun(null);
103
139
  setRevealed(0);
104
140
  setDesignReady(false);
105
141
  setDims({ L: 320, W: 230, H: 150 });
142
+ resetView();
106
143
  setPhase("empty");
107
144
  };
108
145
 
146
+ const zoomBy = (d: number) => setZoom((z) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round((z + d) * 100) / 100)));
147
+ const resetView = () => {
148
+ setZoom(1);
149
+ setPan({ x: 0, y: 0 });
150
+ };
151
+
109
152
  return (
110
153
  <View style={{ flex: 1, backgroundColor: colors.zinc[50] }}>
111
154
  {/* slim top bar */}
112
155
  <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.zinc[200], backgroundColor: colors.background }}>
113
156
  <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}
157
+ {phase !== "empty" ? <Button title="New" color="secondary" shape="rounded" icon="plus" onPress={newSession} /> : null}
116
158
  </View>
117
159
 
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
- />
160
+ {/* canvas region a pannable + zoomable surface (DRAG to pan, the zoom pill
161
+ to scale). The design sits CENTRED at rest and zoom scales it about that
162
+ centre; panel, zoom pill and composer float ON TOP, never shifting it. */}
163
+ <View
164
+ style={[{ flex: 1, overflow: "hidden" }, designReady ? grabCursor(dragging) : null]}
165
+ {...(designReady ? panResponder.panHandlers : {})}
166
+ >
167
+ <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 48 }}>
168
+ {phase === "empty" ? (
169
+ <EmptyFrame />
170
+ ) : phase === "working" && !designReady ? (
171
+ <BuildingFrame />
172
+ ) : (
173
+ <View style={{ opacity: phase === "working" ? 0.45 : 1, transform: [{ translateX: pan.x }, { translateY: pan.y }, { scale: zoom }] }}>
174
+ <DielinePreview L={dims.L} W={dims.W} H={dims.H} />
175
+ </View>
176
+ )}
177
+ </View>
178
+
179
+ {designReady ? (
180
+ <>
181
+ <View style={[StyleSheet.absoluteFill, { pointerEvents: "none", alignItems: "flex-end", justifyContent: "center", paddingRight: 24 }]}>
182
+ <View style={{ pointerEvents: "auto" }}>
183
+ <ParamsPanel dims={dims} onEditParam={editParam} onDownload={() => {}} />
184
+ </View>
136
185
  </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={() => {}} />
186
+ <View style={{ position: "absolute", left: 24, bottom: 24 }}>
187
+ <ZoomBar zoom={zoom} onZoomOut={() => zoomBy(-ZOOM_STEP)} onZoomIn={() => zoomBy(ZOOM_STEP)} onReset={resetView} />
140
188
  </View>
141
- </View>
142
- )}
143
- </ScrollView>
189
+ </>
190
+ ) : null}
191
+ </View>
144
192
 
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 }}>
193
+ {/* floating composer — morphs into the progress pill while the agent runs.
194
+ box-none so its full-width band doesn't intercept the zoom bar / canvas. */}
195
+ <View style={{ position: "absolute", left: 0, right: 0, bottom: 24, alignItems: "center", paddingHorizontal: 16, pointerEvents: "none" }}>
196
+ <View style={{ width: "100%", maxWidth: 620, pointerEvents: "auto" }}>
148
197
  {phase === "working" ? (
149
198
  <AgentProgress steps={liveSteps} state="streaming" />
150
199
  ) : (
@@ -162,12 +211,108 @@ export function TplDieline() {
162
211
  );
163
212
  }
164
213
 
165
- function EmptyFrame() {
214
+ // The pinned right-rail — the RecordReview field-card in live-edit mode: each
215
+ // parameter is click-to-edit and re-flows the design on save; one primary
216
+ // Download. Floats over the canvas, so it carries the composer's lift.
217
+ const PARAMS: { key: keyof Dims; label: string }[] = [
218
+ { key: "L", label: "Length" },
219
+ { key: "W", label: "Width" },
220
+ { key: "H", label: "Height" },
221
+ ];
222
+
223
+ function ParamsPanel({ dims, onEditParam, onDownload }: { dims: Dims; onEditParam: (key: keyof Dims, value: number) => void; onDownload: () => void }) {
224
+ const [open, setOpen] = useState(true);
166
225
  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")} />
226
+ <View style={[panel.card, open ? panel.cardOpen : panel.cardMin]}>
227
+ <View style={panel.header}>
228
+ <View style={{ flex: 1, gap: 1 }}>
229
+ <Text size="sm" weight="semibold">Parameters</Text>
230
+ {open ? <Text size="xs" color="muted">FEFCO 0201 · B-flute</Text> : null}
231
+ </View>
232
+ <IconButton icon={open ? "minus" : "plus"} accessibilityLabel={open ? "Minimize parameters" : "Expand parameters"} onPress={() => setOpen((o) => !o)} />
170
233
  </View>
234
+
235
+ {open ? (
236
+ <>
237
+ <View style={panel.fields}>
238
+ {PARAMS.map((p) => (
239
+ <View key={p.key} style={panel.row}>
240
+ <Text size="sm" color="muted" style={panel.label}>{p.label}</Text>
241
+ <View style={panel.value}>
242
+ <View style={{ flex: 1 }}>
243
+ <InlineTextInput
244
+ value={String(dims[p.key])}
245
+ accessibilityLabel={`Edit ${p.label}`}
246
+ onSave={(next) => {
247
+ const n = Math.round(Number(next.replace(/[^\d.]/g, "")));
248
+ if (Number.isFinite(n) && n > 0) onEditParam(p.key, n);
249
+ }}
250
+ />
251
+ </View>
252
+ <Text size="sm" color="muted">mm</Text>
253
+ </View>
254
+ </View>
255
+ ))}
256
+ </View>
257
+
258
+ <Button title="Download" color="primary" shape="rounded" icon="file-down" alignSelf="stretch" onPress={onDownload} />
259
+ </>
260
+ ) : null}
261
+ </View>
262
+ );
263
+ }
264
+
265
+ const panel = StyleSheet.create({
266
+ card: {
267
+ borderWidth: 1,
268
+ borderColor: colors.border,
269
+ backgroundColor: colors.white,
270
+ borderRadius: 12,
271
+ padding: 16,
272
+ gap: 14,
273
+ boxShadow: "0 4px 14px rgba(24,24,27,0.08)",
274
+ },
275
+ cardOpen: { width: 264 },
276
+ cardMin: { paddingVertical: 8, paddingHorizontal: 12 },
277
+ header: { flexDirection: "row", alignItems: "center", gap: 10 },
278
+ fields: { gap: 2 },
279
+ row: { flexDirection: "row", alignItems: "center", gap: 12, minHeight: 34 },
280
+ label: { width: 70 },
281
+ value: { flex: 1, flexDirection: "row", alignItems: "center", gap: 6 },
282
+ });
283
+
284
+ // The canvas zoom control — out / percentage (tap to reset) / in, in a floating
285
+ // pill that carries the same lift as the composer and params panel.
286
+ function ZoomBar({ zoom, onZoomOut, onZoomIn, onReset }: { zoom: number; onZoomOut: () => void; onZoomIn: () => void; onReset: () => void }) {
287
+ return (
288
+ <View style={zoombar.bar}>
289
+ <IconButton icon="minus" accessibilityLabel="Zoom out" onPress={onZoomOut} />
290
+ <PressableHighlight accessibilityRole="button" accessibilityLabel="Reset zoom to 100%" onPress={onReset} style={zoombar.pct}>
291
+ <Text size="xs" weight="medium" tabular>{Math.round(zoom * 100)}%</Text>
292
+ </PressableHighlight>
293
+ <IconButton icon="plus" accessibilityLabel="Zoom in" onPress={onZoomIn} />
294
+ </View>
295
+ );
296
+ }
297
+
298
+ const zoombar = StyleSheet.create({
299
+ bar: {
300
+ flexDirection: "row",
301
+ alignItems: "center",
302
+ gap: 2,
303
+ padding: 4,
304
+ borderWidth: 1,
305
+ borderColor: colors.border,
306
+ backgroundColor: colors.white,
307
+ borderRadius: 12,
308
+ boxShadow: "0 4px 14px rgba(24,24,27,0.08)",
309
+ },
310
+ pct: { minWidth: 50, height: 28, alignItems: "center", justifyContent: "center", borderRadius: 8, paddingHorizontal: 6 },
311
+ });
312
+
313
+ function EmptyFrame() {
314
+ return (
315
+ <View style={frame.box}>
171
316
  <View style={{ alignItems: "center", gap: 2 }}>
172
317
  <Text size="md" weight="medium">Your dieline appears here</Text>
173
318
  <Text size="sm" color="muted">Attach a photo of the carton in the composer below.</Text>
@@ -178,20 +323,38 @@ function EmptyFrame() {
178
323
 
179
324
  function BuildingFrame() {
180
325
  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")} />
326
+ <View style={[frame.box, { backgroundColor: colors.zinc[50] }]}>
327
+ <DotsIndicator size={9} color={colors.zinc[900]} />
183
328
  <Text size="sm" color="muted">Designing your dieline…</Text>
184
329
  </View>
185
330
  );
186
331
  }
187
332
 
333
+ const frame = StyleSheet.create({
334
+ box: {
335
+ width: 640,
336
+ maxWidth: "100%",
337
+ height: 360,
338
+ borderRadius: 16,
339
+ borderWidth: 1.5,
340
+ borderColor: colors.zinc[300],
341
+ borderStyle: "dashed",
342
+ backgroundColor: colors.background,
343
+ alignItems: "center",
344
+ justifyContent: "center",
345
+ gap: 12,
346
+ },
347
+ });
348
+
188
349
  // 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 }) {
350
+ // cut; dashed = crease. Re-flows whenever a dimension changes. Sized to fill the
351
+ // canvas (TARGET wide), so the design reads as the hero.
352
+ const TARGET = 780;
353
+ function DielinePreview({ L, W, H }: Dims) {
191
354
  const TAB = 40;
192
355
  const blankW = 2 * (L + W) + TAB;
193
356
  const flap = W / 2;
194
- const scale = 520 / blankW;
357
+ const scale = TARGET / blankW;
195
358
  const px = (mm: number) => Math.max(2, mm * scale);
196
359
  const walls = [
197
360
  { w: W, label: "Side" },
@@ -204,11 +367,11 @@ function DielinePreview({ L, W, H }: { L: number; W: number; H: number }) {
204
367
  const crease = { borderColor: colors.zinc[300], borderStyle: "dashed" as const };
205
368
 
206
369
  return (
207
- <View style={{ alignItems: "flex-start", gap: 10 }}>
370
+ <View style={{ alignItems: "flex-start", gap: 12 }}>
208
371
  <View style={{ borderWidth: 1.5, borderColor: colors.zinc[500], backgroundColor: colors.background }}>
209
372
  <View style={{ flexDirection: "row", height: flapH }}>
210
373
  {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) }}>
374
+ <View key={`tf-${i}`} style={{ width: px(p.w), borderLeftWidth: i === 0 ? 0 : 1, ...crease, alignItems: "center", justifyContent: "center", backgroundColor: colors.zinc[100] }}>
212
375
  <Text size="xs" color="muted">flap</Text>
213
376
  </View>
214
377
  ))}
@@ -227,7 +390,7 @@ function DielinePreview({ L, W, H }: { L: number; W: number; H: number }) {
227
390
  </View>
228
391
  <View style={{ flexDirection: "row", height: flapH }}>
229
392
  {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) }}>
393
+ <View key={`bf-${i}`} style={{ width: px(p.w), borderLeftWidth: i === 0 ? 0 : 1, ...crease, alignItems: "center", justifyContent: "center", backgroundColor: colors.zinc[100] }}>
231
394
  <Text size="xs" color="muted">flap</Text>
232
395
  </View>
233
396
  ))}
@@ -1,9 +1,8 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { ScrollView, View } from "react-native";
3
- import { colors, solid, tint } from "@lotics/ui/colors";
3
+ import { colors } from "@lotics/ui/colors";
4
4
  import { Text } from "@lotics/ui/text";
5
5
  import { Button } from "@lotics/ui/button";
6
- import { Icon } from "@lotics/ui/icon";
7
6
  import { Card, CardBody, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
8
7
  import { SegmentedControl } from "@lotics/ui/segmented_control";
9
8
  import { TextInputField } from "@lotics/ui/text_input_field";
@@ -107,7 +106,7 @@ export function TplDraft() {
107
106
  <CardBody>
108
107
  <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 16 }}>
109
108
  <View style={{ gap: 6 }}>
110
- <Text size="xs" color="muted" transform="uppercase">Tone</Text>
109
+ <Text size="xs" color="muted" weight="medium">Tone</Text>
111
110
  <SegmentedControl
112
111
  accessibilityLabel="Tone"
113
112
  options={[{ label: "Friendly", value: "friendly" }, { label: "Formal", value: "formal" }]}
@@ -117,7 +116,7 @@ export function TplDraft() {
117
116
  />
118
117
  </View>
119
118
  <View style={{ gap: 6 }}>
120
- <Text size="xs" color="muted" transform="uppercase">Length</Text>
119
+ <Text size="xs" color="muted" weight="medium">Length</Text>
121
120
  <SegmentedControl
122
121
  accessibilityLabel="Length"
123
122
  options={[{ label: "Short", value: "short" }, { label: "Detailed", value: "detailed" }]}
@@ -130,16 +129,15 @@ export function TplDraft() {
130
129
 
131
130
  {phase === "idle" ? (
132
131
  <View style={{ alignItems: "flex-start", paddingTop: 4 }}>
133
- <Button title="Draft a reply" color="primary" shape="rounded" icon="sparkles" onPress={startDraft} />
132
+ <Button title="Draft a reply" color="primary" shape="rounded" onPress={startDraft} />
134
133
  </View>
135
134
  ) : null}
136
135
 
137
136
  {phase === "drafting" ? (
138
- <View style={{ borderWidth: 1, borderColor: tint("violet", 0.35), borderRadius: 10, padding: 14, gap: 10, backgroundColor: tint("violet", 0.03) }}>
137
+ <View style={{ borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 10, padding: 14, gap: 10, backgroundColor: colors.zinc[50] }}>
139
138
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
140
- <Icon name="sparkles" size={13} color={solid("violet")} />
141
- <Text size="xs" color="muted" style={{ flex: 1 }}>Drafting…</Text>
142
- <DotsIndicator size={5} color={solid("violet")} />
139
+ <Text size="xs" color="muted" weight="medium" style={{ flex: 1 }}>Drafting</Text>
140
+ <DotsIndicator size={5} color={colors.zinc[900]} />
143
141
  </View>
144
142
  <Text size="sm">{streamed}</Text>
145
143
  </View>