panelui-native 0.81.0 → 0.83.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.
Files changed (73) hide show
  1. package/lib/module/components/bottom-sheet/index.js +189 -21
  2. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  3. package/lib/module/components/button/index.js +13 -0
  4. package/lib/module/components/button/index.js.map +1 -1
  5. package/lib/module/components/button-group/index.js +3 -0
  6. package/lib/module/components/button-group/index.js.map +1 -1
  7. package/lib/module/components/image-generation/dot-field.js +267 -0
  8. package/lib/module/components/image-generation/dot-field.js.map +1 -0
  9. package/lib/module/components/image-generation/index.js +459 -0
  10. package/lib/module/components/image-generation/index.js.map +1 -0
  11. package/lib/module/components/kpi/index.js +2 -4
  12. package/lib/module/components/kpi/index.js.map +1 -1
  13. package/lib/module/components/menu/index.js +8 -16
  14. package/lib/module/components/menu/index.js.map +1 -1
  15. package/lib/module/components/panelside/index.js +340 -33
  16. package/lib/module/components/panelside/index.js.map +1 -1
  17. package/lib/module/components/select/index.js +127 -23
  18. package/lib/module/components/select/index.js.map +1 -1
  19. package/lib/module/components/slide-button/index.js +610 -0
  20. package/lib/module/components/slide-button/index.js.map +1 -0
  21. package/lib/module/components/slide-button/slide-button-track.js +98 -0
  22. package/lib/module/components/slide-button/slide-button-track.js.map +1 -0
  23. package/lib/module/icons/index.js +553 -1934
  24. package/lib/module/icons/index.js.map +1 -1
  25. package/lib/module/index.js +3 -1
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/module/native/index.js +24 -0
  28. package/lib/module/native/index.js.map +1 -1
  29. package/lib/module/primitives/portal.js +15 -1
  30. package/lib/module/primitives/portal.js.map +1 -1
  31. package/lib/typescript/src/components/bottom-sheet/index.d.ts +17 -1
  32. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/button/index.d.ts +12 -0
  34. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  35. package/lib/typescript/src/components/button-group/index.d.ts +9 -0
  36. package/lib/typescript/src/components/button-group/index.d.ts.map +1 -1
  37. package/lib/typescript/src/components/image-generation/dot-field.d.ts +96 -0
  38. package/lib/typescript/src/components/image-generation/dot-field.d.ts.map +1 -0
  39. package/lib/typescript/src/components/image-generation/index.d.ts +134 -0
  40. package/lib/typescript/src/components/image-generation/index.d.ts.map +1 -0
  41. package/lib/typescript/src/components/kpi/index.d.ts.map +1 -1
  42. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  43. package/lib/typescript/src/components/panelside/index.d.ts +194 -10
  44. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  45. package/lib/typescript/src/components/select/index.d.ts +75 -2
  46. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  47. package/lib/typescript/src/components/slide-button/index.d.ts +317 -0
  48. package/lib/typescript/src/components/slide-button/index.d.ts.map +1 -0
  49. package/lib/typescript/src/components/slide-button/slide-button-track.d.ts +70 -0
  50. package/lib/typescript/src/components/slide-button/slide-button-track.d.ts.map +1 -0
  51. package/lib/typescript/src/icons/index.d.ts +136 -189
  52. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  53. package/lib/typescript/src/index.d.ts +4 -2
  54. package/lib/typescript/src/index.d.ts.map +1 -1
  55. package/lib/typescript/src/native/index.d.ts +26 -0
  56. package/lib/typescript/src/native/index.d.ts.map +1 -1
  57. package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
  58. package/package.json +3 -2
  59. package/src/components/bottom-sheet/index.tsx +223 -20
  60. package/src/components/button/index.tsx +10 -2
  61. package/src/components/button-group/index.tsx +1 -0
  62. package/src/components/image-generation/dot-field.ts +282 -0
  63. package/src/components/image-generation/index.tsx +537 -0
  64. package/src/components/kpi/index.tsx +2 -4
  65. package/src/components/menu/index.tsx +10 -15
  66. package/src/components/panelside/index.tsx +498 -33
  67. package/src/components/select/index.tsx +158 -31
  68. package/src/components/slide-button/index.tsx +681 -0
  69. package/src/components/slide-button/slide-button-track.ts +100 -0
  70. package/src/icons/index.tsx +338 -1327
  71. package/src/index.ts +28 -0
  72. package/src/native/index.ts +45 -0
  73. package/src/primitives/portal.tsx +15 -1
@@ -0,0 +1,537 @@
1
+ /**
2
+ * ImageGeneration — the place an image will be, while it is being made.
3
+ *
4
+ * ```tsx
5
+ * <ImageGeneration status={status} prompt="a quiet mountain at sunset">
6
+ * <Image source={{ uri }} />
7
+ * </ImageGeneration>
8
+ * ```
9
+ *
10
+ * A generated image arrives seconds after it is asked for, and arrives at a
11
+ * size nobody knew in advance. Left to itself that is a screen that reflows
12
+ * under the reader's thumb at the moment they were about to tap something —
13
+ * so this reserves the box first, at the aspect ratio the image will have, and
14
+ * fills it with something to watch.
15
+ *
16
+ * ## The dot field
17
+ *
18
+ * A grid of dots with a soft light moving through it, drawn as three layers:
19
+ * the field at rest, and two copies of the lit dots fading into one another.
20
+ *
21
+ * Every picture the loop will ever draw is built when the box is measured, so
22
+ * running it hands over a string that already exists. That leaves twenty-four
23
+ * pictures to spread across the pass, which on its own is a flipbook — a new
24
+ * one every 175ms, however fast the screen refreshes. The two layers are what
25
+ * fills the gap: one holds the frame being left and one the frame being
26
+ * arrived at, and only their opacities move, which the compositor animates at
27
+ * the display's own rate without the drawing being touched.
28
+ *
29
+ * They are opacities on views rather than on the dots, and that is not a
30
+ * detail. A value that changes every frame sitting beside the path string
31
+ * pushes the path every frame too, and every push of a path is a re-parse of
32
+ * several hundred subpaths — which is the cost this whole arrangement exists
33
+ * to avoid. Kept apart, the path is pushed only when it actually changes.
34
+ *
35
+ * ## What arrives, and when
36
+ *
37
+ * `status` moves the picture through the work rather than switching it: the
38
+ * field fades as the image fades in, and for one step in the middle both are
39
+ * visible at once. That overlap is the point — an image that appears the
40
+ * instant the field vanishes has been swapped in, and one that surfaces
41
+ * through it has been developed.
42
+ *
43
+ * ## Reduced motion
44
+ *
45
+ * The light stops, and the field holds wherever the clock was left. A
46
+ * placeholder that shows nothing is indistinguishable from a component that
47
+ * failed to load, so this is a quieter picture rather than an empty one.
48
+ */
49
+ import { useEffect, useMemo, useState, type ReactNode } from 'react';
50
+ import {
51
+ Pressable,
52
+ StyleSheet,
53
+ View,
54
+ type LayoutChangeEvent,
55
+ type ViewProps,
56
+ } from 'react-native';
57
+ import Svg, { Path } from 'react-native-svg';
58
+ import Animated, {
59
+ Easing,
60
+ cancelAnimation,
61
+ useAnimatedProps,
62
+ useAnimatedStyle,
63
+ useFrameCallback,
64
+ useReducedMotion,
65
+ useSharedValue,
66
+ withRepeat,
67
+ withTiming,
68
+ type SharedValue,
69
+ } from 'react-native-reanimated';
70
+ import { tv } from 'tailwind-variants';
71
+ import { useCSSVariable } from 'uniwind';
72
+ import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from '../../icons';
73
+ import { Text } from '../../primitives/text';
74
+ import { cn } from '../../utils/cn';
75
+ import {
76
+ DOT_REST_OPACITY,
77
+ FRAMES,
78
+ crossfadeAlphas,
79
+ framePhase,
80
+ gridPath,
81
+ litFrames,
82
+ type DotFieldAnimation,
83
+ } from './dot-field';
84
+
85
+ /** How far the generation has got. */
86
+ export type ImageGenerationStatus =
87
+ | 'queued'
88
+ | 'generating'
89
+ | 'refining'
90
+ | 'complete'
91
+ | 'error';
92
+
93
+ /**
94
+ * The moment a held field is frozen at.
95
+ *
96
+ * Not zero: at zero the light sits dead centre, which reads as a target rather
97
+ * than as something passing through.
98
+ */
99
+ const STILL_FRAME = 900;
100
+
101
+ /** How visible the field is at each step of the work. */
102
+ const FIELD_OPACITY: Record<ImageGenerationStatus, number> = {
103
+ queued: 1,
104
+ generating: 1,
105
+ refining: 0.48,
106
+ complete: 0,
107
+ error: 0,
108
+ };
109
+
110
+ /**
111
+ * How visible the image is at each step.
112
+ *
113
+ * `refining` is the overlap: the field is still there at half strength and the
114
+ * image is already coming through it. Both fully on for one step is what makes
115
+ * the picture surface rather than get swapped in.
116
+ */
117
+ const MEDIA_OPACITY: Record<ImageGenerationStatus, number> = {
118
+ queued: 0,
119
+ generating: 0,
120
+ refining: 0.62,
121
+ complete: 1,
122
+ error: 0.28,
123
+ };
124
+
125
+ /**
126
+ * How much larger than final the image starts.
127
+ *
128
+ * Small, and it settles rather than zooms. A generated image is not arriving
129
+ * from anywhere; the scale is there to give the last of the fade somewhere to
130
+ * go, so it does not simply stop.
131
+ */
132
+ const MEDIA_SCALE: Record<ImageGenerationStatus, number> = {
133
+ queued: 1.02,
134
+ generating: 1.02,
135
+ refining: 1.008,
136
+ complete: 1,
137
+ error: 1,
138
+ };
139
+
140
+ const STATUS_TEXT: Record<ImageGenerationStatus, string> = {
141
+ queued: 'Waiting to generate',
142
+ generating: 'Creating image',
143
+ refining: 'Refining details',
144
+ complete: 'Image ready',
145
+ error: 'Generation failed',
146
+ };
147
+
148
+ const imageGenerationVariants = tv({
149
+ slots: {
150
+ root: 'w-full',
151
+ frame: 'relative w-full overflow-hidden rounded-2xl bg-muted',
152
+ media: 'absolute inset-0',
153
+ resolution:
154
+ 'absolute end-2 top-2 rounded-full bg-background/75 px-2 py-0.5 text-[10px] text-muted-foreground',
155
+ status: 'mt-3 flex-row items-center gap-2',
156
+ statusText: 'text-sm font-medium text-foreground',
157
+ prompt: 'mt-0.5 text-xs text-muted-foreground',
158
+ retry: 'mt-3 min-h-12 flex-row items-center gap-2 self-start rounded-full px-3 active:bg-accent',
159
+ retryLabel: 'text-sm font-medium text-foreground',
160
+ },
161
+ variants: {
162
+ size: {
163
+ /** Held to a thumbnail, centred — a result inside a conversation. */
164
+ compact: { root: 'max-w-52 self-center' },
165
+ /** As wide as it is given. */
166
+ fluid: {},
167
+ },
168
+ error: {
169
+ true: { statusText: 'text-destructive' },
170
+ },
171
+ },
172
+ defaultVariants: {
173
+ size: 'compact',
174
+ },
175
+ });
176
+
177
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
178
+
179
+ export interface ImageGenerationFieldProps extends Omit<ViewProps, 'children'> {
180
+ className?: string;
181
+ /** Holds the light still, at one representative frame. */
182
+ paused?: boolean;
183
+ /**
184
+ * How the light moves through the field.
185
+ *
186
+ * `drift` wanders around the middle; `pulse` is a ring leaving the centre;
187
+ * `scan` crosses as a band. All three cost the same.
188
+ */
189
+ animation?: DotFieldAnimation;
190
+ }
191
+
192
+ /**
193
+ * The dot field on its own, for a placeholder that is not an image.
194
+ *
195
+ * It fills its parent absolutely rather than sizing to its contents, because
196
+ * what it draws has no intrinsic height — laid out normally it measures to
197
+ * nothing and draws a single row of dots along the top.
198
+ *
199
+ * ## What it costs
200
+ *
201
+ * Two paths: the resting grid, and the lit dots over it. Both are built when
202
+ * the box is measured — every frame of the loop at once — so running it is
203
+ * handing over a string that already exists, and a page of these costs the
204
+ * same as a page of static drawings plus one property assignment each.
205
+ */
206
+ function ImageGenerationField({
207
+ className,
208
+ paused = false,
209
+ animation = 'drift',
210
+ style,
211
+ ...props
212
+ }: ImageGenerationFieldProps) {
213
+ const reducedMotion = useReducedMotion();
214
+ const [size, setSize] = useState({ width: 0, height: 0 });
215
+ const clock = useSharedValue(STILL_FRAME);
216
+
217
+ const tint = useCSSVariable('--color-muted-foreground');
218
+ const color = typeof tint === 'string' ? tint : '#737373';
219
+
220
+ const running = !paused && !reducedMotion;
221
+
222
+ const grid = useMemo(
223
+ () => gridPath(size.width, size.height),
224
+ [size.width, size.height]
225
+ );
226
+ const frames = useMemo(
227
+ () => litFrames(size.width, size.height, animation),
228
+ [animation, size.width, size.height]
229
+ );
230
+
231
+ const frame = useFrameCallback((info) => {
232
+ 'worklet';
233
+ // Accumulated rather than read off the total, so pausing and resuming does
234
+ // not jump the light to wherever the clock would have carried it. A dropped
235
+ // frame is clamped: a 300ms hitch played back whole is a lurch.
236
+ clock.value += Math.min(info.timeSincePreviousFrame ?? 16, 48);
237
+ }, false);
238
+
239
+ const { setActive } = frame;
240
+ useEffect(() => {
241
+ setActive(running);
242
+ return () => setActive(false);
243
+ }, [running, setActive]);
244
+
245
+ /*
246
+ * A still field is not an empty one.
247
+ *
248
+ * Held and reduced-motion fields keep whichever frame the clock is on, and
249
+ * the clock starts partway through the loop rather than at zero — so the
250
+ * light is somewhere legible rather than dead centre or absent, which is the
251
+ * difference between "not animating" and "failed to load".
252
+ */
253
+ const slotA = useAnimatedProps(() => ({
254
+ d: frames[Math.floor(framePhase(clock.value, animation))] ?? '',
255
+ }));
256
+ const slotB = useAnimatedProps(() => ({
257
+ d: frames[(Math.floor(framePhase(clock.value, animation)) + 1) % FRAMES] ?? '',
258
+ }));
259
+
260
+ // Only these move between one frame and the next, and a view's opacity is a
261
+ // layer alpha — so the drawing underneath is composited again rather than
262
+ // drawn again.
263
+ const fadeOut = useAnimatedStyle(() => ({
264
+ opacity: crossfadeAlphas(framePhase(clock.value, animation) % 1)[0],
265
+ }));
266
+ const fadeIn = useAnimatedStyle(() => ({
267
+ opacity: crossfadeAlphas(framePhase(clock.value, animation) % 1)[1],
268
+ }));
269
+
270
+ const onLayout = (event: LayoutChangeEvent) => {
271
+ const { width, height } = event.nativeEvent.layout;
272
+ setSize((was) => (was.width === width && was.height === height ? was : { width, height }));
273
+ };
274
+
275
+ return (
276
+ <View
277
+ accessibilityElementsHidden
278
+ importantForAccessibility="no-hide-descendants"
279
+ pointerEvents="none"
280
+ onLayout={onLayout}
281
+ className={className}
282
+ style={[StyleSheet.absoluteFill, style]}
283
+ {...props}
284
+ >
285
+ {grid ? (
286
+ <>
287
+ {/* The field at rest, under everything and never touched again. */}
288
+ <Svg
289
+ width={size.width}
290
+ height={size.height}
291
+ style={StyleSheet.absoluteFill}
292
+ >
293
+ <Path d={grid} fill={color} fillOpacity={DOT_REST_OPACITY} />
294
+ </Svg>
295
+ {/* The dots the light has reached, drawn larger over their own
296
+ resting copies rather than instead of them — so a dot brightens
297
+ in place instead of appearing to move.
298
+
299
+ Two of them: the frame being left and the frame being arrived at.
300
+ Each carries its dots at full strength and the pair is brought to
301
+ LIT_OPACITY by the views, because what fades has to be the layer
302
+ and not the fill — see crossfadeAlphas for why the ramps are not
303
+ simply t and 1 - t. */}
304
+ <Animated.View
305
+ style={[StyleSheet.absoluteFill, fadeOut]}
306
+ pointerEvents="none"
307
+ >
308
+ <Svg width={size.width} height={size.height}>
309
+ <AnimatedPath animatedProps={slotA} fill={color} />
310
+ </Svg>
311
+ </Animated.View>
312
+ <Animated.View
313
+ style={[StyleSheet.absoluteFill, fadeIn]}
314
+ pointerEvents="none"
315
+ >
316
+ <Svg width={size.width} height={size.height}>
317
+ <AnimatedPath animatedProps={slotB} fill={color} />
318
+ </Svg>
319
+ </Animated.View>
320
+ </>
321
+ ) : null}
322
+ </View>
323
+ );
324
+ }
325
+
326
+ export interface ImageGenerationProps extends Omit<ViewProps, 'children'> {
327
+ className?: string;
328
+ /** How far the generation has got. Defaults to `generating`. */
329
+ status?: ImageGenerationStatus;
330
+ /**
331
+ * What the box is, for a screen reader. Defaults to the status text, with
332
+ * the prompt after it where there is one.
333
+ */
334
+ label?: string;
335
+ /** The instruction the image was made from. Shown under the status. */
336
+ prompt?: string;
337
+ /** Shown in the corner of the frame. Pass an empty string to drop it. */
338
+ resolution?: string;
339
+ /**
340
+ * The box's shape, as width over height. Defaults to `1` — square, which is
341
+ * what most models return, and what the frame must be before there is an
342
+ * image to measure.
343
+ */
344
+ aspectRatio?: number;
345
+ /** `compact` caps the width at a thumbnail and centres it; `fluid` fills. */
346
+ size?: 'compact' | 'fluid';
347
+ /**
348
+ * How the light behind the dots moves while there is work outstanding.
349
+ * `drift` wanders, `pulse` leaves the centre as a ring, `scan` crosses as a
350
+ * band. All three cost the same.
351
+ */
352
+ animation?: DotFieldAnimation;
353
+ /** Replaces the sentence under the frame. */
354
+ statusText?: string;
355
+ /** Hides the status line, leaving the frame and the prompt. */
356
+ showStatus?: boolean;
357
+ /** Shown as a button under an `error`. Without it there is no button. */
358
+ onRetry?: () => void;
359
+ /** Extra classes for the frame — its radius, ground and aspect. */
360
+ frameClassName?: string;
361
+ /** Extra classes for the layer the image sits in. */
362
+ mediaClassName?: string;
363
+ /** Extra classes for the status line. */
364
+ statusClassName?: string;
365
+ /** The finished image. Anything that fills its parent — an `Image`, a video. */
366
+ children?: ReactNode;
367
+ }
368
+
369
+ /** The mark beside the status: a turning square while it works, then an answer. */
370
+ function StatusMark({ status }: { status: ImageGenerationStatus }) {
371
+ const reducedMotion = useReducedMotion();
372
+ const spin = useSharedValue(0);
373
+ const working = status !== 'complete' && status !== 'error';
374
+
375
+ useEffect(() => {
376
+ if (!working || reducedMotion) {
377
+ cancelAnimation(spin);
378
+ spin.value = 0;
379
+ return;
380
+ }
381
+ spin.value = 0;
382
+ spin.value = withRepeat(
383
+ withTiming(1, { duration: 2400, easing: Easing.inOut(Easing.quad) }),
384
+ -1
385
+ );
386
+ return () => cancelAnimation(spin);
387
+ }, [reducedMotion, spin, working]);
388
+
389
+ const style = useAnimatedStyle(() => ({
390
+ transform: [{ rotate: `${spin.value * 360}deg` }],
391
+ }));
392
+
393
+ const tint = useCSSVariable('--color-muted-foreground');
394
+ const color = typeof tint === 'string' ? tint : '#737373';
395
+
396
+ if (status === 'complete') return <CheckIcon size={14} />;
397
+ if (status === 'error') return <AlertTriangleIcon size={14} />;
398
+
399
+ // Four dots turning as a block: the same shape as the field it stands for,
400
+ // small enough to sit on a line of text.
401
+ return (
402
+ <Animated.View
403
+ accessibilityElementsHidden
404
+ importantForAccessibility="no-hide-descendants"
405
+ style={[{ width: 14, height: 14, flexDirection: 'row', flexWrap: 'wrap', gap: 2 }, style]}
406
+ >
407
+ {[1, 0.55, 0.55, 1].map((opacity, index) => (
408
+ <View
409
+ key={index}
410
+ style={{ width: 5, height: 5, borderRadius: 1, backgroundColor: color, opacity }}
411
+ />
412
+ ))}
413
+ </Animated.View>
414
+ );
415
+ }
416
+
417
+ function ImageGenerationRoot({
418
+ className,
419
+ status = 'generating',
420
+ label,
421
+ prompt,
422
+ resolution = '1024 × 1024',
423
+ aspectRatio = 1,
424
+ size = 'compact',
425
+ animation = 'drift',
426
+ statusText,
427
+ showStatus = true,
428
+ onRetry,
429
+ frameClassName,
430
+ mediaClassName,
431
+ statusClassName,
432
+ children,
433
+ ...props
434
+ }: ImageGenerationProps) {
435
+ const reducedMotion = useReducedMotion();
436
+ const working = status === 'queued' || status === 'generating' || status === 'refining';
437
+ const slots = imageGenerationVariants({ size, error: status === 'error' });
438
+ const sentence = statusText ?? STATUS_TEXT[status];
439
+
440
+ const progress = useSharedValue(MEDIA_OPACITY[status]);
441
+ const fieldFade = useSharedValue(FIELD_OPACITY[status]);
442
+ const scale = useSharedValue(MEDIA_SCALE[status]);
443
+
444
+ useEffect(() => {
445
+ const duration = reducedMotion ? 0 : 400;
446
+ progress.value = withTiming(MEDIA_OPACITY[status], { duration });
447
+ fieldFade.value = withTiming(FIELD_OPACITY[status], { duration });
448
+ scale.value = withTiming(MEDIA_SCALE[status], { duration });
449
+ }, [fieldFade, progress, reducedMotion, scale, status]);
450
+
451
+ useEffect(
452
+ () => () => {
453
+ cancelAnimation(progress);
454
+ cancelAnimation(fieldFade);
455
+ cancelAnimation(scale);
456
+ },
457
+ [fieldFade, progress, scale]
458
+ );
459
+
460
+ const mediaStyle = useAnimatedStyle(() => ({
461
+ opacity: progress.value,
462
+ transform: [{ scale: scale.value }],
463
+ }));
464
+ const fieldStyle = useAnimatedStyle(() => ({ opacity: fieldFade.value }));
465
+
466
+ return (
467
+ <View {...props} className={slots.root({ className })}>
468
+ <View
469
+ accessible
470
+ accessibilityRole="image"
471
+ accessibilityLabel={label ?? (prompt ? `${sentence}: ${prompt}` : sentence)}
472
+ // `busy` is what tells a screen reader the box is not the answer yet.
473
+ accessibilityState={{ busy: working }}
474
+ style={{ aspectRatio }}
475
+ className={slots.frame({ className: frameClassName })}
476
+ >
477
+ <Animated.View
478
+ className={cn(slots.media(), mediaClassName)}
479
+ style={mediaStyle}
480
+ // Nothing to announce until there is something in it.
481
+ accessibilityElementsHidden={working}
482
+ importantForAccessibility={working ? 'no-hide-descendants' : 'auto'}
483
+ >
484
+ {children}
485
+ </Animated.View>
486
+
487
+ {/* Kept mounted at zero rather than unmounted, so the image is not
488
+ fading in over a box that is simultaneously being torn down. The
489
+ field stops drawing as soon as there is no work left, so an idle
490
+ one costs nothing but the views. */}
491
+ <Animated.View
492
+ style={[StyleSheet.absoluteFill, fieldStyle]}
493
+ pointerEvents="none"
494
+ >
495
+ <ImageGenerationField paused={!working} animation={animation} />
496
+ </Animated.View>
497
+
498
+ {resolution ? <Text className={slots.resolution()}>{resolution}</Text> : null}
499
+ </View>
500
+
501
+ {showStatus ? (
502
+ <View
503
+ accessibilityLiveRegion="polite"
504
+ className={slots.status({ className: statusClassName })}
505
+ >
506
+ <StatusMark status={status} />
507
+ <Text className={slots.statusText()}>{sentence}</Text>
508
+ </View>
509
+ ) : null}
510
+
511
+ {prompt ? (
512
+ <Text numberOfLines={1} className={slots.prompt()}>
513
+ {prompt}
514
+ </Text>
515
+ ) : null}
516
+
517
+ {status === 'error' && onRetry ? (
518
+ <Pressable
519
+ accessibilityRole="button"
520
+ accessibilityLabel="Try again"
521
+ onPress={onRetry}
522
+ className={slots.retry()}
523
+ >
524
+ <RotateCcwIcon size={16} />
525
+ <Text className={slots.retryLabel()}>Try again</Text>
526
+ </Pressable>
527
+ ) : null}
528
+ </View>
529
+ );
530
+ }
531
+
532
+ ImageGenerationRoot.displayName = 'ImageGeneration';
533
+ ImageGenerationField.displayName = 'ImageGeneration.Field';
534
+
535
+ export const ImageGeneration = Object.assign(ImageGenerationRoot, {
536
+ Field: ImageGenerationField,
537
+ });
@@ -28,11 +28,9 @@
28
28
  */
29
29
  import { createContext, forwardRef, useContext, useMemo, type ReactNode } from 'react';
30
30
  import { View, type ViewProps } from 'react-native';
31
- import ArrowDown from 'lucide-react-native/icons/arrow-down';
32
- import ArrowUp from 'lucide-react-native/icons/arrow-up';
33
- import Minus from 'lucide-react-native/icons/minus';
34
31
  import { tv, type VariantProps } from 'tailwind-variants';
35
32
  import { useCSSVariable } from 'uniwind';
33
+ import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from '../../icons';
36
34
  import { Text } from '../../primitives/text';
37
35
  import { cn } from '../../utils/cn';
38
36
  import { type SeriesColorIndex } from '../../utils/chart';
@@ -442,7 +440,7 @@ function KpiTrend({
442
440
  const raw = tone === 'good' ? goodTint : tone === 'bad' ? badTint : mutedTint;
443
441
  const tint = typeof raw === 'string' ? raw : '#737373';
444
442
 
445
- const Arrow = flat ? Minus : rising ? ArrowUp : ArrowDown;
443
+ const Arrow = flat ? MinusIcon : rising ? ArrowUpIcon : ArrowDownIcon;
446
444
 
447
445
  const label = format
448
446
  ? format(value)
@@ -57,12 +57,9 @@ import Animated, {
57
57
  } from 'react-native-reanimated';
58
58
  import { tv } from 'tailwind-variants';
59
59
  import { useCSSVariable } from 'uniwind';
60
- import Check from 'lucide-react-native/icons/check';
61
- import ChevronLeft from 'lucide-react-native/icons/chevron-left';
62
- import ChevronRight from 'lucide-react-native/icons/chevron-right';
63
- import Circle from 'lucide-react-native/icons/circle';
64
60
  import { Text, textChildren } from '../../primitives/text';
65
- import { useDirection, useDirectionSign } from '../../hooks/use-direction';
61
+ import { useDirectionSign } from '../../hooks/use-direction';
62
+ import { CheckIcon, ChevronRightIcon, CircleIcon } from '../../icons';
66
63
  import { selectionTick } from '../../utils/haptics';
67
64
  import { cn } from '../../utils/cn';
68
65
  import { Popover, type PopoverContentProps, type PopoverProps } from '../popover';
@@ -570,7 +567,7 @@ export function MenuCheckboxItem({
570
567
  // as a different row rather than the same one in a new state.
571
568
  icon={
572
569
  checked ? (
573
- <Check
570
+ <CheckIcon
574
571
  size={16}
575
572
  strokeWidth={INDICATOR_STROKE}
576
573
  color={tint ?? INDICATOR_FALLBACK}
@@ -654,14 +651,14 @@ export function MenuRadioItem({
654
651
  // the tick beside it, and drawing one of the two as a rounded box
655
652
  // is what makes them optically different sizes at the same nominal
656
653
  // one.
657
- <Circle
654
+ <CircleIcon
658
655
  size={8}
656
+ filled
659
657
  strokeWidth={INDICATOR_STROKE}
660
658
  color={tint ?? INDICATOR_FALLBACK}
661
- fill={tint ?? INDICATOR_FALLBACK}
662
659
  />
663
660
  ) : (
664
- <Check
661
+ <CheckIcon
665
662
  size={16}
666
663
  strokeWidth={INDICATOR_STROKE}
667
664
  color={tint ?? INDICATOR_FALLBACK}
@@ -737,13 +734,8 @@ export interface MenuSubTriggerProps
737
734
  export function MenuSubTrigger({ className, children, icon, onSelect, ...props }: MenuSubTriggerProps) {
738
735
  const { open, toggle } = useMenuSub('Menu.SubTrigger');
739
736
  const sign = useDirectionSign();
740
- const direction = useDirection();
741
737
  const reducedMotion = useReducedMotion();
742
738
  const chevronTint = useTint('--color-muted-foreground');
743
- // The glyph mirrors by being the other glyph. A `scaleX` flip would work on
744
- // a chevron, but it also flips the rotation below with it — and this one is
745
- // already being rotated.
746
- const Chevron = direction === 'rtl' ? ChevronLeft : ChevronRight;
747
739
  const progress = useSharedValue(open ? 1 : 0);
748
740
 
749
741
  useEffect(() => {
@@ -769,7 +761,10 @@ export function MenuSubTrigger({ className, children, icon, onSelect, ...props }
769
761
  className={className}
770
762
  trailing={
771
763
  <Animated.View style={chevronStyle}>
772
- <Chevron
764
+ {/* The mirror and the rotation sit on different views, so they
765
+ compose: the glyph turns itself around under RTL, and the parent
766
+ turns the result through the same quarter the other way. */}
767
+ <ChevronRightIcon
773
768
  size={16}
774
769
  strokeWidth={INDICATOR_STROKE}
775
770
  color={chevronTint ?? INDICATOR_FALLBACK}