@expokit/cli 0.2.0 → 0.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.
Files changed (110) hide show
  1. package/README.md +14 -0
  2. package/bin/expo-kit.js +86 -24
  3. package/bin/mcp.mjs +8 -1
  4. package/package.json +8 -3
  5. package/registry/_docs/accordion.json +16 -0
  6. package/registry/_docs/action-sheet.json +28 -0
  7. package/registry/_docs/alert.json +28 -0
  8. package/registry/_docs/avatar.json +24 -0
  9. package/registry/_docs/badge.json +35 -4
  10. package/registry/_docs/bottom-nav.json +24 -0
  11. package/registry/_docs/bottom-sheet.json +28 -0
  12. package/registry/_docs/bullet.json +3 -0
  13. package/registry/_docs/button-3d.json +16 -0
  14. package/registry/_docs/button.json +16 -0
  15. package/registry/_docs/calendar.json +16 -0
  16. package/registry/_docs/card-stack.json +16 -0
  17. package/registry/_docs/card.json +20 -0
  18. package/registry/_docs/carousel.json +20 -0
  19. package/registry/_docs/chat.json +184 -0
  20. package/registry/_docs/checkbox.json +16 -0
  21. package/registry/_docs/checkout.json +3 -0
  22. package/registry/_docs/chip-group.json +16 -0
  23. package/registry/_docs/color-swatch.json +16 -0
  24. package/registry/_docs/confetti.json +20 -0
  25. package/registry/_docs/coupon.json +16 -0
  26. package/registry/_docs/cube-pager.json +16 -0
  27. package/registry/_docs/dashboard.json +3 -0
  28. package/registry/_docs/date-picker.json +20 -0
  29. package/registry/_docs/dialog.json +24 -0
  30. package/registry/_docs/drawer.json +28 -0
  31. package/registry/_docs/fab.json +24 -0
  32. package/registry/_docs/flip-card.json +16 -0
  33. package/registry/_docs/floating-input.json +20 -0
  34. package/registry/_docs/gift-card.json +16 -0
  35. package/registry/_docs/glass-button.json +24 -0
  36. package/registry/_docs/glass-nav-bar.json +24 -0
  37. package/registry/_docs/glass-search-bar.json +24 -0
  38. package/registry/_docs/glass-segmented.json +28 -0
  39. package/registry/_docs/glass-sheet.json +28 -0
  40. package/registry/_docs/glass-tab-bar.json +24 -0
  41. package/registry/_docs/glass.json +16 -0
  42. package/registry/_docs/inbox.json +3 -0
  43. package/registry/_docs/isometric-bar.json +3 -0
  44. package/registry/_docs/kit.json +3 -0
  45. package/registry/_docs/kpi-card.json +3 -0
  46. package/registry/_docs/like-button.json +20 -0
  47. package/registry/_docs/liquidglass-tab.json +3 -0
  48. package/registry/_docs/list-item.json +16 -0
  49. package/registry/_docs/loyalty-card.json +16 -0
  50. package/registry/_docs/morph-button.json +20 -0
  51. package/registry/_docs/number-ticker.json +16 -0
  52. package/registry/_docs/offers.json +3 -0
  53. package/registry/_docs/onboarding.json +3 -0
  54. package/registry/_docs/otp-input.json +24 -0
  55. package/registry/_docs/pagination-dots.json +20 -0
  56. package/registry/_docs/parallax-layers.json +20 -0
  57. package/registry/_docs/parallax-scroll.json +24 -0
  58. package/registry/_docs/password-input.json +20 -0
  59. package/registry/_docs/payment-card.json +16 -0
  60. package/registry/_docs/pixel-bar.json +3 -0
  61. package/registry/_docs/polar-area.json +3 -0
  62. package/registry/_docs/profile.json +3 -0
  63. package/registry/_docs/progress-steps.json +24 -0
  64. package/registry/_docs/progress.json +20 -0
  65. package/registry/_docs/radio-group.json +20 -0
  66. package/registry/_docs/rating.json +24 -0
  67. package/registry/_docs/ring-carousel.json +20 -0
  68. package/registry/_docs/scratch-card.json +20 -0
  69. package/registry/_docs/search-bar.json +20 -0
  70. package/registry/_docs/search-results.json +3 -0
  71. package/registry/_docs/segmented-control.json +20 -0
  72. package/registry/_docs/select.json +26 -1
  73. package/registry/_docs/settings.json +3 -0
  74. package/registry/_docs/shimmer-button.json +20 -0
  75. package/registry/_docs/sign-in.json +3 -0
  76. package/registry/_docs/skeleton.json +20 -0
  77. package/registry/_docs/slide-to-confirm.json +24 -0
  78. package/registry/_docs/slider.json +21 -1
  79. package/registry/_docs/spin-wheel.json +24 -0
  80. package/registry/_docs/spinner.json +41 -11
  81. package/registry/_docs/stateful-button.json +184 -0
  82. package/registry/_docs/stepper.json +16 -0
  83. package/registry/_docs/swipe-deck.json +16 -0
  84. package/registry/_docs/swipeable-row.json +24 -1
  85. package/registry/_docs/switch.json +20 -0
  86. package/registry/_docs/tabs.json +24 -0
  87. package/registry/_docs/tag-input.json +20 -0
  88. package/registry/_docs/text-animation.json +141 -0
  89. package/registry/_docs/textarea.json +16 -0
  90. package/registry/_docs/ticket-card.json +20 -0
  91. package/registry/_docs/ticket-shape.json +3 -0
  92. package/registry/_docs/tilt-card.json +16 -0
  93. package/registry/_docs/time-picker.json +16 -0
  94. package/registry/_docs/timeline-chart.json +3 -0
  95. package/registry/_docs/timeline.json +16 -0
  96. package/registry/_docs/toast.json +24 -0
  97. package/registry/_docs/treemap.json +3 -0
  98. package/registry/_docs/waterfall.json +3 -0
  99. package/registry/_docs/wheel-list.json +20 -0
  100. package/registry/badge/files/src/components/ui/badge.tsx +327 -17
  101. package/registry/badge.json +1 -1
  102. package/registry/chat.json +18 -0
  103. package/registry/spinner/files/src/components/ui/spinner.tsx +435 -22
  104. package/registry/spinner.json +1 -1
  105. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +532 -0
  106. package/registry/stateful-button/files/src/lib/kit.tsx +219 -0
  107. package/registry/stateful-button.json +18 -0
  108. package/registry/text-animation/files/src/components/ui/text-animation.tsx +650 -0
  109. package/registry/text-animation/files/src/lib/kit.tsx +219 -0
  110. package/registry/text-animation.json +15 -0
@@ -1,10 +1,20 @@
1
- import React, { useEffect, useRef, useState } from 'react';
2
- import { Animated, Easing, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ AccessibilityInfo,
4
+ Animated,
5
+ Easing,
6
+ StyleSheet,
7
+ Text,
8
+ View,
9
+ type LayoutChangeEvent,
10
+ type StyleProp,
11
+ type ViewStyle,
12
+ } from 'react-native';
3
13
  import Svg, { Circle, Path } from 'react-native-svg';
4
14
 
5
15
  import { alpha, isTextNode, motion, readableOn, type Colors, useKit } from '@/lib/kit';
6
16
 
7
- export type BadgeIconName = 'bell' | 'message' | 'package' | 'cart' | 'heart';
17
+ export type BadgeIconName = 'bell' | 'message' | 'package' | 'cart' | 'heart' | 'clock' | 'refresh' | 'check' | 'alert';
8
18
 
9
19
  const ICON_STROKE = { strokeWidth: 1.85, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' as const };
10
20
 
@@ -37,6 +47,20 @@ export function BadgeIcon({ name, color, size = 22 }: { name: BadgeIconName; col
37
47
  <Path d="M6 6L5 3H2" {...p} />
38
48
  </>
39
49
  ),
50
+ clock: (
51
+ <>
52
+ <Circle cx={12} cy={12} r={8.5} {...p} />
53
+ <Path d="M12 7.5V12l3 2" {...p} />
54
+ </>
55
+ ),
56
+ refresh: <Path d="M19 12a7 7 0 1 1-2.05-4.95M19 4.5V8h-3.5" {...p} />,
57
+ check: <Path d="M5 12.5l4.5 4.5L19 7.5" {...p} />,
58
+ alert: (
59
+ <>
60
+ <Circle cx={12} cy={12} r={8.5} {...p} />
61
+ <Path d="M12 7.5v5M12 16.2v.3" {...p} />
62
+ </>
63
+ ),
40
64
  heart: <Path d="M12 20.5l-1.1-1C6.1 15.1 3 12.2 3 8.6A4.6 4.6 0 0 1 7.6 4c2 0 3.1 1.2 4.4 2.5C13.3 5.2 14.4 4 16.4 4A4.6 4.6 0 0 1 21 8.6c0 3.6-3.1 6.5-7.9 10.9L12 20.5z" {...p} />,
41
65
  };
42
66
  return (
@@ -101,6 +125,11 @@ export interface BadgeProps {
101
125
  shape?: 'pill' | 'rounded';
102
126
  /** Pulse the leading dot (dot variant) — "live" states. */
103
127
  pulse?: boolean;
128
+ /**
129
+ * When the label, icon or colour changes: roll the old label up and the new one in, pop the icon,
130
+ * cross-fade the colours and spring the width (default true). Never animates on mount.
131
+ */
132
+ animateChanges?: boolean;
104
133
  style?: StyleProp<ViewStyle>;
105
134
  }
106
135
 
@@ -114,7 +143,139 @@ function toneColor(colors: Colors, tone: BadgeTone) {
114
143
  return { neutral: colors.mutedForeground, accent: colors.accent, success: colors.success, warning: colors.warning, danger: colors.danger }[tone];
115
144
  }
116
145
 
117
- /** Small status label: solid, soft, outline or dot — five tones, three sizes. */
146
+ function useReduceMotion() {
147
+ const [reduce, setReduce] = useState(false);
148
+ useEffect(() => {
149
+ let alive = true;
150
+ AccessibilityInfo.isReduceMotionEnabled()
151
+ .then((r) => alive && setReduce(r))
152
+ .catch(() => {});
153
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
154
+ return () => {
155
+ alive = false;
156
+ sub.remove();
157
+ };
158
+ }, []);
159
+ return reduce;
160
+ }
161
+
162
+ interface Look {
163
+ bg: string;
164
+ fg: string;
165
+ border: string;
166
+ dot: string;
167
+ }
168
+
169
+ interface Change {
170
+ /** Outgoing label rolling up and out; the current label rolls in under it. */
171
+ text?: { from: string; fg: string; perLetter: boolean; duration: number };
172
+ /** Outgoing icon (null when an icon was just added). */
173
+ icon?: { from: React.ReactNode };
174
+ /** Colours being faded from. */
175
+ look?: Look;
176
+ }
177
+
178
+ /** Short Latin labels roll letter by letter; anything else (CJK, emoji, long text) rolls as one piece. */
179
+ const LATIN = /^[A-Za-z0-9À-ɏ .,:;'’&+\-!?#%()/]{1,18}$/;
180
+ /** Roll timing in ms: delay between letters, one letter's travel, and how far the new label trails the old. */
181
+ const STAGGER = 24;
182
+ const ROLL = 320;
183
+ const LEAD = 70;
184
+
185
+ function labelText(node: React.ReactNode): string | null {
186
+ if (!isTextNode(node)) return null;
187
+ return Array.isArray(node) ? node.join('') : String(node);
188
+ }
189
+
190
+ function sameValue(a: unknown, b: unknown) {
191
+ if (Object.is(a, b)) return true;
192
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || React.isValidElement(a) || React.isValidElement(b)) return false;
193
+ try {
194
+ return JSON.stringify(a) === JSON.stringify(b);
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
199
+
200
+ /** Icons compare by type, key and props (ignoring colour), so an inline `<BadgeIcon />` only "changes" with its glyph. */
201
+ function sameIcon(a: React.ReactNode, b: React.ReactNode) {
202
+ if (Object.is(a, b)) return true;
203
+ if (!React.isValidElement(a) || !React.isValidElement(b)) return false;
204
+ if (a.type !== b.type || a.key !== b.key) return false;
205
+ const pa = a.props as Record<string, unknown>;
206
+ const pb = b.props as Record<string, unknown>;
207
+ for (const k of new Set([...Object.keys(pa), ...Object.keys(pb)])) {
208
+ if (k !== 'color' && !sameValue(pa[k], pb[k])) return false;
209
+ }
210
+ return true;
211
+ }
212
+
213
+ /** Eased move from `from` to `to` while `v` runs start → end (held outside), sampled for the native driver. */
214
+ function span(v: Animated.Value, start: number, end: number, from: number, to: number, ease: (t: number) => number) {
215
+ const inputRange: number[] = [];
216
+ const outputRange: number[] = [];
217
+ for (let k = 0; k <= 10; k++) {
218
+ inputRange.push(start + ((end - start) * k) / 10);
219
+ outputRange.push(from + (to - from) * ease(k / 10));
220
+ }
221
+ return v.interpolate({ inputRange, outputRange, extrapolate: 'clamp' });
222
+ }
223
+
224
+ const easeIn = Easing.in(Easing.cubic);
225
+ const easeBack = Easing.out(Easing.back(1.7));
226
+ const easeLinear = Easing.linear;
227
+
228
+ /** One side of a label roll: letters (or the whole label) slide up out of / up into the clipped slot. */
229
+ function RollingLabel({
230
+ text,
231
+ color,
232
+ fontSize,
233
+ progress,
234
+ out,
235
+ perLetter,
236
+ duration,
237
+ }: {
238
+ text: string;
239
+ color: string;
240
+ fontSize: number;
241
+ progress: Animated.Value;
242
+ out: boolean;
243
+ perLetter: boolean;
244
+ duration: number;
245
+ }) {
246
+ const pieces = perLetter ? [...text].map((ch) => (ch === ' ' ? ' ' : ch)) : [text];
247
+ const distance = Math.ceil(fontSize * 1.35);
248
+ return (
249
+ <View pointerEvents="none" style={styles.roll}>
250
+ {pieces.map((piece, i) => {
251
+ const begin = ((out ? 0 : LEAD) + i * STAGGER) / duration;
252
+ const travel = (out ? ROLL * 0.7 : ROLL) / duration;
253
+ const end = Math.min(1, begin + travel);
254
+ const fade = Math.min(1, begin + travel * 0.5);
255
+ return (
256
+ <Animated.View
257
+ key={i}
258
+ style={{
259
+ opacity: out ? span(progress, begin, fade, 1, 0, easeLinear) : span(progress, begin, fade, 0, 1, easeLinear),
260
+ transform: [
261
+ { translateY: out ? span(progress, begin, end, 0, -distance, easeIn) : span(progress, begin, end, distance, 0, easeBack) },
262
+ ],
263
+ }}
264
+ >
265
+ <Text numberOfLines={1} allowFontScaling={false} style={[styles.label, { color, fontSize }]}>
266
+ {piece}
267
+ </Text>
268
+ </Animated.View>
269
+ );
270
+ })}
271
+ </View>
272
+ );
273
+ }
274
+
275
+ /**
276
+ * Small status label: solid, soft, outline or dot — five tones, three sizes. When its text, icon or
277
+ * tone changes, the label rolls over, the icon pops, the colour cross-fades and the pill springs to size.
278
+ */
118
279
  export function Badge({
119
280
  children,
120
281
  variant = 'soft',
@@ -124,9 +285,11 @@ export function Badge({
124
285
  icon,
125
286
  shape = 'pill',
126
287
  pulse,
288
+ animateChanges = true,
127
289
  style,
128
290
  }: BadgeProps) {
129
291
  const { colors, radius, dark } = useKit();
292
+ const reduce = useReduceMotion();
130
293
  const s = SIZES[size];
131
294
  const c = color ?? toneColor(colors, tone);
132
295
  const neutral = !color && tone === 'neutral';
@@ -155,29 +318,167 @@ export function Badge({
155
318
  };
156
319
  const { bg, fg, border } = look[variant];
157
320
  const dotColor = neutral ? colors.mutedForeground : c;
321
+ const text = labelText(children);
322
+
323
+ // Change tracking: diff against the last render and start a transition — never on mount.
324
+ const [last, setLast] = useState({ seq: 0, text, icon, look: { bg, fg, border, dot: dotColor } as Look });
325
+ const [change, setChange] = useState<Change | null>(null);
326
+ // While set, the pill keeps this width (number), then springs (Animated.Value) to the new content.
327
+ const [pin, setPin] = useState<number | Animated.Value | null>(null);
328
+ const natural = useRef<number | null>(null);
329
+ const rowWidth = useRef<number | null>(null);
330
+ const widthV = useRef(new Animated.Value(0)).current;
331
+ const flat = StyleSheet.flatten(style) ?? {};
332
+ const canSize = flat.width == null && flat.minWidth == null && flat.flex == null && flat.flexGrow == null && flat.alignSelf !== 'stretch';
333
+
334
+ const textChanged = text !== last.text;
335
+ const iconChanged = !sameIcon(icon, last.icon);
336
+ const lookChanged = bg !== last.look.bg || fg !== last.look.fg || border !== last.look.border || dotColor !== last.look.dot;
337
+ if (textChanged || iconChanged || lookChanged) {
338
+ setLast({ seq: last.seq + 1, text, icon, look: { bg, fg, border, dot: dotColor } });
339
+ const next: Change = {};
340
+ if (animateChanges && !reduce) {
341
+ if (textChanged && last.text != null && text != null) {
342
+ const perLetter = LATIN.test(last.text) && LATIN.test(text);
343
+ const n = perLetter ? Math.max(last.text.length, text.length) : 1;
344
+ next.text = { from: last.text, fg: last.look.fg, perLetter, duration: LEAD + (n - 1) * STAGGER + ROLL };
345
+ }
346
+ if (iconChanged && icon != null) next.icon = { from: last.icon ?? null };
347
+ if (lookChanged) next.look = last.look;
348
+ }
349
+ const animated = !!(next.text || next.icon || next.look);
350
+ setChange(animated ? next : null);
351
+ setPin(animated && (textChanged || iconChanged) && canSize && natural.current != null ? natural.current : null);
352
+ }
353
+
354
+ // Fresh values per change, so the first frame of a transition already sits at its start.
355
+ const anim = useMemo(
356
+ () => ({ seq: last.seq, roll: new Animated.Value(0), pop: new Animated.Value(0), mix: new Animated.Value(0) }),
357
+ [last.seq]
358
+ );
158
359
 
159
- const label =
160
- isTextNode(children) ? (
161
- <Text numberOfLines={1} allowFontScaling={false} style={[styles.label, { color: fg, fontSize: s.font }]}>
162
- {children}
163
- </Text>
360
+ useEffect(() => {
361
+ if (!change) return;
362
+ const runs: Animated.CompositeAnimation[] = [];
363
+ if (change.text) {
364
+ runs.push(Animated.timing(anim.roll, { toValue: 1, duration: change.text.duration, easing: easeLinear, useNativeDriver: true }));
365
+ }
366
+ if (change.icon) runs.push(Animated.spring(anim.pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }));
367
+ if (change.look) {
368
+ runs.push(Animated.timing(anim.mix, { toValue: 1, duration: motion.normal, easing: Easing.out(Easing.quad), useNativeDriver: false }));
369
+ }
370
+ let left = runs.length;
371
+ runs.forEach((r) =>
372
+ r.start(({ finished }) => {
373
+ left -= 1;
374
+ if (finished && left === 0) setChange((cur) => (cur === change ? null : cur));
375
+ })
376
+ );
377
+ return () => runs.forEach((r) => r.stop());
378
+ }, [change, anim]);
379
+
380
+ const onRoot = (e: LayoutChangeEvent) => {
381
+ if (pin == null) natural.current = e.nativeEvent.layout.width;
382
+ };
383
+ // The icon + label row remounts on every change, so this always reports the new content's width.
384
+ const onRow = (e: LayoutChangeEvent) => {
385
+ const w = e.nativeEvent.layout.width;
386
+ const prev = rowWidth.current;
387
+ rowWidth.current = w;
388
+ if (typeof pin !== 'number') return;
389
+ if (prev == null || Math.abs(w - prev) < 0.5) {
390
+ setPin(null);
391
+ return;
392
+ }
393
+ const to = Math.max(0, pin + (w - prev));
394
+ widthV.setValue(pin);
395
+ natural.current = to;
396
+ setPin(widthV);
397
+ Animated.spring(widthV, { toValue: to, useNativeDriver: false, ...motion.snappy }).start(({ finished }) => {
398
+ if (finished) setPin((p) => (p === widthV ? null : p));
399
+ });
400
+ };
401
+
402
+ const from = change?.look;
403
+ const mix = (a: string, b: string) => (from ? anim.mix.interpolate({ inputRange: [0, 1], outputRange: [a, b] }) : b);
404
+ const roll = change?.text;
405
+
406
+ let label: React.ReactNode = children;
407
+ if (text != null) {
408
+ label = (
409
+ <View style={roll ? styles.clip : undefined}>
410
+ <Animated.Text
411
+ numberOfLines={1}
412
+ allowFontScaling={false}
413
+ style={[styles.label, { color: from && !roll ? mix(from.fg, fg) : fg, fontSize: s.font }, roll ? styles.hidden : null]}
414
+ >
415
+ {text}
416
+ </Animated.Text>
417
+ {roll ? (
418
+ <>
419
+ <RollingLabel text={roll.from} color={roll.fg} fontSize={s.font} progress={anim.roll} out perLetter={roll.perLetter} duration={roll.duration} />
420
+ <RollingLabel text={text} color={fg} fontSize={s.font} progress={anim.roll} out={false} perLetter={roll.perLetter} duration={roll.duration} />
421
+ </>
422
+ ) : null}
423
+ </View>
424
+ );
425
+ }
426
+
427
+ const pop = anim.pop;
428
+ const iconNode =
429
+ icon != null && change?.icon ? (
430
+ <View>
431
+ {change.icon.from != null ? (
432
+ <Animated.View
433
+ pointerEvents="none"
434
+ style={[
435
+ styles.center,
436
+ {
437
+ opacity: pop.interpolate({ inputRange: [0, 0.4], outputRange: [1, 0], extrapolate: 'clamp' }),
438
+ transform: [
439
+ { scale: pop.interpolate({ inputRange: [0, 0.5], outputRange: [1, 0.3], extrapolate: 'clamp' }) },
440
+ { rotate: pop.interpolate({ inputRange: [0, 0.5], outputRange: ['0deg', '100deg'], extrapolate: 'clamp' }) },
441
+ ],
442
+ },
443
+ ]}
444
+ >
445
+ {change.icon.from}
446
+ </Animated.View>
447
+ ) : null}
448
+ <Animated.View
449
+ style={{
450
+ opacity: pop.interpolate({ inputRange: [0, 0.35], outputRange: [0, 1], extrapolate: 'clamp' }),
451
+ transform: [
452
+ { scale: pop.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }) },
453
+ { rotate: pop.interpolate({ inputRange: [0, 1], outputRange: ['-100deg', '0deg'] }) },
454
+ ],
455
+ }}
456
+ >
457
+ {icon}
458
+ </Animated.View>
459
+ </View>
164
460
  ) : (
165
- children
461
+ icon
166
462
  );
167
463
 
168
464
  return (
169
- <View
465
+ <Animated.View
466
+ accessible={text != null ? true : undefined}
170
467
  accessibilityRole="text"
468
+ accessibilityLabel={text ?? undefined}
469
+ accessibilityLiveRegion={animateChanges && text != null ? 'polite' : undefined}
470
+ onLayout={animateChanges ? onRoot : undefined}
171
471
  style={[
172
472
  styles.base,
173
473
  {
174
474
  height: s.h,
175
475
  paddingHorizontal: s.px,
176
476
  gap: s.gap,
177
- backgroundColor: bg,
178
- borderColor: border,
477
+ backgroundColor: from ? mix(from.bg, bg) : bg,
478
+ borderColor: from ? mix(from.border, border) : border,
179
479
  borderRadius: shape === 'pill' ? radius.capsule(s.h) : radius.sm,
180
480
  },
481
+ pin != null ? { width: pin, overflow: 'hidden' as const } : null,
181
482
  style,
182
483
  ]}
183
484
  >
@@ -196,12 +497,15 @@ export function Badge({
196
497
  ]}
197
498
  />
198
499
  ) : null}
199
- <View style={[styles.fill, { borderRadius: s.dot, backgroundColor: dotColor }]} />
500
+ {/* Colour fades on the JS driver, so it lives on this view — the pulse above owns the native transforms. */}
501
+ <Animated.View style={[styles.fill, { borderRadius: s.dot, backgroundColor: from ? mix(from.dot, dotColor) : dotColor }]} />
200
502
  </View>
201
503
  ) : null}
202
- {icon}
203
- {label}
204
- </View>
504
+ <View key={last.seq} onLayout={animateChanges ? onRow : undefined} style={[styles.row, { gap: s.gap }]}>
505
+ {iconNode}
506
+ {label}
507
+ </View>
508
+ </Animated.View>
205
509
  );
206
510
  }
207
511
 
@@ -309,6 +613,12 @@ export function NotificationBadge({
309
613
  const styles = StyleSheet.create({
310
614
  base: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', borderWidth: 1 },
311
615
  label: { fontWeight: '400', letterSpacing: 0.1 },
616
+ row: { flexDirection: 'row', alignItems: 'center' },
617
+ // Clips the roll; the 2pt slack (cancelled by negative margins) keeps overshoot and unkerned letters whole.
618
+ clip: { overflow: 'hidden', padding: 2, margin: -2 },
619
+ hidden: { opacity: 0 },
620
+ roll: { position: 'absolute', top: 2, left: 2, flexDirection: 'row' },
621
+ center: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center', justifyContent: 'center' },
312
622
  fill: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },
313
623
  anchor: { alignSelf: 'flex-start' },
314
624
  bubble: { alignItems: 'center', justifyContent: 'center', borderWidth: 2 },
@@ -2,7 +2,7 @@
2
2
  "name": "badge",
3
3
  "type": "component",
4
4
  "title": "Badge",
5
- "description": "Status labels in solid, soft, outline and dot styles across five tones — plus NotificationBadge with BadgeIconTile/BadgeIcon anchors (or your own children), spring-in counts, and pop on change.",
5
+ "description": "Status labels in solid, soft, outline and dot styles across five tones — when the label changes it rolls over letter by letter, the icon pops, the colour cross-fades and the pill springs to its new width. Plus NotificationBadge with BadgeIconTile/BadgeIcon anchors (or your own children), spring-in counts, and pop on change.",
6
6
  "tier": "free",
7
7
  "dependencies": [
8
8
  "react-native-svg"
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "chat",
3
+ "type": "block",
4
+ "title": "Chat",
5
+ "description": "An AI assistant chat screen: replies stream in word by word with a soft fade and a blinking caret, a shimmering “Thinking…” waits for the first token, and the send button morphs into Stop. Light markdown (bold, inline code, lists, scrollable code blocks), suggestion chips, copy / regenerate / thumbs actions and a prompt field that grows to five lines and stays above the keyboard.",
6
+ "tier": "pro",
7
+ "dependencies": [
8
+ "react-native-svg",
9
+ "expo-haptics"
10
+ ],
11
+ "registryDependencies": [
12
+ "kit"
13
+ ],
14
+ "sharedFiles": [
15
+ "lib/kit.tsx"
16
+ ],
17
+ "filesDir": null
18
+ }