panelui-native 0.80.0 → 0.82.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 (92) hide show
  1. package/lib/module/components/bottom-sheet/index.js +90 -17
  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 +210 -0
  8. package/lib/module/components/image-generation/dot-field.js.map +1 -0
  9. package/lib/module/components/image-generation/index.js +417 -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/planner/index.js +446 -32
  18. package/lib/module/components/planner/index.js.map +1 -1
  19. package/lib/module/components/planner/planner-entries.js +36 -0
  20. package/lib/module/components/planner/planner-entries.js.map +1 -1
  21. package/lib/module/components/planner/planner-grid-navigation.js +34 -2
  22. package/lib/module/components/planner/planner-grid-navigation.js.map +1 -1
  23. package/lib/module/components/planner/planner-weeks.js +95 -0
  24. package/lib/module/components/planner/planner-weeks.js.map +1 -0
  25. package/lib/module/components/select/index.js +127 -23
  26. package/lib/module/components/select/index.js.map +1 -1
  27. package/lib/module/components/slide-button/index.js +610 -0
  28. package/lib/module/components/slide-button/index.js.map +1 -0
  29. package/lib/module/components/slide-button/slide-button-track.js +98 -0
  30. package/lib/module/components/slide-button/slide-button-track.js.map +1 -0
  31. package/lib/module/icons/index.js +553 -1934
  32. package/lib/module/icons/index.js.map +1 -1
  33. package/lib/module/index.js +3 -1
  34. package/lib/module/index.js.map +1 -1
  35. package/lib/module/primitives/portal.js +15 -1
  36. package/lib/module/primitives/portal.js.map +1 -1
  37. package/lib/module/utils/date.js +10 -3
  38. package/lib/module/utils/date.js.map +1 -1
  39. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  40. package/lib/typescript/src/components/button/index.d.ts +12 -0
  41. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  42. package/lib/typescript/src/components/button-group/index.d.ts +9 -0
  43. package/lib/typescript/src/components/button-group/index.d.ts.map +1 -1
  44. package/lib/typescript/src/components/image-generation/dot-field.d.ts +61 -0
  45. package/lib/typescript/src/components/image-generation/dot-field.d.ts.map +1 -0
  46. package/lib/typescript/src/components/image-generation/index.d.ts +126 -0
  47. package/lib/typescript/src/components/image-generation/index.d.ts.map +1 -0
  48. package/lib/typescript/src/components/kpi/index.d.ts.map +1 -1
  49. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  50. package/lib/typescript/src/components/panelside/index.d.ts +194 -10
  51. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/planner/index.d.ts +65 -4
  53. package/lib/typescript/src/components/planner/index.d.ts.map +1 -1
  54. package/lib/typescript/src/components/planner/planner-entries.d.ts +18 -0
  55. package/lib/typescript/src/components/planner/planner-entries.d.ts.map +1 -1
  56. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts +7 -1
  57. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts.map +1 -1
  58. package/lib/typescript/src/components/planner/planner-weeks.d.ts +50 -0
  59. package/lib/typescript/src/components/planner/planner-weeks.d.ts.map +1 -0
  60. package/lib/typescript/src/components/select/index.d.ts +75 -2
  61. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  62. package/lib/typescript/src/components/slide-button/index.d.ts +317 -0
  63. package/lib/typescript/src/components/slide-button/index.d.ts.map +1 -0
  64. package/lib/typescript/src/components/slide-button/slide-button-track.d.ts +70 -0
  65. package/lib/typescript/src/components/slide-button/slide-button-track.d.ts.map +1 -0
  66. package/lib/typescript/src/icons/index.d.ts +136 -189
  67. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  68. package/lib/typescript/src/index.d.ts +5 -3
  69. package/lib/typescript/src/index.d.ts.map +1 -1
  70. package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
  71. package/lib/typescript/src/utils/date.d.ts +1 -1
  72. package/lib/typescript/src/utils/date.d.ts.map +1 -1
  73. package/package.json +3 -2
  74. package/src/components/bottom-sheet/index.tsx +97 -17
  75. package/src/components/button/index.tsx +10 -2
  76. package/src/components/button-group/index.tsx +1 -0
  77. package/src/components/image-generation/dot-field.ts +226 -0
  78. package/src/components/image-generation/index.tsx +488 -0
  79. package/src/components/kpi/index.tsx +2 -4
  80. package/src/components/menu/index.tsx +10 -15
  81. package/src/components/panelside/index.tsx +498 -33
  82. package/src/components/planner/index.tsx +516 -38
  83. package/src/components/planner/planner-entries.ts +33 -0
  84. package/src/components/planner/planner-grid-navigation.ts +37 -2
  85. package/src/components/planner/planner-weeks.ts +90 -0
  86. package/src/components/select/index.tsx +158 -31
  87. package/src/components/slide-button/index.tsx +681 -0
  88. package/src/components/slide-button/slide-button-track.ts +100 -0
  89. package/src/icons/index.tsx +338 -1327
  90. package/src/index.ts +30 -0
  91. package/src/primitives/portal.tsx +15 -1
  92. package/src/utils/date.ts +13 -3
@@ -0,0 +1,488 @@
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 band of light travelling across it. Each column
19
+ * is one animated view holding its own dots, so a field of five hundred dots
20
+ * costs twenty-two worklets a frame rather than five hundred — the dots inside
21
+ * a column never move independently of it, and at this scale nobody can tell.
22
+ *
23
+ * The per-dot brightness underneath the band is fixed, and comes from the
24
+ * dot's own coordinates. Without it the field reads as clean vertical stripes
25
+ * sweeping past; with it, it reads as the individual dots lighting up.
26
+ *
27
+ * ## What arrives, and when
28
+ *
29
+ * `status` moves the picture through the work rather than switching it: the
30
+ * field fades as the image fades in, and for one step in the middle both are
31
+ * visible at once. That overlap is the point — an image that appears the
32
+ * instant the field vanishes has been swapped in, and one that surfaces
33
+ * through it has been developed.
34
+ *
35
+ * ## Reduced motion
36
+ *
37
+ * The band stops, and the field is drawn at one still frame of itself. A
38
+ * placeholder that shows nothing is indistinguishable from a component that
39
+ * failed to load, so this is a quieter picture rather than an empty one.
40
+ */
41
+ import { useEffect, useId, useMemo, useState, type ReactNode } from 'react';
42
+ import {
43
+ Pressable,
44
+ StyleSheet,
45
+ View,
46
+ type LayoutChangeEvent,
47
+ type ViewProps,
48
+ } from 'react-native';
49
+ import Svg, { Path } from 'react-native-svg';
50
+ import Animated, {
51
+ Easing,
52
+ cancelAnimation,
53
+ useAnimatedProps,
54
+ useAnimatedStyle,
55
+ useFrameCallback,
56
+ useReducedMotion,
57
+ useSharedValue,
58
+ withRepeat,
59
+ withTiming,
60
+ type SharedValue,
61
+ } from 'react-native-reanimated';
62
+ import { tv } from 'tailwind-variants';
63
+ import { useCSSVariable } from 'uniwind';
64
+ import { useDirectionSign } from '../../hooks/use-direction';
65
+ import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from '../../icons';
66
+ import { Text } from '../../primitives/text';
67
+ import { cn } from '../../utils/cn';
68
+ import {
69
+ DOT_REST_OPACITY,
70
+ frameAt,
71
+ gridPath,
72
+ litFrames,
73
+ type DotFieldAnimation,
74
+ } from './dot-field';
75
+
76
+ /** How far the generation has got. */
77
+ export type ImageGenerationStatus =
78
+ | 'queued'
79
+ | 'generating'
80
+ | 'refining'
81
+ | 'complete'
82
+ | 'error';
83
+
84
+ /**
85
+ * The moment a held field is frozen at.
86
+ *
87
+ * Not zero: at zero the light sits dead centre, which reads as a target rather
88
+ * than as something passing through.
89
+ */
90
+ const STILL_FRAME = 900;
91
+
92
+ /** How visible the field is at each step of the work. */
93
+ const FIELD_OPACITY: Record<ImageGenerationStatus, number> = {
94
+ queued: 1,
95
+ generating: 1,
96
+ refining: 0.48,
97
+ complete: 0,
98
+ error: 0,
99
+ };
100
+
101
+ /**
102
+ * How visible the image is at each step.
103
+ *
104
+ * `refining` is the overlap: the field is still there at half strength and the
105
+ * image is already coming through it. Both fully on for one step is what makes
106
+ * the picture surface rather than get swapped in.
107
+ */
108
+ const MEDIA_OPACITY: Record<ImageGenerationStatus, number> = {
109
+ queued: 0,
110
+ generating: 0,
111
+ refining: 0.62,
112
+ complete: 1,
113
+ error: 0.28,
114
+ };
115
+
116
+ /**
117
+ * How much larger than final the image starts.
118
+ *
119
+ * Small, and it settles rather than zooms. A generated image is not arriving
120
+ * from anywhere; the scale is there to give the last of the fade somewhere to
121
+ * go, so it does not simply stop.
122
+ */
123
+ const MEDIA_SCALE: Record<ImageGenerationStatus, number> = {
124
+ queued: 1.02,
125
+ generating: 1.02,
126
+ refining: 1.008,
127
+ complete: 1,
128
+ error: 1,
129
+ };
130
+
131
+ const STATUS_TEXT: Record<ImageGenerationStatus, string> = {
132
+ queued: 'Waiting to generate',
133
+ generating: 'Creating image',
134
+ refining: 'Refining details',
135
+ complete: 'Image ready',
136
+ error: 'Generation failed',
137
+ };
138
+
139
+ const imageGenerationVariants = tv({
140
+ slots: {
141
+ root: 'w-full',
142
+ frame: 'relative w-full overflow-hidden rounded-2xl bg-muted',
143
+ media: 'absolute inset-0',
144
+ resolution:
145
+ 'absolute end-2 top-2 rounded-full bg-background/75 px-2 py-0.5 text-[10px] text-muted-foreground',
146
+ status: 'mt-3 flex-row items-center gap-2',
147
+ statusText: 'text-sm font-medium text-foreground',
148
+ prompt: 'mt-0.5 text-xs text-muted-foreground',
149
+ retry: 'mt-3 min-h-12 flex-row items-center gap-2 self-start rounded-full px-3 active:bg-accent',
150
+ retryLabel: 'text-sm font-medium text-foreground',
151
+ },
152
+ variants: {
153
+ size: {
154
+ /** Held to a thumbnail, centred — a result inside a conversation. */
155
+ compact: { root: 'max-w-52 self-center' },
156
+ /** As wide as it is given. */
157
+ fluid: {},
158
+ },
159
+ error: {
160
+ true: { statusText: 'text-destructive' },
161
+ },
162
+ },
163
+ defaultVariants: {
164
+ size: 'compact',
165
+ },
166
+ });
167
+
168
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
169
+
170
+ export interface ImageGenerationFieldProps extends Omit<ViewProps, 'children'> {
171
+ className?: string;
172
+ /** Holds the light still, at one representative frame. */
173
+ paused?: boolean;
174
+ /**
175
+ * How the light moves through the field.
176
+ *
177
+ * `drift` wanders around the middle; `pulse` is a ring leaving the centre;
178
+ * `scan` crosses as a band. All three cost the same.
179
+ */
180
+ animation?: DotFieldAnimation;
181
+ }
182
+
183
+ /**
184
+ * The dot field on its own, for a placeholder that is not an image.
185
+ *
186
+ * It fills its parent absolutely rather than sizing to its contents, because
187
+ * what it draws has no intrinsic height — laid out normally it measures to
188
+ * nothing and draws a single row of dots along the top.
189
+ *
190
+ * ## What it costs
191
+ *
192
+ * Two paths: the resting grid, and the lit dots over it. Both are built when
193
+ * the box is measured — every frame of the loop at once — so running it is
194
+ * handing over a string that already exists, and a page of these costs the
195
+ * same as a page of static drawings plus one property assignment each.
196
+ */
197
+ function ImageGenerationField({
198
+ className,
199
+ paused = false,
200
+ animation = 'drift',
201
+ style,
202
+ ...props
203
+ }: ImageGenerationFieldProps) {
204
+ const reducedMotion = useReducedMotion();
205
+ const [size, setSize] = useState({ width: 0, height: 0 });
206
+ const clock = useSharedValue(STILL_FRAME);
207
+
208
+ const tint = useCSSVariable('--color-muted-foreground');
209
+ const color = typeof tint === 'string' ? tint : '#737373';
210
+
211
+ const running = !paused && !reducedMotion;
212
+
213
+ const grid = useMemo(
214
+ () => gridPath(size.width, size.height),
215
+ [size.width, size.height]
216
+ );
217
+ const frames = useMemo(
218
+ () => litFrames(size.width, size.height, animation),
219
+ [animation, size.width, size.height]
220
+ );
221
+
222
+ const frame = useFrameCallback((info) => {
223
+ 'worklet';
224
+ // Accumulated rather than read off the total, so pausing and resuming does
225
+ // not jump the light to wherever the clock would have carried it. A dropped
226
+ // frame is clamped: a 300ms hitch played back whole is a lurch.
227
+ clock.value += Math.min(info.timeSincePreviousFrame ?? 16, 48);
228
+ }, false);
229
+
230
+ const { setActive } = frame;
231
+ useEffect(() => {
232
+ setActive(running);
233
+ return () => setActive(false);
234
+ }, [running, setActive]);
235
+
236
+ /*
237
+ * A still field is not an empty one.
238
+ *
239
+ * Held and reduced-motion fields keep whichever frame the clock is on, and
240
+ * the clock starts partway through the loop rather than at zero — so the
241
+ * light is somewhere legible rather than dead centre or absent, which is the
242
+ * difference between "not animating" and "failed to load".
243
+ */
244
+ const animatedProps = useAnimatedProps(() => ({
245
+ d: frames[frameAt(clock.value, animation)] ?? '',
246
+ }));
247
+
248
+ const onLayout = (event: LayoutChangeEvent) => {
249
+ const { width, height } = event.nativeEvent.layout;
250
+ setSize((was) => (was.width === width && was.height === height ? was : { width, height }));
251
+ };
252
+
253
+ return (
254
+ <View
255
+ accessibilityElementsHidden
256
+ importantForAccessibility="no-hide-descendants"
257
+ pointerEvents="none"
258
+ onLayout={onLayout}
259
+ className={className}
260
+ style={[StyleSheet.absoluteFill, style]}
261
+ {...props}
262
+ >
263
+ {grid ? (
264
+ <Svg width={size.width} height={size.height}>
265
+ {/* The field at rest, under everything and never touched again. */}
266
+ <Path d={grid} fill={color} fillOpacity={DOT_REST_OPACITY} />
267
+ {/* The dots the light has reached, drawn larger over their own
268
+ resting copies rather than instead of them — so a dot brightens
269
+ in place instead of appearing to move. */}
270
+ <AnimatedPath animatedProps={animatedProps} fill={color} fillOpacity={0.9} />
271
+ </Svg>
272
+ ) : null}
273
+ </View>
274
+ );
275
+ }
276
+
277
+ export interface ImageGenerationProps extends Omit<ViewProps, 'children'> {
278
+ className?: string;
279
+ /** How far the generation has got. Defaults to `generating`. */
280
+ status?: ImageGenerationStatus;
281
+ /**
282
+ * What the box is, for a screen reader. Defaults to the status text, with
283
+ * the prompt after it where there is one.
284
+ */
285
+ label?: string;
286
+ /** The instruction the image was made from. Shown under the status. */
287
+ prompt?: string;
288
+ /** Shown in the corner of the frame. Pass an empty string to drop it. */
289
+ resolution?: string;
290
+ /**
291
+ * The box's shape, as width over height. Defaults to `1` — square, which is
292
+ * what most models return, and what the frame must be before there is an
293
+ * image to measure.
294
+ */
295
+ aspectRatio?: number;
296
+ /** `compact` caps the width at a thumbnail and centres it; `fluid` fills. */
297
+ size?: 'compact' | 'fluid';
298
+ /**
299
+ * How the light behind the dots moves while there is work outstanding.
300
+ * `drift` wanders, `pulse` leaves the centre as a ring, `scan` crosses as a
301
+ * band. All three cost the same.
302
+ */
303
+ animation?: DotFieldAnimation;
304
+ /** Replaces the sentence under the frame. */
305
+ statusText?: string;
306
+ /** Hides the status line, leaving the frame and the prompt. */
307
+ showStatus?: boolean;
308
+ /** Shown as a button under an `error`. Without it there is no button. */
309
+ onRetry?: () => void;
310
+ /** Extra classes for the frame — its radius, ground and aspect. */
311
+ frameClassName?: string;
312
+ /** Extra classes for the layer the image sits in. */
313
+ mediaClassName?: string;
314
+ /** Extra classes for the status line. */
315
+ statusClassName?: string;
316
+ /** The finished image. Anything that fills its parent — an `Image`, a video. */
317
+ children?: ReactNode;
318
+ }
319
+
320
+ /** The mark beside the status: a turning square while it works, then an answer. */
321
+ function StatusMark({ status }: { status: ImageGenerationStatus }) {
322
+ const reducedMotion = useReducedMotion();
323
+ const spin = useSharedValue(0);
324
+ const working = status !== 'complete' && status !== 'error';
325
+
326
+ useEffect(() => {
327
+ if (!working || reducedMotion) {
328
+ cancelAnimation(spin);
329
+ spin.value = 0;
330
+ return;
331
+ }
332
+ spin.value = 0;
333
+ spin.value = withRepeat(
334
+ withTiming(1, { duration: 2400, easing: Easing.inOut(Easing.quad) }),
335
+ -1
336
+ );
337
+ return () => cancelAnimation(spin);
338
+ }, [reducedMotion, spin, working]);
339
+
340
+ const style = useAnimatedStyle(() => ({
341
+ transform: [{ rotate: `${spin.value * 360}deg` }],
342
+ }));
343
+
344
+ const tint = useCSSVariable('--color-muted-foreground');
345
+ const color = typeof tint === 'string' ? tint : '#737373';
346
+
347
+ if (status === 'complete') return <CheckIcon size={14} />;
348
+ if (status === 'error') return <AlertTriangleIcon size={14} />;
349
+
350
+ // Four dots turning as a block: the same shape as the field it stands for,
351
+ // small enough to sit on a line of text.
352
+ return (
353
+ <Animated.View
354
+ accessibilityElementsHidden
355
+ importantForAccessibility="no-hide-descendants"
356
+ style={[{ width: 14, height: 14, flexDirection: 'row', flexWrap: 'wrap', gap: 2 }, style]}
357
+ >
358
+ {[1, 0.55, 0.55, 1].map((opacity, index) => (
359
+ <View
360
+ key={index}
361
+ style={{ width: 5, height: 5, borderRadius: 1, backgroundColor: color, opacity }}
362
+ />
363
+ ))}
364
+ </Animated.View>
365
+ );
366
+ }
367
+
368
+ function ImageGenerationRoot({
369
+ className,
370
+ status = 'generating',
371
+ label,
372
+ prompt,
373
+ resolution = '1024 × 1024',
374
+ aspectRatio = 1,
375
+ size = 'compact',
376
+ animation = 'drift',
377
+ statusText,
378
+ showStatus = true,
379
+ onRetry,
380
+ frameClassName,
381
+ mediaClassName,
382
+ statusClassName,
383
+ children,
384
+ ...props
385
+ }: ImageGenerationProps) {
386
+ const reducedMotion = useReducedMotion();
387
+ const working = status === 'queued' || status === 'generating' || status === 'refining';
388
+ const slots = imageGenerationVariants({ size, error: status === 'error' });
389
+ const sentence = statusText ?? STATUS_TEXT[status];
390
+
391
+ const progress = useSharedValue(MEDIA_OPACITY[status]);
392
+ const fieldFade = useSharedValue(FIELD_OPACITY[status]);
393
+ const scale = useSharedValue(MEDIA_SCALE[status]);
394
+
395
+ useEffect(() => {
396
+ const duration = reducedMotion ? 0 : 400;
397
+ progress.value = withTiming(MEDIA_OPACITY[status], { duration });
398
+ fieldFade.value = withTiming(FIELD_OPACITY[status], { duration });
399
+ scale.value = withTiming(MEDIA_SCALE[status], { duration });
400
+ }, [fieldFade, progress, reducedMotion, scale, status]);
401
+
402
+ useEffect(
403
+ () => () => {
404
+ cancelAnimation(progress);
405
+ cancelAnimation(fieldFade);
406
+ cancelAnimation(scale);
407
+ },
408
+ [fieldFade, progress, scale]
409
+ );
410
+
411
+ const mediaStyle = useAnimatedStyle(() => ({
412
+ opacity: progress.value,
413
+ transform: [{ scale: scale.value }],
414
+ }));
415
+ const fieldStyle = useAnimatedStyle(() => ({ opacity: fieldFade.value }));
416
+
417
+ return (
418
+ <View {...props} className={slots.root({ className })}>
419
+ <View
420
+ accessible
421
+ accessibilityRole="image"
422
+ accessibilityLabel={label ?? (prompt ? `${sentence}: ${prompt}` : sentence)}
423
+ // `busy` is what tells a screen reader the box is not the answer yet.
424
+ accessibilityState={{ busy: working }}
425
+ style={{ aspectRatio }}
426
+ className={slots.frame({ className: frameClassName })}
427
+ >
428
+ <Animated.View
429
+ className={cn(slots.media(), mediaClassName)}
430
+ style={mediaStyle}
431
+ // Nothing to announce until there is something in it.
432
+ accessibilityElementsHidden={working}
433
+ importantForAccessibility={working ? 'no-hide-descendants' : 'auto'}
434
+ >
435
+ {children}
436
+ </Animated.View>
437
+
438
+ {/* Kept mounted at zero rather than unmounted, so the image is not
439
+ fading in over a box that is simultaneously being torn down. The
440
+ field stops drawing as soon as there is no work left, so an idle
441
+ one costs nothing but the views. */}
442
+ <Animated.View
443
+ style={[StyleSheet.absoluteFill, fieldStyle]}
444
+ pointerEvents="none"
445
+ >
446
+ <ImageGenerationField paused={!working} animation={animation} />
447
+ </Animated.View>
448
+
449
+ {resolution ? <Text className={slots.resolution()}>{resolution}</Text> : null}
450
+ </View>
451
+
452
+ {showStatus ? (
453
+ <View
454
+ accessibilityLiveRegion="polite"
455
+ className={slots.status({ className: statusClassName })}
456
+ >
457
+ <StatusMark status={status} />
458
+ <Text className={slots.statusText()}>{sentence}</Text>
459
+ </View>
460
+ ) : null}
461
+
462
+ {prompt ? (
463
+ <Text numberOfLines={1} className={slots.prompt()}>
464
+ {prompt}
465
+ </Text>
466
+ ) : null}
467
+
468
+ {status === 'error' && onRetry ? (
469
+ <Pressable
470
+ accessibilityRole="button"
471
+ accessibilityLabel="Try again"
472
+ onPress={onRetry}
473
+ className={slots.retry()}
474
+ >
475
+ <RotateCcwIcon size={16} />
476
+ <Text className={slots.retryLabel()}>Try again</Text>
477
+ </Pressable>
478
+ ) : null}
479
+ </View>
480
+ );
481
+ }
482
+
483
+ ImageGenerationRoot.displayName = 'ImageGeneration';
484
+ ImageGenerationField.displayName = 'ImageGeneration.Field';
485
+
486
+ export const ImageGeneration = Object.assign(ImageGenerationRoot, {
487
+ Field: ImageGenerationField,
488
+ });
@@ -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}