@expokit/cli 0.4.3 → 0.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.
Files changed (242) hide show
  1. package/README.md +30 -3
  2. package/bin/expo-kit.js +749 -288
  3. package/bin/lib.mjs +313 -4
  4. package/package.json +1 -1
  5. package/registry/_docs/action-sheet.json +1 -1
  6. package/registry/_docs/alert.json +6 -0
  7. package/registry/_docs/bottom-sheet.json +6 -0
  8. package/registry/_docs/bullet.json +6 -0
  9. package/registry/_docs/dialog.json +1 -1
  10. package/registry/_docs/drawer.json +3 -3
  11. package/registry/_docs/flip-calendar.json +106 -0
  12. package/registry/_docs/glass-search-bar.json +1 -1
  13. package/registry/_docs/grain.json +6 -1
  14. package/registry/_docs/isometric-bar.json +6 -0
  15. package/registry/_docs/liquidglass-tab.json +1 -1
  16. package/registry/_docs/password-input.json +1 -1
  17. package/registry/_docs/paywall.json +144 -0
  18. package/registry/_docs/pixel-bar.json +6 -0
  19. package/registry/_docs/polar-area.json +6 -0
  20. package/registry/_docs/qr-code-button.json +9 -3
  21. package/registry/_docs/ruler-time-picker.json +147 -0
  22. package/registry/_docs/scratch-card.json +7 -1
  23. package/registry/_docs/search-bar.json +1 -1
  24. package/registry/_docs/select.json +2 -2
  25. package/registry/_docs/slide-to-confirm.json +2 -2
  26. package/registry/_docs/spinner.json +1 -1
  27. package/registry/_docs/stateful-button.json +2 -2
  28. package/registry/_docs/swipe-deck.json +1 -1
  29. package/registry/_docs/time-picker.json +2 -2
  30. package/registry/_docs/timeline-chart.json +6 -0
  31. package/registry/_docs/toast.json +15 -3
  32. package/registry/_docs/treemap.json +6 -0
  33. package/registry/_docs/waterfall.json +6 -0
  34. package/registry/accordion/files/src/components/ui/accordion.tsx +6 -4
  35. package/registry/accordion/files/src/lib/kit.tsx +462 -12
  36. package/registry/accordion.json +1 -0
  37. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +50 -58
  38. package/registry/action-sheet/files/src/lib/kit.tsx +462 -12
  39. package/registry/action-sheet.json +1 -0
  40. package/registry/alert/files/src/components/ui/alert.tsx +20 -13
  41. package/registry/alert/files/src/lib/kit.tsx +462 -12
  42. package/registry/alert.json +3 -4
  43. package/registry/assistant-app.json +1 -0
  44. package/registry/avatar/files/src/components/ui/avatar.tsx +89 -74
  45. package/registry/avatar/files/src/lib/kit.tsx +462 -12
  46. package/registry/avatar.json +1 -0
  47. package/registry/badge/files/src/components/ui/badge.tsx +36 -36
  48. package/registry/badge/files/src/lib/kit.tsx +462 -12
  49. package/registry/badge.json +1 -0
  50. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +39 -44
  51. package/registry/bottom-nav/files/src/lib/kit.tsx +462 -12
  52. package/registry/bottom-nav.json +5 -0
  53. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +357 -300
  54. package/registry/bottom-sheet/files/src/lib/kit.tsx +462 -12
  55. package/registry/bottom-sheet.json +5 -1
  56. package/registry/bullet.json +1 -0
  57. package/registry/button/files/src/components/ui/button.tsx +23 -34
  58. package/registry/button/files/src/lib/kit.tsx +462 -12
  59. package/registry/button-3d/files/src/components/ui/button-3d.tsx +6 -9
  60. package/registry/button-3d/files/src/lib/kit.tsx +462 -12
  61. package/registry/button-3d.json +1 -0
  62. package/registry/button.json +1 -0
  63. package/registry/calendar/files/src/components/ui/calendar.tsx +77 -79
  64. package/registry/calendar/files/src/lib/kit.tsx +462 -12
  65. package/registry/calendar.json +1 -0
  66. package/registry/card/files/src/components/ui/card.tsx +7 -6
  67. package/registry/card/files/src/lib/kit.tsx +462 -12
  68. package/registry/card-stack.json +1 -0
  69. package/registry/card.json +1 -0
  70. package/registry/carousel.json +1 -0
  71. package/registry/chat.json +7 -0
  72. package/registry/checkbox/files/src/components/ui/checkbox.tsx +4 -4
  73. package/registry/checkbox/files/src/lib/kit.tsx +462 -12
  74. package/registry/checkbox.json +1 -0
  75. package/registry/checkout.json +1 -0
  76. package/registry/chip-group/files/src/components/ui/chip-group.tsx +24 -24
  77. package/registry/chip-group/files/src/lib/kit.tsx +462 -12
  78. package/registry/chip-group.json +1 -0
  79. package/registry/confetti/files/src/components/ui/confetti.tsx +15 -14
  80. package/registry/confetti.json +3 -6
  81. package/registry/coupon/files/src/components/ui/coupon.tsx +47 -52
  82. package/registry/coupon/files/src/components/ui/flip-card.tsx +9 -14
  83. package/registry/coupon/files/src/components/ui/tilt-card.tsx +80 -101
  84. package/registry/coupon/files/src/lib/kit.tsx +462 -12
  85. package/registry/coupon/files/src/lib/ticket-shape.tsx +54 -44
  86. package/registry/coupon.json +1 -0
  87. package/registry/cube-pager.json +1 -0
  88. package/registry/dashboard.json +1 -0
  89. package/registry/date-picker/files/src/components/ui/calendar.tsx +77 -79
  90. package/registry/date-picker/files/src/components/ui/date-picker.tsx +64 -71
  91. package/registry/date-picker/files/src/lib/kit.tsx +462 -12
  92. package/registry/date-picker.json +1 -0
  93. package/registry/dialog/files/src/components/ui/dialog.tsx +66 -68
  94. package/registry/dialog/files/src/lib/kit.tsx +462 -12
  95. package/registry/dialog.json +1 -0
  96. package/registry/donut-shop.json +1 -0
  97. package/registry/drawer/files/src/components/ui/drawer.tsx +76 -88
  98. package/registry/drawer/files/src/lib/kit.tsx +462 -12
  99. package/registry/drawer.json +1 -0
  100. package/registry/fab.json +1 -0
  101. package/registry/fintech-app.json +1 -0
  102. package/registry/flip-calendar/files/src/components/ui/flip-calendar.tsx +632 -0
  103. package/registry/flip-calendar/files/src/lib/kit.tsx +672 -0
  104. package/registry/flip-calendar.json +24 -0
  105. package/registry/flip-card/files/src/components/ui/flip-card.tsx +9 -14
  106. package/registry/flip-card/files/src/lib/kit.tsx +462 -12
  107. package/registry/flip-card.json +1 -0
  108. package/registry/floating-input/files/src/components/ui/floating-input.tsx +33 -33
  109. package/registry/floating-input/files/src/lib/kit.tsx +462 -12
  110. package/registry/floating-input.json +1 -0
  111. package/registry/food-app.json +1 -0
  112. package/registry/gift-card.json +1 -0
  113. package/registry/glass-button.json +5 -0
  114. package/registry/glass-nav-bar.json +1 -0
  115. package/registry/glass-search-bar.json +1 -0
  116. package/registry/glass-segmented.json +1 -0
  117. package/registry/glass-sheet.json +1 -0
  118. package/registry/glass-tab-bar.json +5 -0
  119. package/registry/glass.json +1 -0
  120. package/registry/grain/files/src/components/ui/grain.tsx +16 -14
  121. package/registry/grain.json +8 -6
  122. package/registry/inbox.json +1 -0
  123. package/registry/isometric-bar.json +1 -0
  124. package/registry/kit/files/src/lib/kit.tsx +462 -12
  125. package/registry/kit.json +1 -0
  126. package/registry/liquidglass-tab/files/src/app/_layout.tsx +0 -1
  127. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +29 -16
  128. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +33 -19
  129. package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +1 -1
  130. package/registry/liquidglass-tab.json +1 -0
  131. package/registry/list-item/files/src/components/ui/list-item.tsx +43 -43
  132. package/registry/list-item/files/src/lib/kit.tsx +462 -12
  133. package/registry/list-item.json +1 -0
  134. package/registry/loyalty-card.json +1 -0
  135. package/registry/morphing-button-group.json +1 -0
  136. package/registry/number-ticker.json +1 -0
  137. package/registry/offers.json +1 -0
  138. package/registry/onboarding.json +1 -0
  139. package/registry/orb-reveal.json +1 -0
  140. package/registry/otp-input/files/src/components/ui/otp-input.tsx +49 -30
  141. package/registry/otp-input/files/src/lib/kit.tsx +462 -12
  142. package/registry/otp-input.json +1 -0
  143. package/registry/parallax-layers.json +1 -0
  144. package/registry/parallax-scroll.json +1 -0
  145. package/registry/password-input/files/src/components/ui/floating-input.tsx +33 -33
  146. package/registry/password-input/files/src/components/ui/password-input.tsx +29 -25
  147. package/registry/password-input/files/src/lib/kit.tsx +462 -12
  148. package/registry/password-input.json +1 -0
  149. package/registry/payment-card/files/src/components/ui/flip-card.tsx +9 -14
  150. package/registry/payment-card/files/src/components/ui/payment-card.tsx +26 -20
  151. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +80 -101
  152. package/registry/payment-card/files/src/lib/kit.tsx +462 -12
  153. package/registry/payment-card.json +1 -0
  154. package/registry/paywall.json +23 -0
  155. package/registry/pixel-bar.json +1 -0
  156. package/registry/polar-area.json +1 -0
  157. package/registry/profile.json +1 -0
  158. package/registry/progress/files/src/components/ui/progress.tsx +31 -29
  159. package/registry/progress/files/src/lib/kit.tsx +462 -12
  160. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +21 -21
  161. package/registry/progress-steps/files/src/lib/kit.tsx +462 -12
  162. package/registry/progress-steps.json +1 -0
  163. package/registry/progress.json +1 -0
  164. package/registry/qr-code-button.json +1 -0
  165. package/registry/radio-group/files/src/components/ui/radio-group.tsx +19 -19
  166. package/registry/radio-group/files/src/lib/kit.tsx +462 -12
  167. package/registry/radio-group.json +1 -0
  168. package/registry/rating.json +1 -0
  169. package/registry/ring-carousel.json +1 -0
  170. package/registry/ruler-time-picker/files/src/components/ui/ruler-time-picker.tsx +832 -0
  171. package/registry/ruler-time-picker/files/src/lib/kit.tsx +672 -0
  172. package/registry/ruler-time-picker.json +26 -0
  173. package/registry/scratch-card.json +1 -0
  174. package/registry/search-bar/files/src/components/ui/search-bar.tsx +28 -28
  175. package/registry/search-bar/files/src/lib/kit.tsx +462 -12
  176. package/registry/search-bar.json +1 -0
  177. package/registry/search-results.json +4 -0
  178. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +11 -17
  179. package/registry/segmented-control/files/src/lib/kit.tsx +462 -12
  180. package/registry/segmented-control.json +1 -0
  181. package/registry/select/files/src/components/ui/select.tsx +107 -104
  182. package/registry/select/files/src/lib/kit.tsx +462 -12
  183. package/registry/select.json +6 -0
  184. package/registry/settings.json +1 -0
  185. package/registry/sign-in.json +1 -0
  186. package/registry/skeleton/files/src/components/ui/skeleton.tsx +149 -80
  187. package/registry/skeleton/files/src/lib/kit.tsx +462 -12
  188. package/registry/skeleton.json +1 -0
  189. package/registry/slide-to-confirm.json +1 -0
  190. package/registry/slider/files/src/components/ui/slider.tsx +106 -108
  191. package/registry/slider/files/src/lib/kit.tsx +462 -12
  192. package/registry/slider.json +5 -0
  193. package/registry/spin-wheel.json +1 -0
  194. package/registry/spinner/files/src/components/ui/spinner.tsx +39 -29
  195. package/registry/spinner/files/src/lib/kit.tsx +462 -12
  196. package/registry/spinner.json +6 -0
  197. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +39 -32
  198. package/registry/stateful-button/files/src/lib/kit.tsx +462 -12
  199. package/registry/stateful-button.json +5 -0
  200. package/registry/stepper/files/src/components/ui/stepper.tsx +40 -38
  201. package/registry/stepper/files/src/lib/kit.tsx +462 -12
  202. package/registry/stepper.json +1 -0
  203. package/registry/swipe-deck.json +1 -0
  204. package/registry/swipeable-row.json +4 -0
  205. package/registry/switch/files/src/components/ui/switch.tsx +41 -47
  206. package/registry/switch/files/src/lib/kit.tsx +462 -12
  207. package/registry/switch.json +1 -0
  208. package/registry/tabs/files/src/components/ui/tabs.tsx +61 -64
  209. package/registry/tabs/files/src/lib/kit.tsx +462 -12
  210. package/registry/tabs.json +1 -0
  211. package/registry/text-animation/files/src/components/ui/text-animation.tsx +55 -42
  212. package/registry/text-animation/files/src/lib/kit.tsx +462 -12
  213. package/registry/text-animation.json +7 -0
  214. package/registry/textarea/files/src/components/ui/textarea.tsx +10 -10
  215. package/registry/textarea/files/src/lib/kit.tsx +462 -12
  216. package/registry/textarea.json +1 -0
  217. package/registry/ticket-card.json +1 -0
  218. package/registry/ticket-shape/files/src/lib/kit.tsx +462 -12
  219. package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +54 -44
  220. package/registry/ticket-shape.json +1 -0
  221. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +80 -101
  222. package/registry/tilt-card/files/src/lib/kit.tsx +462 -12
  223. package/registry/tilt-card.json +1 -0
  224. package/registry/time-picker/files/src/components/ui/time-picker.tsx +136 -131
  225. package/registry/time-picker/files/src/lib/kit.tsx +462 -12
  226. package/registry/time-picker.json +1 -0
  227. package/registry/timeline/files/src/components/ui/avatar.tsx +89 -74
  228. package/registry/timeline/files/src/components/ui/timeline.tsx +49 -57
  229. package/registry/timeline/files/src/lib/kit.tsx +462 -12
  230. package/registry/timeline-chart.json +1 -0
  231. package/registry/timeline.json +1 -0
  232. package/registry/toast/files/src/components/ui/toast.tsx +543 -465
  233. package/registry/toast/files/src/lib/kit.tsx +462 -12
  234. package/registry/toast.json +7 -5
  235. package/registry/treemap.json +1 -0
  236. package/registry/waterfall.json +1 -0
  237. package/registry/wheel-list.json +1 -0
  238. package/schema/components.schema.json +1 -0
  239. package/registry/alert/files/src/components/ui/button.tsx +0 -303
  240. package/registry/confetti/files/src/lib/kit.tsx +0 -222
  241. package/registry/grain/files/src/lib/kit.tsx +0 -222
  242. package/registry/toast/files/src/components/ui/button.tsx +0 -303
package/bin/lib.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  * Pure helpers for the expo-kit CLI (no I/O, no dependencies) — kept separate so they can be unit-tested.
3
3
  */
4
4
 
5
+ import { createHash } from 'node:crypto';
5
6
  import path from 'node:path';
6
7
 
7
8
  /** Thrown for user-facing errors; the CLI entry point prints `message` and exits 1. */
@@ -312,7 +313,7 @@ export function kitVersion(text) {
312
313
  }
313
314
 
314
315
  /**
315
- * Packages a registry item may depend on that work in the Expo Go app (SDK 57): native modules Expo Go
316
+ * Packages a registry item may depend on that work in the Expo Go app (SDK 58): native modules Expo Go
316
317
  * ships, plus pure-JS packages. Anything else needs a development build (`npx expo run:ios|android`).
317
318
  * Keep in sync with the Expo Go module list when the SDK moves.
318
319
  */
@@ -320,8 +321,10 @@ export const EXPO_GO_PACKAGES = new Set([
320
321
  '@expo/vector-icons',
321
322
  '@react-native-async-storage/async-storage',
322
323
  '@shopify/react-native-skia',
324
+ 'expo-audio',
323
325
  'expo-blur',
324
326
  'expo-charts',
327
+ 'expo-file-system',
325
328
  'expo-glass-effect',
326
329
  'expo-haptics',
327
330
  'expo-router',
@@ -344,13 +347,122 @@ export function needsDevBuild(deps) {
344
347
 
345
348
  /**
346
349
  * The component a file is named for: the PascalCase export matching one of `names` ("Grain Gradient" or
347
- * "grain-gradient" → GrainGradient), else the first PascalCase export (never a CONSTANT).
350
+ * "grain-gradient" → GrainGradient), then one that starts with it (Toast → Toaster, not ToastProvider: the shortest),
351
+ * else the first PascalCase export (never a CONSTANT).
348
352
  */
349
353
  export function pickMainExport(source, names = []) {
350
354
  const exports = [...source.matchAll(/export (?:function|const) ([A-Z][A-Za-z0-9]*[a-z][A-Za-z0-9]*)\b/g)].map((m) => m[1]);
351
355
  const pascal = (s) => String(s ?? '').split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join('');
352
- const wanted = names.map(pascal).map((n) => n.toLowerCase());
353
- return exports.find((e) => wanted.includes(e.toLowerCase())) ?? exports[0] ?? null;
356
+ const wanted = names.map(pascal).map((n) => n.toLowerCase()).filter(Boolean);
357
+ const exact = exports.find((e) => wanted.includes(e.toLowerCase()));
358
+ if (exact) return exact;
359
+ const prefixed = exports.filter((e) => wanted.some((w) => e.toLowerCase().startsWith(w))).sort((a, b) => a.length - b.length);
360
+ return prefixed[0] ?? exports[0] ?? null;
361
+ }
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // add: clashes, kit files, packages, suggestions, --dir
365
+ // ---------------------------------------------------------------------------
366
+
367
+ /**
368
+ * The entry in a folder listing that differs from `name` only by case (`Skeleton.tsx` for `skeleton.tsx`), or null.
369
+ * macOS and Windows treat the two as one file, so writing ours would replace theirs; on Linux both would live side
370
+ * by side and break the repo for anyone who clones it on a Mac.
371
+ */
372
+ export function caseClash(entries, name) {
373
+ const lower = name.toLowerCase();
374
+ return entries.find((e) => e !== name && e.toLowerCase() === lower) ?? null;
375
+ }
376
+
377
+ /** Whether a file came from ExpoKit (kit.tsx itself, or a component built on it) rather than being the user's own. */
378
+ export function looksLikeKitFile(text) {
379
+ return /\bfrom\s+['"][^'"]*\/lib\/kit['"]/.test(text) || kitVersion(text) !== null;
380
+ }
381
+
382
+ const bareName = (spec) => spec.replace(/^(@?[^@]+)@.*$/, '$1');
383
+
384
+ /** `deps` (npm specs, `pkg@1.2` allowed) that package.json doesn't list yet, deduped by package name. */
385
+ export function missingPackages(deps, pkg) {
386
+ const has = (name) =>
387
+ [pkg?.dependencies, pkg?.devDependencies].some((d) => d && typeof d === 'object' && Object.hasOwn(d, name));
388
+ const seen = new Set();
389
+ return (deps ?? []).filter((spec) => {
390
+ const name = bareName(spec);
391
+ if (seen.has(name) || has(name)) return false;
392
+ seen.add(name);
393
+ return true;
394
+ });
395
+ }
396
+
397
+ function editDistance(a, b) {
398
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
399
+ for (let i = 1; i <= a.length; i += 1) {
400
+ const row = [i];
401
+ for (let j = 1; j <= b.length; j += 1) {
402
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
403
+ }
404
+ prev = row;
405
+ }
406
+ return prev[b.length];
407
+ }
408
+
409
+ /** Up to 3 item names close to a mistyped one ("buton" → button, "sheet-bottom" → bottom-sheet), closest first. */
410
+ export function suggestItemNames(input, names) {
411
+ const want = String(input).toLowerCase();
412
+ const sorted = (s) => s.split('-').filter(Boolean).sort().join('-');
413
+ const flat = (s) => s.replace(/-/g, '');
414
+ const scored = [];
415
+ for (const name of new Set(names)) {
416
+ const d = editDistance(want, name);
417
+ let score = null;
418
+ if (sorted(want) === sorted(name) || flat(want) === flat(name)) score = 0;
419
+ else if (Math.min(want.length, name.length) >= 3 && (want.includes(name) || name.includes(want))) score = 1;
420
+ else if (d <= Math.max(1, Math.floor(want.length / 3))) score = 1 + d;
421
+ if (score !== null) scored.push({ name, score, d });
422
+ }
423
+ return scored
424
+ .sort((a, b) => a.score - b.score || a.d - b.d || a.name.localeCompare(b.name))
425
+ .slice(0, 3)
426
+ .map((s) => s.name);
427
+ }
428
+
429
+ /**
430
+ * Registry files import each other as `@/components/…` and `@/lib/…`. When components.json maps those to other
431
+ * aliases (or `add --dir` moved the components), point the imports there. Leaves everything else untouched.
432
+ */
433
+ export function rewriteKitImports(text, aliases) {
434
+ let out = text;
435
+ for (const key of ['components', 'lib', 'hooks']) {
436
+ const to = aliases?.[key];
437
+ if (typeof to !== 'string' || to === `@/${key}`) continue;
438
+ out = out.replace(new RegExp(`(['"])@/${key}/`, 'g'), (_, q) => `${q}${to.replace(/\/+$/, '')}/`);
439
+ }
440
+ return out;
441
+ }
442
+
443
+ /**
444
+ * The folder `@/*` points at in a parsed tsconfig ("src" for `./src/*`, "" for `./*`), or null when there is no
445
+ * such alias. Used to tell whether an `add --dir` folder can be imported as `@/…`.
446
+ */
447
+ export function aliasRootFromTsconfig(tsconfig) {
448
+ const target = tsconfig?.compilerOptions?.paths?.['@/*']?.[0];
449
+ if (typeof target !== 'string' || !target.endsWith('*')) return null;
450
+ const base = typeof tsconfig.compilerOptions.baseUrl === 'string' ? tsconfig.compilerOptions.baseUrl : '.';
451
+ const joined = path.posix.normalize(path.posix.join(base, target.slice(0, -1)));
452
+ const clean = joined.replace(/\/+$/, '');
453
+ return clean === '.' ? '' : clean;
454
+ }
455
+
456
+ /**
457
+ * `@/…` alias for a project-relative folder under the alias root (`src/components/kit` → `@/components/kit` when
458
+ * `@/*` is `./src/*`), or null when the folder is outside it, is the root itself, or isn't a plain folder path.
459
+ */
460
+ export function aliasTargetForDir(dir, aliasRoot) {
461
+ const rel = String(dir).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
462
+ if (!rel || rel.startsWith('/') || /^[A-Za-z]:/.test(rel) || !/^[A-Za-z0-9._()/-]+$/.test(rel)) return null;
463
+ if (rel.split('/').some((seg) => seg === '..' || seg === '.' || seg === '')) return null;
464
+ if (!aliasRoot) return `@/${rel}`;
465
+ return rel.startsWith(`${aliasRoot}/`) ? `@/${rel.slice(aliasRoot.length + 1)}` : null;
354
466
  }
355
467
 
356
468
  /** Union of dependency lists, order preserved. */
@@ -362,6 +474,203 @@ export function mergeDependencies(...lists) {
362
474
  return out;
363
475
  }
364
476
 
477
+ // ---------------------------------------------------------------------------
478
+ // Install records (components.json `files` / `installed`), diffs, init theme
479
+ // ---------------------------------------------------------------------------
480
+
481
+ /** sha256 (hex) of a file's text, line endings normalised so a git autocrlf checkout hashes the same. */
482
+ export function contentHash(text) {
483
+ return createHash('sha256').update(String(text).replace(/\r\n/g, '\n'), 'utf8').digest('hex');
484
+ }
485
+
486
+ const HASH = /^[0-9a-f]{64}$/;
487
+
488
+ /**
489
+ * The hash components.json recorded for a project file, or null. The file comes from the repo, so it is untrusted:
490
+ * only own, well-formed entries count, and its keys are only ever looked up — never used as paths to read or write.
491
+ */
492
+ export function recordedHash(files, rel) {
493
+ if (!files || typeof files !== 'object' || Array.isArray(files) || !Object.hasOwn(files, rel)) return null;
494
+ const value = files[rel];
495
+ return typeof value === 'string' && HASH.test(value) ? value : null;
496
+ }
497
+
498
+ /**
499
+ * A project file against the registry's copy:
500
+ * current (same as upstream) · pristine (as the CLI wrote it, upstream moved on) ·
501
+ * modified (changed since it was written) · untracked (differs, and there is no record to tell why)
502
+ */
503
+ export function fileStatus(recorded, have, upstream) {
504
+ const norm = (t) => String(t).replace(/\r\n/g, '\n');
505
+ if (norm(have) === norm(upstream)) return 'current';
506
+ if (!recorded) return 'untracked';
507
+ return contentHash(have) === recorded ? 'pristine' : 'modified';
508
+ }
509
+
510
+ /**
511
+ * components.json `installed`, cleaned: `{ name: { version?, kitVersion?, dir? } }`. Older CLIs stored just the
512
+ * version string. Unknown names and odd values are dropped (the file is untrusted); `dir` is checked where it's used.
513
+ */
514
+ export function normalizeInstalled(raw) {
515
+ const out = {};
516
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
517
+ for (const [name, value] of Object.entries(raw)) {
518
+ if (!isValidItemName(name)) continue;
519
+ if (typeof value === 'string') {
520
+ out[name] = value.length <= 40 ? { version: value } : {};
521
+ continue;
522
+ }
523
+ if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
524
+ const entry = {};
525
+ if (typeof value.version === 'string' && value.version.length <= 40) entry.version = value.version;
526
+ if (Number.isInteger(value.kitVersion) && value.kitVersion > 0) entry.kitVersion = value.kitVersion;
527
+ if (typeof value.dir === 'string' && value.dir.length <= 200) entry.dir = value.dir;
528
+ out[name] = entry;
529
+ }
530
+ return out;
531
+ }
532
+
533
+ const splitLines = (text) => {
534
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n');
535
+ if (lines[lines.length - 1] === '') lines.pop();
536
+ return lines;
537
+ };
538
+
539
+ /** Edit script (' ', '-', '+' per line) by LCS; past `maxCells` the changed middle is one remove + add block. */
540
+ function diffOps(a, b, maxCells) {
541
+ let pre = 0;
542
+ while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre += 1;
543
+ let suf = 0;
544
+ while (suf < a.length - pre && suf < b.length - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf += 1;
545
+ const x = a.slice(pre, a.length - suf);
546
+ const y = b.slice(pre, b.length - suf);
547
+ const ops = a.slice(0, pre).map((line) => [' ', line]);
548
+ if (x.length * y.length > maxCells) {
549
+ ops.push(...x.map((line) => ['-', line]), ...y.map((line) => ['+', line]));
550
+ } else {
551
+ const w = y.length + 1;
552
+ const lcs = new Uint32Array((x.length + 1) * w);
553
+ for (let i = x.length - 1; i >= 0; i -= 1) {
554
+ for (let j = y.length - 1; j >= 0; j -= 1) {
555
+ lcs[i * w + j] = x[i] === y[j] ? lcs[(i + 1) * w + j + 1] + 1 : Math.max(lcs[(i + 1) * w + j], lcs[i * w + j + 1]);
556
+ }
557
+ }
558
+ let i = 0;
559
+ let j = 0;
560
+ while (i < x.length || j < y.length) {
561
+ if (i < x.length && j < y.length && x[i] === y[j]) {
562
+ ops.push([' ', x[i]]);
563
+ i += 1;
564
+ j += 1;
565
+ } else if (i < x.length && (j === y.length || lcs[(i + 1) * w + j] >= lcs[i * w + j + 1])) {
566
+ ops.push(['-', x[i]]);
567
+ i += 1;
568
+ } else {
569
+ ops.push(['+', y[j]]);
570
+ j += 1;
571
+ }
572
+ }
573
+ }
574
+ ops.push(...a.slice(a.length - suf).map((line) => [' ', line]));
575
+ return ops;
576
+ }
577
+
578
+ /** A unified diff (`--- from` / `+++ to` / `@@` hunks) of two texts, or '' when they match (CRLF vs LF is no change). */
579
+ export function unifiedDiff(a, b, { from = 'a', to = 'b', context = 3, maxCells = 4_000_000 } = {}) {
580
+ const na = String(a).replace(/\r\n/g, '\n');
581
+ const nb = String(b).replace(/\r\n/g, '\n');
582
+ if (na === nb) return '';
583
+ const head = `--- ${from}\n+++ ${to}\n`;
584
+ const ops = diffOps(splitLines(na), splitLines(nb), maxCells);
585
+ const changed = ops.flatMap((op, i) => (op[0] === ' ' ? [] : [i]));
586
+ if (!changed.length) return `${head}(only the newline at the end of the file differs)\n`;
587
+
588
+ // Line numbers before each op, for the @@ headers.
589
+ const at = [];
590
+ let la = 1;
591
+ let lb = 1;
592
+ for (const [kind] of ops) {
593
+ at.push([la, lb]);
594
+ if (kind !== '+') la += 1;
595
+ if (kind !== '-') lb += 1;
596
+ }
597
+ let out = head;
598
+ let k = 0;
599
+ while (k < changed.length) {
600
+ const start = Math.max(0, changed[k] - context);
601
+ let end = changed[k];
602
+ while (k + 1 < changed.length && changed[k + 1] - end <= context * 2 + 1) end = changed[(k += 1)];
603
+ k += 1;
604
+ end = Math.min(ops.length - 1, end + context);
605
+ const slice = ops.slice(start, end + 1);
606
+ const countA = slice.filter(([kind]) => kind !== '+').length;
607
+ const countB = slice.filter(([kind]) => kind !== '-').length;
608
+ const [sa, sb] = at[start];
609
+ out += `@@ -${countA ? sa : sa - 1},${countA} +${countB ? sb : sb - 1},${countB} @@\n`;
610
+ out += slice.map(([kind, line]) => `${kind}${line}\n`).join('');
611
+ }
612
+ return out;
613
+ }
614
+
615
+ /** `init` theme answers, checked: they are written into a .tsx file, so only plain values get through. */
616
+ export function themeOptions({ brand, font, radius, scheme } = {}) {
617
+ const out = { brand: null, font: null, radius: null, scheme: 'system' };
618
+ if (brand !== undefined && brand !== null && brand !== '') {
619
+ const hex = String(brand).trim().replace(/^#?/, '#').toLowerCase();
620
+ if (!/^#([0-9a-f]{3}|[0-9a-f]{6})$/.test(hex)) throw new CliError(`--brand must be a hex colour like #6366f1 (got ${JSON.stringify(brand)}).`);
621
+ out.brand = hex.length === 4 ? `#${[...hex.slice(1)].map((c) => c + c).join('')}` : hex;
622
+ }
623
+ if (font !== undefined && font !== null && font !== '') {
624
+ const name = String(font).trim();
625
+ if (!/^[A-Za-z0-9][A-Za-z0-9 _-]{0,63}$/.test(name)) throw new CliError(`--font must be a font family name like Inter (got ${JSON.stringify(font)}).`);
626
+ out.font = name;
627
+ }
628
+ if (radius !== undefined && radius !== null && radius !== '') {
629
+ const text = String(radius).trim();
630
+ if (!/^\d{1,2}$/.test(text) || Number(text) > 48) throw new CliError(`--radius must be a whole number of points from 0 to 48 (got ${JSON.stringify(radius)}).`);
631
+ out.radius = Number(text);
632
+ }
633
+ if (scheme !== undefined && scheme !== null && scheme !== '') {
634
+ const s = String(scheme).trim().toLowerCase();
635
+ if (!['system', 'light', 'dark'].includes(s)) throw new CliError(`--scheme must be light, dark or system (got ${JSON.stringify(scheme)}).`);
636
+ out.scheme = s;
637
+ }
638
+ return out;
639
+ }
640
+
641
+ /** The `AppTheme` wrapper `init` writes next to kit.tsx: KitProvider with the chosen theme, and a place for your tokens. */
642
+ export function kitThemeSource({ brand, font, radius, scheme }, libAlias = '@/lib') {
643
+ const props = [`scheme="${scheme}"`];
644
+ if (brand) props.push(`accent="${brand}"`);
645
+ if (radius !== null && radius !== undefined) props.push(`radius={${radius}}`);
646
+ if (font) props.push(`fontFamily="${font}"`);
647
+ return `import type { ReactNode } from 'react';
648
+
649
+ import { KitProvider } from '${String(libAlias).replace(/\/+$/, '')}/kit';
650
+
651
+ /**
652
+ * Your app's ExpoKit theme, written by \`expo-kit init\`. Wrap your root layout in it:
653
+ * <AppTheme><Stack /></AppTheme>
654
+ * Already have a design system? Point the kit tokens at it in \`colors\`:
655
+ * https://expokit.in/docs/design-tokens
656
+ */
657
+ export function AppTheme({ children }: { children: ReactNode }) {
658
+ return (
659
+ <KitProvider
660
+ ${props.join('\n ')}
661
+ colors={{
662
+ // light: { background: tokens.surface, foreground: tokens.text, muted: tokens.subtle, border: tokens.line },
663
+ light: {},
664
+ dark: {},
665
+ }}
666
+ >
667
+ {children}
668
+ </KitProvider>
669
+ );
670
+ }
671
+ `;
672
+ }
673
+
365
674
  // ---------------------------------------------------------------------------
366
675
  // Site URL + token guard
367
676
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expokit/cli",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "mcpName": "io.github.Anshkaran7/expokit",
5
5
  "description": "Animated UI components for Expo and React Native — a shadcn-style CLI that copies buttons, inputs, cards, charts and full screens into your app",
6
6
  "license": "MIT",
@@ -35,7 +35,7 @@
35
35
  [
36
36
  "cancelLabel",
37
37
  "string | null",
38
- "\"Cancel\"",
38
+ "labels.cancel",
39
39
  "Separate cancel button; null hides it."
40
40
  ],
41
41
  [
@@ -86,6 +86,12 @@
86
86
  "tone colour",
87
87
  "Override the tone colour."
88
88
  ],
89
+ [
90
+ "dismissLabel",
91
+ "string",
92
+ "labels.dismiss",
93
+ "Screen-reader name of the dismiss button. Set it app-wide with KitProvider's labels."
94
+ ],
89
95
  [
90
96
  "style",
91
97
  "StyleProp<ViewStyle>",
@@ -68,6 +68,12 @@
68
68
  "with a title",
69
69
  "Round ✕ in the header (shown when dismissible, there's a title and no headerRight)."
70
70
  ],
71
+ [
72
+ "closeLabel",
73
+ "string",
74
+ "labels.close",
75
+ "Screen-reader name of the close button (default: the kit's labels.close)."
76
+ ],
71
77
  [
72
78
  "handle",
73
79
  "boolean",
@@ -25,6 +25,12 @@
25
25
  "string",
26
26
  "auto summary",
27
27
  "What screen readers announce; defaults to a summary of the data."
28
+ ],
29
+ [
30
+ "accessibilityHint",
31
+ "string",
32
+ "\"Swipe up or down to read each …\"",
33
+ "Screen-reader hint for the swipe-to-read gesture; pass your own to translate it."
28
34
  ]
29
35
  ],
30
36
  "behavior": [],
@@ -71,7 +71,7 @@
71
71
  [
72
72
  "confirmLabel / cancelLabel",
73
73
  "string / string | null",
74
- "\"Continue\" / \"Cancel\"",
74
+ "labels.continue (destructive: labels.delete) / labels.cancel",
75
75
  "Labels for the default buttons; cancelLabel null leaves a single button."
76
76
  ],
77
77
  [
@@ -95,7 +95,7 @@
95
95
  [
96
96
  "accessibilityLabel",
97
97
  "string",
98
- "\"Navigation drawer\"",
98
+ "labels.navigationDrawer",
99
99
  "Panel label."
100
100
  ],
101
101
  [
@@ -112,9 +112,9 @@
112
112
  ],
113
113
  [
114
114
  "DrawerHeader",
115
- "{ title?, subtitle?, leading?, showClose?, onClose? }",
115
+ "{ title?, subtitle?, leading?, showClose?, onClose?, closeLabel? }",
116
116
  "—",
117
- "Title chrome for the header slot."
117
+ "Title chrome for the header slot. closeLabel names the close button (default labels.closeMenu)."
118
118
  ],
119
119
  [
120
120
  "DrawerItem",
@@ -0,0 +1,106 @@
1
+ {
2
+ "name": "flip-calendar",
3
+ "category": "Inputs",
4
+ "aliases": [
5
+ "flip-date-picker",
6
+ "desk-calendar",
7
+ "daily-calendar",
8
+ "page-calendar",
9
+ "tear-off-calendar"
10
+ ],
11
+ "exportName": "FlipCalendar",
12
+ "files": [
13
+ "src/components/ui/flip-calendar.tsx"
14
+ ],
15
+ "summary": "A desk calendar: lift a page to flip it over the rings, pull down to bring it back.",
16
+ "props": [
17
+ [
18
+ "value",
19
+ "Date",
20
+ "—",
21
+ "Controlled date; its time of day is kept on every change."
22
+ ],
23
+ [
24
+ "defaultValue",
25
+ "Date",
26
+ "today",
27
+ "Initial date when uncontrolled."
28
+ ],
29
+ [
30
+ "onChange",
31
+ "(date: Date) => void",
32
+ "—",
33
+ "Called when a page turns over (next day) or back (previous day)."
34
+ ],
35
+ [
36
+ "minDate",
37
+ "Date",
38
+ "—",
39
+ "Earliest day; pulling back past it only nudges the page."
40
+ ],
41
+ [
42
+ "maxDate",
43
+ "Date",
44
+ "—",
45
+ "Latest day; lifting past it only raises the page a little."
46
+ ],
47
+ [
48
+ "locale",
49
+ "string",
50
+ "device",
51
+ "Weekday and month names (Intl), English where Intl isn't available."
52
+ ],
53
+ [
54
+ "accent",
55
+ "string",
56
+ "theme accent",
57
+ "Month band and Sunday colour (try colors.danger for the classic red)."
58
+ ],
59
+ [
60
+ "maxWidth",
61
+ "number",
62
+ "300",
63
+ "Widest the pad grows; it fills its container up to this."
64
+ ],
65
+ [
66
+ "haptics",
67
+ "boolean",
68
+ "true",
69
+ "A tick as a page passes upright and a soft thud when it lands."
70
+ ],
71
+ [
72
+ "accessibilityLabel",
73
+ "string",
74
+ "'Date'",
75
+ "Name read by screen readers."
76
+ ],
77
+ [
78
+ "style",
79
+ "StyleProp<ViewStyle>",
80
+ "—",
81
+ "Root container."
82
+ ]
83
+ ],
84
+ "behavior": [
85
+ [
86
+ "Touch",
87
+ "Drag up to lift today's page; past upright (or with a flick) it turns over to the next day, otherwise it settles back. Drag down to bring yesterday's page back over the rings. The point you grab stays under your finger. A hard flick turns up to six more pages. At minDate / maxDate the page only lifts or nudges a little."
88
+ ],
89
+ [
90
+ "Motion",
91
+ "Pages hinge on the rings in 3D on the native driver, darken as they turn away from the light and swap to their plain back exactly at upright. Every page keeps its own angle, so the page underneath is already in place when one turns. Reduce Motion swaps the turn for an instant change."
92
+ ],
93
+ [
94
+ "Screen readers",
95
+ "One adjustable element whose value is the full date; increment turns to the next day, decrement turns back, within the limits."
96
+ ]
97
+ ],
98
+ "usage": "import { useState } from 'react';\nimport { FlipCalendar } from '@/components/ui/flip-calendar';\n\nconst [date, setDate] = useState(new Date());\n<FlipCalendar value={date} onChange={setDate} minDate={new Date()} />",
99
+ "examples": [
100
+ {
101
+ "name": "Desk",
102
+ "title": "Desk",
103
+ "code": "import React, { useState } from 'react';\nimport { Pressable, StyleSheet, Text, View } from 'react-native';\nimport { FlipCalendar } from '@/components/ui/flip-calendar';\nimport { useKit } from '@/lib/kit';\n\n/** Controls stop at iOS's largest standard text size; content text stays uncapped. */\nconst FONT_CAP = 1.35;\n\n/** From the Expo Go viewer: `screen` = this IS the phone screen, so no mock frame; `bottomInset` = home indicator. */\ntype DemoProps = { screen?: boolean; bottomInset?: number };\n\nconst atMidnight = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());\n\n/** \"Today\", \"Tomorrow\", \"In 5 days\" or \"3 days ago\". */\nfunction relative(date: Date, today: Date) {\n const n = Math.round((atMidnight(date).getTime() - atMidnight(today).getTime()) / 86400000);\n if (n === 0) return 'Today';\n if (n === 1) return 'Tomorrow';\n if (n === -1) return 'Yesterday';\n return n > 0 ? `In ${n} days` : `${-n} days ago`;\n}\n\nconst s = StyleSheet.create({\n frame: { alignSelf: 'stretch', minHeight: 520, alignItems: 'center', justifyContent: 'center', gap: 18, paddingTop: 24, paddingHorizontal: 24 },\n frameScreen: { flex: 1 },\n head: { alignItems: 'center', gap: 2 },\n title: { fontSize: 22, fontWeight: '700' },\n sub: { fontSize: 15 },\n hint: { fontSize: 13, textAlign: 'center' },\n button: { minHeight: 40, paddingHorizontal: 18, alignItems: 'center', justifyContent: 'center', borderWidth: StyleSheet.hairlineWidth },\n buttonText: { fontSize: 14, fontWeight: '600' },\n});\n\nexport default function Desk({ screen, bottomInset = 0 }: DemoProps) {\n const { colors, radius } = useKit();\n const [today] = useState(() => new Date());\n const [date, setDate] = useState(today);\n return (\n <View\n style={[\n s.frame,\n { backgroundColor: colors.muted, paddingBottom: 24 + bottomInset },\n screen ? s.frameScreen : { borderRadius: radius.xl, borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth },\n ]}\n >\n <View style={s.head}>\n <Text style={[s.title, { color: colors.foreground }]}>Pick-up date</Text>\n <Text style={[s.sub, { color: colors.mutedForeground }]}>{relative(date, today)}</Text>\n </View>\n <FlipCalendar value={date} onChange={setDate} accent={colors.danger} accessibilityLabel=\"Pick-up date\" />\n <Text style={[s.hint, { color: colors.mutedForeground }]}>Lift a page to turn it · pull down to go back</Text>\n <Pressable\n accessibilityRole=\"button\"\n onPress={() => setDate(today)}\n style={({ pressed }) => [s.button, { borderColor: colors.border, backgroundColor: colors.card, borderRadius: radius.capsule(40), opacity: pressed ? 0.7 : 1 }]}\n >\n <Text maxFontSizeMultiplier={FONT_CAP} style={[s.buttonText, { color: colors.foreground }]}>Back to today</Text>\n </Pressable>\n </View>\n );\n}\n"
104
+ }
105
+ ]
106
+ }
@@ -17,7 +17,7 @@
17
17
  [
18
18
  "placeholder",
19
19
  "string",
20
- "\"Search\"",
20
+ "labels.search",
21
21
  "Placeholder, also the field's accessibility label."
22
22
  ],
23
23
  [
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "name": "grain",
3
3
  "category": "Display",
4
- "aliases": [],
4
+ "aliases": [
5
+ "grain-gradient",
6
+ "gradient",
7
+ "mesh-gradient",
8
+ "noise"
9
+ ],
5
10
  "exportName": "GrainGradient",
6
11
  "files": [
7
12
  "src/components/ui/grain.tsx"
@@ -73,6 +73,12 @@
73
73
  "string",
74
74
  "auto summary",
75
75
  "What screen readers announce; defaults to a summary of the data."
76
+ ],
77
+ [
78
+ "accessibilityHint",
79
+ "string",
80
+ "\"Swipe up or down to read each …\"",
81
+ "Screen-reader hint for the swipe-to-read gesture; pass your own to translate it."
76
82
  ]
77
83
  ],
78
84
  "behavior": [],
@@ -25,7 +25,7 @@
25
25
  {
26
26
  "name": "Preview",
27
27
  "title": "Preview",
28
- "code": "import React, { useEffect, useRef, useState } from 'react';\nimport { Animated, Image, Platform, Pressable, ScrollView, StyleSheet, Text, View, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native';\nimport { BlurView } from 'expo-blur';\nimport Svg, { Circle, Path } from 'react-native-svg';\nimport { useKit } from '@/lib/kit';\n\n/** Control text stops at iOS's largest standard size. */\nconst FONT_CAP = 1.35;\n\ntype TabKey = 'index' | 'explore' | 'alerts' | 'profile';\n\nconst TABS: { key: TabKey; title: string; badge?: string; icon: (c: string) => React.ReactNode }[] = [\n { key: 'index', title: 'Home', icon: (c) => <Path d=\"M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z\" stroke={c} strokeWidth={2} strokeLinejoin=\"round\" fill=\"none\" /> },\n {\n key: 'explore',\n title: 'Explore',\n icon: (c) => (\n <>\n <Circle cx={12} cy={12} r={9} stroke={c} strokeWidth={2} fill=\"none\" />\n <Path d=\"m15.5 8.5-2 5-5 2 2-5z\" stroke={c} strokeWidth={2} strokeLinejoin=\"round\" fill=\"none\" />\n </>\n ),\n },\n { key: 'alerts', title: 'Alerts', badge: '3', icon: (c) => <Path d=\"M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10 21h4\" stroke={c} strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\" fill=\"none\" /> },\n {\n key: 'profile',\n title: 'Profile',\n icon: (c) => (\n <>\n <Circle cx={12} cy={8} r={4} stroke={c} strokeWidth={2} fill=\"none\" />\n <Path d=\"M4 21c1.5-4 4.5-6 8-6s6.5 2 8 6\" stroke={c} strokeWidth={2} strokeLinecap=\"round\" fill=\"none\" />\n </>\n ),\n },\n];\n\nconst PHOTOS = [\n 'photo-1501785888041-af3ef285b470',\n 'photo-1469474968028-56623f02e42e',\n 'photo-1500530855697-b586d89ba3ee',\n 'photo-1470071459604-3b5ec3a7fe05',\n 'photo-1441974231531-c6227db76b6e',\n].map((id) => `https://images.unsplash.com/${id}?w=700&auto=format&fit=crop`);\n\nconst COPY: Record<TabKey, { title: string; sub: string }> = {\n index: { title: 'For you', sub: 'Places picked for this weekend' },\n explore: { title: 'Explore', sub: 'Trending trails near you' },\n alerts: { title: 'Alerts', sub: '3 new since yesterday' },\n profile: { title: 'Profile', sub: 'Trips, saves and settings' },\n};\n\nconst BAR_H = 64;\n\nfunction GlassBar({\n value,\n onChange,\n dark,\n collapsed,\n onExpand,\n}: {\n value: TabKey;\n onChange: (k: TabKey) => void;\n dark: boolean;\n collapsed: boolean;\n onExpand: () => void;\n}) {\n const [w, setW] = useState(0);\n // 0 = full bar, 1 = folded into a circle (JS driver: it animates the bar's width).\n const fold = useRef(new Animated.Value(0)).current;\n useEffect(() => {\n Animated.spring(fold, { toValue: collapsed ? 1 : 0, useNativeDriver: false, damping: 29, stiffness: 220, mass: 0.9 }).start();\n }, [collapsed, fold]);\n const width = fold.interpolate({ inputRange: [0, 1], outputRange: [w || 1, BAR_H] });\n const rowOpacity = fold.interpolate({ inputRange: [0, 0.45], outputRange: [1, 0], extrapolate: 'clamp' });\n const dotOpacity = fold.interpolate({ inputRange: [0.55, 1], outputRange: [0, 1], extrapolate: 'clamp' });\n const dotScale = fold.interpolate({ inputRange: [0.55, 1], outputRange: [0.6, 1], extrapolate: 'clamp' });\n const active = TABS.find((t) => t.key === value) ?? TABS[0];\n const x = useRef(new Animated.Value(0)).current;\n const stretch = useRef(new Animated.Value(1)).current;\n const index = TABS.findIndex((t) => t.key === value);\n const itemW = w ? (w - 8) / TABS.length : 0;\n const tint = dark ? '#0a84ff' : '#007aff';\n const inactive = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';\n\n useEffect(() => {\n if (!itemW) return;\n Animated.parallel([\n Animated.spring(x, { toValue: index * itemW, useNativeDriver: true, damping: 29, stiffness: 220, mass: 0.9 }),\n Animated.sequence([\n Animated.timing(stretch, { toValue: 1.22, duration: 110, useNativeDriver: true }),\n Animated.spring(stretch, { toValue: 1, useNativeDriver: true, damping: 33, stiffness: 260 }),\n ]),\n ]).start();\n }, [index, itemW, x, stretch]);\n\n return (\n <View pointerEvents=\"box-none\" style={styles.host} onLayout={(e: LayoutChangeEvent) => setW(Math.round(e.nativeEvent.layout.width))}>\n <Animated.View style={[styles.shadow, dark ? styles.shadowDark : styles.shadowLight, w ? { width } : null]}>\n <View style={[styles.glass, { borderColor: dark ? 'rgba(255,255,255,0.14)' : 'rgba(255,255,255,0.8)' }]}>\n <BlurView intensity={Platform.OS === 'ios' ? 70 : 40} tint={dark ? 'dark' : 'light'} style={[StyleSheet.absoluteFill, styles.round]} />\n <View style={[StyleSheet.absoluteFill, { backgroundColor: dark ? 'rgba(28,28,34,0.5)' : 'rgba(255,255,255,0.55)' }]} />\n <View style={[styles.rim, { backgroundColor: dark ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.95)' }]} />\n {itemW ? (\n <Animated.View\n style={[\n styles.bubble,\n {\n width: itemW,\n backgroundColor: dark ? 'rgba(255,255,255,0.14)' : 'rgba(0,0,0,0.06)',\n transform: [{ translateX: x }, { scaleX: stretch }, { scaleY: stretch.interpolate({ inputRange: [1, 1.22], outputRange: [1, 0.88] }) }],\n },\n ]}\n />\n ) : null}\n <Animated.View pointerEvents={collapsed ? 'none' : 'auto'} style={[styles.row, w ? { width: w } : null, { opacity: rowOpacity }]}>\n {TABS.map((t) => {\n const on = t.key === value;\n const c = on ? tint : inactive;\n return (\n <Pressable\n key={t.key}\n onPress={() => onChange(t.key)}\n accessibilityRole=\"tab\"\n accessibilityState={{ selected: on }}\n accessibilityLabel={t.title}\n style={styles.item}\n >\n <View>\n <Svg width={24} height={24} viewBox=\"0 0 24 24\">\n {t.icon(c)}\n </Svg>\n {t.badge ? (\n <View style={styles.badge}>\n <Text maxFontSizeMultiplier={FONT_CAP} style={styles.badgeText}>{t.badge}</Text>\n </View>\n ) : null}\n </View>\n <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.label, { color: c }]}>{t.title}</Text>\n </Pressable>\n );\n })}\n </Animated.View>\n <Animated.View pointerEvents={collapsed ? 'auto' : 'none'} style={[styles.dot, { opacity: dotOpacity, transform: [{ scale: dotScale }] }]}>\n <Pressable onPress={onExpand} accessibilityRole=\"button\" accessibilityLabel={`${active.title}. Show tab bar`} style={styles.dotPress}>\n <Svg width={26} height={26} viewBox=\"0 0 24 24\">\n {active.icon(tint)}\n </Svg>\n </Pressable>\n </Animated.View>\n </View>\n </Animated.View>\n </View>\n );\n}\n\nconst styles = StyleSheet.create({\n root: { flex: 1 },\n feed: { paddingHorizontal: 16, paddingTop: 12, paddingBottom: 120, gap: 12 },\n large: { fontSize: 32, lineHeight: 38, letterSpacing: -0.8, fontWeight: '400' },\n sub: { fontSize: 15, marginTop: -6, marginBottom: 6 },\n card: { height: 190, borderRadius: 22, overflow: 'hidden', justifyContent: 'flex-end', padding: 16 },\n cardShade: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.18)' },\n cardTitle: { color: '#ffffff', fontSize: 20, fontWeight: '400', letterSpacing: -0.3 },\n host: { position: 'absolute', left: 20, right: 20, bottom: 24 },\n shadow: { height: BAR_H, borderRadius: BAR_H / 2 },\n shadowLight: { boxShadow: '0 10px 28px -8px rgba(0,0,0,0.22)' },\n shadowDark: { boxShadow: '0 10px 28px -8px rgba(0,0,0,0.7)' },\n glass: { flex: 1, borderRadius: 32, overflow: 'hidden', borderWidth: StyleSheet.hairlineWidth * 2 },\n round: { borderRadius: 32 },\n rim: { position: 'absolute', top: 0, left: 24, right: 24, height: 1 },\n bubble: { position: 'absolute', top: 4, bottom: 4, left: 4, borderRadius: 28 },\n row: { flex: 1, flexDirection: 'row', paddingHorizontal: 4 },\n dot: { position: 'absolute', top: 0, left: 0, width: BAR_H, height: BAR_H },\n dotPress: { flex: 1, alignItems: 'center', justifyContent: 'center' },\n item: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 2 },\n label: { fontSize: 10.5, fontWeight: '400' },\n badge: { position: 'absolute', top: -4, right: -10, minWidth: 17, minHeight: 17, borderRadius: 9, paddingHorizontal: 4, backgroundColor: '#ff3b30', alignItems: 'center', justifyContent: 'center' },\n badgeText: { color: '#ffffff', fontSize: 10.5, fontWeight: '400' },\n});\n\nexport default function Preview() {\n const { dark } = useKit();\n const [tab, setTab] = useState<TabKey>('index');\n const [collapsed, setCollapsed] = useState(false);\n const lastY = useRef(0);\n const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {\n const y = e.nativeEvent.contentOffset.y;\n const dy = y - lastY.current;\n lastY.current = y;\n if (y < 24) setCollapsed(false);\n else if (dy > 6) setCollapsed(true);\n else if (dy < -6) setCollapsed(false);\n };\n const fg = dark ? '#ffffff' : '#1c1c1e';\n const sub = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';\n const offset = TABS.findIndex((t) => t.key === tab);\n const copy = COPY[tab];\n\n return (\n <View style={[styles.root, { backgroundColor: dark ? '#000000' : '#f2f2f7' }]}>\n <ScrollView contentContainerStyle={styles.feed} showsVerticalScrollIndicator={false} onScroll={onScroll} scrollEventThrottle={16}>\n <Text style={[styles.large, { color: fg }]}>{copy.title}</Text>\n <Text style={[styles.sub, { color: sub }]}>{copy.sub}</Text>\n {[0, 1, 2, 3, 4].map((i) => {\n const p = PHOTOS[(i + offset) % PHOTOS.length];\n return (\n <View key={`${tab}-${i}`} style={styles.card}>\n <Image source={{ uri: p }} alt=\"\" style={StyleSheet.absoluteFill} resizeMode=\"cover\" />\n <View style={styles.cardShade} />\n <Text maxFontSizeMultiplier={FONT_CAP} style={styles.cardTitle}>{['Lake Braies', 'Dolomites', 'Valley trail', 'Blue ridge', 'Old forest'][(i + offset) % 5]}</Text>\n </View>\n );\n })}\n </ScrollView>\n <GlassBar value={tab} onChange={setTab} dark={dark} collapsed={collapsed} onExpand={() => setCollapsed(false)} />\n </View>\n );\n}\n"
28
+ "code": "import React, { useEffect, useRef, useState } from 'react';\nimport { Animated, Image, Platform, Pressable, ScrollView, StyleSheet, Text, View, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native';\nimport { BlurView } from 'expo-blur';\nimport Svg, { Circle, Path } from 'react-native-svg';\nimport { useKit } from '@/lib/kit';\n\n/** Control text stops at iOS's largest standard size. */\nconst FONT_CAP = 1.35;\n\ntype TabKey = 'index' | 'explore' | 'alerts' | 'profile';\n\ntype DemoTab = { key: TabKey; title: string; badge?: string; icon: (c: string) => React.ReactNode };\n\nconst TABS: [DemoTab, ...DemoTab[]] = [\n { key: 'index', title: 'Home', icon: (c) => <Path d=\"M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z\" stroke={c} strokeWidth={2} strokeLinejoin=\"round\" fill=\"none\" /> },\n {\n key: 'explore',\n title: 'Explore',\n icon: (c) => (\n <>\n <Circle cx={12} cy={12} r={9} stroke={c} strokeWidth={2} fill=\"none\" />\n <Path d=\"m15.5 8.5-2 5-5 2 2-5z\" stroke={c} strokeWidth={2} strokeLinejoin=\"round\" fill=\"none\" />\n </>\n ),\n },\n { key: 'alerts', title: 'Alerts', badge: '3', icon: (c) => <Path d=\"M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10 21h4\" stroke={c} strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\" fill=\"none\" /> },\n {\n key: 'profile',\n title: 'Profile',\n icon: (c) => (\n <>\n <Circle cx={12} cy={8} r={4} stroke={c} strokeWidth={2} fill=\"none\" />\n <Path d=\"M4 21c1.5-4 4.5-6 8-6s6.5 2 8 6\" stroke={c} strokeWidth={2} strokeLinecap=\"round\" fill=\"none\" />\n </>\n ),\n },\n];\n\nconst PHOTOS = [\n 'photo-1501785888041-af3ef285b470',\n 'photo-1469474968028-56623f02e42e',\n 'photo-1500530855697-b586d89ba3ee',\n 'photo-1470071459604-3b5ec3a7fe05',\n 'photo-1441974231531-c6227db76b6e',\n].map((id) => `https://images.unsplash.com/${id}?w=700&auto=format&fit=crop`);\n\nconst COPY: Record<TabKey, { title: string; sub: string }> = {\n index: { title: 'For you', sub: 'Places picked for this weekend' },\n explore: { title: 'Explore', sub: 'Trending trails near you' },\n alerts: { title: 'Alerts', sub: '3 new since yesterday' },\n profile: { title: 'Profile', sub: 'Trips, saves and settings' },\n};\n\nconst BAR_H = 64;\n\nfunction GlassBar({\n value,\n onChange,\n dark,\n collapsed,\n onExpand,\n}: {\n value: TabKey;\n onChange: (k: TabKey) => void;\n dark: boolean;\n collapsed: boolean;\n onExpand: () => void;\n}) {\n const [w, setW] = useState(0);\n // 0 = full bar, 1 = folded into a circle (JS driver: it animates the bar's width).\n const fold = useRef(new Animated.Value(0)).current;\n useEffect(() => {\n Animated.spring(fold, { toValue: collapsed ? 1 : 0, useNativeDriver: false, damping: 29, stiffness: 220, mass: 0.9 }).start();\n }, [collapsed, fold]);\n const width = fold.interpolate({ inputRange: [0, 1], outputRange: [w || 1, BAR_H] });\n const rowOpacity = fold.interpolate({ inputRange: [0, 0.45], outputRange: [1, 0], extrapolate: 'clamp' });\n const dotOpacity = fold.interpolate({ inputRange: [0.55, 1], outputRange: [0, 1], extrapolate: 'clamp' });\n const dotScale = fold.interpolate({ inputRange: [0.55, 1], outputRange: [0.6, 1], extrapolate: 'clamp' });\n const active = TABS.find((t) => t.key === value) ?? TABS[0];\n const x = useRef(new Animated.Value(0)).current;\n const stretch = useRef(new Animated.Value(1)).current;\n const index = TABS.findIndex((t) => t.key === value);\n const itemW = w ? (w - 8) / TABS.length : 0;\n const tint = dark ? '#0a84ff' : '#007aff';\n const inactive = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';\n\n useEffect(() => {\n if (!itemW) return;\n Animated.parallel([\n Animated.spring(x, { toValue: index * itemW, useNativeDriver: true, damping: 29, stiffness: 220, mass: 0.9 }),\n Animated.sequence([\n Animated.timing(stretch, { toValue: 1.22, duration: 110, useNativeDriver: true }),\n Animated.spring(stretch, { toValue: 1, useNativeDriver: true, damping: 33, stiffness: 260 }),\n ]),\n ]).start();\n }, [index, itemW, x, stretch]);\n\n return (\n <View pointerEvents=\"box-none\" style={styles.host} onLayout={(e: LayoutChangeEvent) => setW(Math.round(e.nativeEvent.layout.width))}>\n <Animated.View style={[styles.shadow, dark ? styles.shadowDark : styles.shadowLight, w ? { width } : null]}>\n <View style={[styles.glass, { borderColor: dark ? 'rgba(255,255,255,0.14)' : 'rgba(255,255,255,0.8)' }]}>\n <BlurView intensity={Platform.OS === 'ios' ? 70 : 40} tint={dark ? 'dark' : 'light'} style={[StyleSheet.absoluteFill, styles.round]} />\n <View style={[StyleSheet.absoluteFill, { backgroundColor: dark ? 'rgba(28,28,34,0.5)' : 'rgba(255,255,255,0.55)' }]} />\n <View style={[styles.rim, { backgroundColor: dark ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.95)' }]} />\n {itemW ? (\n <Animated.View\n style={[\n styles.bubble,\n {\n width: itemW,\n backgroundColor: dark ? 'rgba(255,255,255,0.14)' : 'rgba(0,0,0,0.06)',\n transform: [{ translateX: x }, { scaleX: stretch }, { scaleY: stretch.interpolate({ inputRange: [1, 1.22], outputRange: [1, 0.88] }) }],\n },\n ]}\n />\n ) : null}\n <Animated.View pointerEvents={collapsed ? 'none' : 'auto'} style={[styles.row, w ? { width: w } : null, { opacity: rowOpacity }]}>\n {TABS.map((t) => {\n const on = t.key === value;\n const c = on ? tint : inactive;\n return (\n <Pressable\n key={t.key}\n onPress={() => onChange(t.key)}\n accessibilityRole=\"tab\"\n accessibilityState={{ selected: on }}\n accessibilityLabel={t.title}\n style={styles.item}\n >\n <View>\n <Svg width={24} height={24} viewBox=\"0 0 24 24\">\n {t.icon(c)}\n </Svg>\n {t.badge ? (\n <View style={styles.badge}>\n <Text maxFontSizeMultiplier={FONT_CAP} style={styles.badgeText}>{t.badge}</Text>\n </View>\n ) : null}\n </View>\n <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.label, { color: c }]}>{t.title}</Text>\n </Pressable>\n );\n })}\n </Animated.View>\n <Animated.View pointerEvents={collapsed ? 'auto' : 'none'} style={[styles.dot, { opacity: dotOpacity, transform: [{ scale: dotScale }] }]}>\n <Pressable onPress={onExpand} accessibilityRole=\"button\" accessibilityLabel={`${active.title}. Show tab bar`} style={styles.dotPress}>\n <Svg width={26} height={26} viewBox=\"0 0 24 24\">\n {active.icon(tint)}\n </Svg>\n </Pressable>\n </Animated.View>\n </View>\n </Animated.View>\n </View>\n );\n}\n\nconst styles = StyleSheet.create({\n root: { flex: 1 },\n feed: { paddingHorizontal: 16, paddingTop: 12, paddingBottom: 120, gap: 12 },\n large: { fontSize: 32, lineHeight: 38, letterSpacing: -0.8, fontWeight: '400' },\n sub: { fontSize: 15, marginTop: -6, marginBottom: 6 },\n card: { height: 190, borderRadius: 22, overflow: 'hidden', justifyContent: 'flex-end', padding: 16 },\n cardShade: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.18)' },\n cardTitle: { color: '#ffffff', fontSize: 20, fontWeight: '400', letterSpacing: -0.3 },\n host: { position: 'absolute', left: 20, right: 20, bottom: 24 },\n shadow: { height: BAR_H, borderRadius: BAR_H / 2 },\n shadowLight: { boxShadow: '0 10px 28px -8px rgba(0,0,0,0.22)' },\n shadowDark: { boxShadow: '0 10px 28px -8px rgba(0,0,0,0.7)' },\n glass: { flex: 1, borderRadius: 32, overflow: 'hidden', borderWidth: StyleSheet.hairlineWidth * 2 },\n round: { borderRadius: 32 },\n rim: { position: 'absolute', top: 0, left: 24, right: 24, height: 1 },\n bubble: { position: 'absolute', top: 4, bottom: 4, left: 4, borderRadius: 28 },\n row: { flex: 1, flexDirection: 'row', paddingHorizontal: 4 },\n dot: { position: 'absolute', top: 0, left: 0, width: BAR_H, height: BAR_H },\n dotPress: { flex: 1, alignItems: 'center', justifyContent: 'center' },\n item: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 2 },\n label: { fontSize: 10.5, fontWeight: '400' },\n badge: { position: 'absolute', top: -4, right: -10, minWidth: 17, minHeight: 17, borderRadius: 9, paddingHorizontal: 4, backgroundColor: '#ff3b30', alignItems: 'center', justifyContent: 'center' },\n badgeText: { color: '#ffffff', fontSize: 10.5, fontWeight: '400' },\n});\n\nexport default function Preview() {\n const { dark } = useKit();\n const [tab, setTab] = useState<TabKey>('index');\n const [collapsed, setCollapsed] = useState(false);\n const lastY = useRef(0);\n const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {\n const y = e.nativeEvent.contentOffset.y;\n const dy = y - lastY.current;\n lastY.current = y;\n if (y < 24) setCollapsed(false);\n else if (dy > 6) setCollapsed(true);\n else if (dy < -6) setCollapsed(false);\n };\n const fg = dark ? '#ffffff' : '#1c1c1e';\n const sub = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';\n const offset = TABS.findIndex((t) => t.key === tab);\n const copy = COPY[tab];\n\n return (\n <View style={[styles.root, { backgroundColor: dark ? '#000000' : '#f2f2f7' }]}>\n <ScrollView contentContainerStyle={styles.feed} showsVerticalScrollIndicator={false} onScroll={onScroll} scrollEventThrottle={16}>\n <Text style={[styles.large, { color: fg }]}>{copy.title}</Text>\n <Text style={[styles.sub, { color: sub }]}>{copy.sub}</Text>\n {[0, 1, 2, 3, 4].map((i) => {\n const p = PHOTOS[(i + offset) % PHOTOS.length];\n return (\n <View key={`${tab}-${i}`} style={styles.card}>\n <Image source={{ uri: p }} alt=\"\" style={StyleSheet.absoluteFill} resizeMode=\"cover\" />\n <View style={styles.cardShade} />\n <Text maxFontSizeMultiplier={FONT_CAP} style={styles.cardTitle}>{['Lake Braies', 'Dolomites', 'Valley trail', 'Blue ridge', 'Old forest'][(i + offset) % 5]}</Text>\n </View>\n );\n })}\n </ScrollView>\n <GlassBar value={tab} onChange={setTab} dark={dark} collapsed={collapsed} onExpand={() => setCollapsed(false)} />\n </View>\n );\n}\n"
29
29
  }
30
30
  ]
31
31
  }