@expokit/cli 0.4.4 → 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 (239) hide show
  1. package/README.md +30 -3
  2. package/bin/expo-kit.js +749 -288
  3. package/bin/lib.mjs +310 -3
  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/glass-search-bar.json +1 -1
  12. package/registry/_docs/grain.json +6 -1
  13. package/registry/_docs/isometric-bar.json +6 -0
  14. package/registry/_docs/liquidglass-tab.json +1 -1
  15. package/registry/_docs/password-input.json +1 -1
  16. package/registry/_docs/pixel-bar.json +6 -0
  17. package/registry/_docs/polar-area.json +6 -0
  18. package/registry/_docs/qr-code-button.json +9 -3
  19. package/registry/_docs/scratch-card.json +7 -1
  20. package/registry/_docs/search-bar.json +1 -1
  21. package/registry/_docs/select.json +2 -2
  22. package/registry/_docs/slide-to-confirm.json +2 -2
  23. package/registry/_docs/spinner.json +1 -1
  24. package/registry/_docs/stateful-button.json +2 -2
  25. package/registry/_docs/swipe-deck.json +1 -1
  26. package/registry/_docs/time-picker.json +2 -2
  27. package/registry/_docs/timeline-chart.json +6 -0
  28. package/registry/_docs/toast.json +15 -3
  29. package/registry/_docs/treemap.json +6 -0
  30. package/registry/_docs/waterfall.json +6 -0
  31. package/registry/accordion/files/src/components/ui/accordion.tsx +6 -4
  32. package/registry/accordion/files/src/lib/kit.tsx +363 -11
  33. package/registry/accordion.json +1 -0
  34. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +43 -48
  35. package/registry/action-sheet/files/src/lib/kit.tsx +363 -11
  36. package/registry/action-sheet.json +1 -0
  37. package/registry/alert/files/src/components/ui/alert.tsx +19 -12
  38. package/registry/alert/files/src/lib/kit.tsx +363 -11
  39. package/registry/alert.json +3 -4
  40. package/registry/assistant-app.json +1 -0
  41. package/registry/avatar/files/src/components/ui/avatar.tsx +84 -66
  42. package/registry/avatar/files/src/lib/kit.tsx +363 -11
  43. package/registry/avatar.json +1 -0
  44. package/registry/badge/files/src/components/ui/badge.tsx +34 -34
  45. package/registry/badge/files/src/lib/kit.tsx +363 -11
  46. package/registry/badge.json +1 -0
  47. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +28 -27
  48. package/registry/bottom-nav/files/src/lib/kit.tsx +363 -11
  49. package/registry/bottom-nav.json +5 -0
  50. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +353 -289
  51. package/registry/bottom-sheet/files/src/lib/kit.tsx +363 -11
  52. package/registry/bottom-sheet.json +5 -1
  53. package/registry/bullet.json +1 -0
  54. package/registry/button/files/src/components/ui/button.tsx +18 -16
  55. package/registry/button/files/src/lib/kit.tsx +363 -11
  56. package/registry/button-3d/files/src/components/ui/button-3d.tsx +2 -2
  57. package/registry/button-3d/files/src/lib/kit.tsx +363 -11
  58. package/registry/button-3d.json +1 -0
  59. package/registry/button.json +1 -0
  60. package/registry/calendar/files/src/components/ui/calendar.tsx +74 -76
  61. package/registry/calendar/files/src/lib/kit.tsx +363 -11
  62. package/registry/calendar.json +1 -0
  63. package/registry/card/files/src/components/ui/card.tsx +3 -3
  64. package/registry/card/files/src/lib/kit.tsx +363 -11
  65. package/registry/card-stack.json +1 -0
  66. package/registry/card.json +1 -0
  67. package/registry/carousel.json +1 -0
  68. package/registry/chat.json +7 -0
  69. package/registry/checkbox/files/src/components/ui/checkbox.tsx +4 -4
  70. package/registry/checkbox/files/src/lib/kit.tsx +363 -11
  71. package/registry/checkbox.json +1 -0
  72. package/registry/checkout.json +1 -0
  73. package/registry/chip-group/files/src/components/ui/chip-group.tsx +22 -22
  74. package/registry/chip-group/files/src/lib/kit.tsx +363 -11
  75. package/registry/chip-group.json +1 -0
  76. package/registry/confetti/files/src/components/ui/confetti.tsx +15 -14
  77. package/registry/confetti.json +3 -6
  78. package/registry/coupon/files/src/components/ui/coupon.tsx +42 -42
  79. package/registry/coupon/files/src/components/ui/flip-card.tsx +6 -5
  80. package/registry/coupon/files/src/components/ui/tilt-card.tsx +76 -91
  81. package/registry/coupon/files/src/lib/kit.tsx +363 -11
  82. package/registry/coupon/files/src/lib/ticket-shape.tsx +54 -44
  83. package/registry/coupon.json +1 -0
  84. package/registry/cube-pager.json +1 -0
  85. package/registry/dashboard.json +1 -0
  86. package/registry/date-picker/files/src/components/ui/calendar.tsx +74 -76
  87. package/registry/date-picker/files/src/components/ui/date-picker.tsx +55 -56
  88. package/registry/date-picker/files/src/lib/kit.tsx +363 -11
  89. package/registry/date-picker.json +1 -0
  90. package/registry/dialog/files/src/components/ui/dialog.tsx +60 -56
  91. package/registry/dialog/files/src/lib/kit.tsx +363 -11
  92. package/registry/dialog.json +1 -0
  93. package/registry/donut-shop.json +1 -0
  94. package/registry/drawer/files/src/components/ui/drawer.tsx +73 -69
  95. package/registry/drawer/files/src/lib/kit.tsx +363 -11
  96. package/registry/drawer.json +1 -0
  97. package/registry/fab.json +1 -0
  98. package/registry/fintech-app.json +1 -0
  99. package/registry/flip-calendar/files/src/components/ui/flip-calendar.tsx +92 -86
  100. package/registry/flip-calendar/files/src/lib/kit.tsx +363 -11
  101. package/registry/flip-calendar.json +7 -0
  102. package/registry/flip-card/files/src/components/ui/flip-card.tsx +6 -5
  103. package/registry/flip-card/files/src/lib/kit.tsx +363 -11
  104. package/registry/flip-card.json +1 -0
  105. package/registry/floating-input/files/src/components/ui/floating-input.tsx +33 -33
  106. package/registry/floating-input/files/src/lib/kit.tsx +363 -11
  107. package/registry/floating-input.json +1 -0
  108. package/registry/food-app.json +1 -0
  109. package/registry/gift-card.json +1 -0
  110. package/registry/glass-button.json +5 -0
  111. package/registry/glass-nav-bar.json +1 -0
  112. package/registry/glass-search-bar.json +1 -0
  113. package/registry/glass-segmented.json +1 -0
  114. package/registry/glass-sheet.json +1 -0
  115. package/registry/glass-tab-bar.json +5 -0
  116. package/registry/glass.json +1 -0
  117. package/registry/grain/files/src/components/ui/grain.tsx +16 -14
  118. package/registry/grain.json +8 -6
  119. package/registry/inbox.json +1 -0
  120. package/registry/isometric-bar.json +1 -0
  121. package/registry/kit/files/src/lib/kit.tsx +363 -11
  122. package/registry/kit.json +1 -0
  123. package/registry/liquidglass-tab/files/src/app/_layout.tsx +0 -1
  124. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +23 -9
  125. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +2 -2
  126. package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +1 -1
  127. package/registry/liquidglass-tab.json +1 -0
  128. package/registry/list-item/files/src/components/ui/list-item.tsx +42 -42
  129. package/registry/list-item/files/src/lib/kit.tsx +363 -11
  130. package/registry/list-item.json +1 -0
  131. package/registry/loyalty-card.json +1 -0
  132. package/registry/morphing-button-group.json +1 -0
  133. package/registry/number-ticker.json +1 -0
  134. package/registry/offers.json +1 -0
  135. package/registry/onboarding.json +1 -0
  136. package/registry/orb-reveal.json +1 -0
  137. package/registry/otp-input/files/src/components/ui/otp-input.tsx +48 -29
  138. package/registry/otp-input/files/src/lib/kit.tsx +363 -11
  139. package/registry/otp-input.json +1 -0
  140. package/registry/parallax-layers.json +1 -0
  141. package/registry/parallax-scroll.json +1 -0
  142. package/registry/password-input/files/src/components/ui/floating-input.tsx +33 -33
  143. package/registry/password-input/files/src/components/ui/password-input.tsx +29 -25
  144. package/registry/password-input/files/src/lib/kit.tsx +363 -11
  145. package/registry/password-input.json +1 -0
  146. package/registry/payment-card/files/src/components/ui/flip-card.tsx +6 -5
  147. package/registry/payment-card/files/src/components/ui/payment-card.tsx +17 -17
  148. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +76 -91
  149. package/registry/payment-card/files/src/lib/kit.tsx +363 -11
  150. package/registry/payment-card.json +1 -0
  151. package/registry/paywall.json +1 -0
  152. package/registry/pixel-bar.json +1 -0
  153. package/registry/polar-area.json +1 -0
  154. package/registry/profile.json +1 -0
  155. package/registry/progress/files/src/components/ui/progress.tsx +29 -29
  156. package/registry/progress/files/src/lib/kit.tsx +363 -11
  157. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +20 -20
  158. package/registry/progress-steps/files/src/lib/kit.tsx +363 -11
  159. package/registry/progress-steps.json +1 -0
  160. package/registry/progress.json +1 -0
  161. package/registry/qr-code-button.json +1 -0
  162. package/registry/radio-group/files/src/components/ui/radio-group.tsx +19 -19
  163. package/registry/radio-group/files/src/lib/kit.tsx +363 -11
  164. package/registry/radio-group.json +1 -0
  165. package/registry/rating.json +1 -0
  166. package/registry/ring-carousel.json +1 -0
  167. package/registry/ruler-time-picker/files/src/components/ui/ruler-time-picker.tsx +98 -82
  168. package/registry/ruler-time-picker/files/src/lib/kit.tsx +363 -11
  169. package/registry/ruler-time-picker.json +7 -0
  170. package/registry/scratch-card.json +1 -0
  171. package/registry/search-bar/files/src/components/ui/search-bar.tsx +25 -21
  172. package/registry/search-bar/files/src/lib/kit.tsx +363 -11
  173. package/registry/search-bar.json +1 -0
  174. package/registry/search-results.json +4 -0
  175. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +8 -7
  176. package/registry/segmented-control/files/src/lib/kit.tsx +363 -11
  177. package/registry/segmented-control.json +1 -0
  178. package/registry/select/files/src/components/ui/select.tsx +93 -88
  179. package/registry/select/files/src/lib/kit.tsx +363 -11
  180. package/registry/select.json +6 -0
  181. package/registry/settings.json +1 -0
  182. package/registry/sign-in.json +1 -0
  183. package/registry/skeleton/files/src/components/ui/skeleton.tsx +149 -80
  184. package/registry/skeleton/files/src/lib/kit.tsx +363 -11
  185. package/registry/skeleton.json +1 -0
  186. package/registry/slide-to-confirm.json +1 -0
  187. package/registry/slider/files/src/components/ui/slider.tsx +103 -96
  188. package/registry/slider/files/src/lib/kit.tsx +363 -11
  189. package/registry/slider.json +5 -0
  190. package/registry/spin-wheel.json +1 -0
  191. package/registry/spinner/files/src/components/ui/spinner.tsx +39 -29
  192. package/registry/spinner/files/src/lib/kit.tsx +363 -11
  193. package/registry/spinner.json +6 -0
  194. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +39 -32
  195. package/registry/stateful-button/files/src/lib/kit.tsx +363 -11
  196. package/registry/stateful-button.json +5 -0
  197. package/registry/stepper/files/src/components/ui/stepper.tsx +38 -36
  198. package/registry/stepper/files/src/lib/kit.tsx +363 -11
  199. package/registry/stepper.json +1 -0
  200. package/registry/swipe-deck.json +1 -0
  201. package/registry/swipeable-row.json +4 -0
  202. package/registry/switch/files/src/components/ui/switch.tsx +39 -42
  203. package/registry/switch/files/src/lib/kit.tsx +363 -11
  204. package/registry/switch.json +1 -0
  205. package/registry/tabs/files/src/components/ui/tabs.tsx +59 -62
  206. package/registry/tabs/files/src/lib/kit.tsx +363 -11
  207. package/registry/tabs.json +1 -0
  208. package/registry/text-animation/files/src/components/ui/text-animation.tsx +55 -42
  209. package/registry/text-animation/files/src/lib/kit.tsx +363 -11
  210. package/registry/text-animation.json +7 -0
  211. package/registry/textarea/files/src/components/ui/textarea.tsx +10 -10
  212. package/registry/textarea/files/src/lib/kit.tsx +363 -11
  213. package/registry/textarea.json +1 -0
  214. package/registry/ticket-card.json +1 -0
  215. package/registry/ticket-shape/files/src/lib/kit.tsx +363 -11
  216. package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +54 -44
  217. package/registry/ticket-shape.json +1 -0
  218. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +76 -91
  219. package/registry/tilt-card/files/src/lib/kit.tsx +363 -11
  220. package/registry/tilt-card.json +1 -0
  221. package/registry/time-picker/files/src/components/ui/time-picker.tsx +122 -119
  222. package/registry/time-picker/files/src/lib/kit.tsx +363 -11
  223. package/registry/time-picker.json +1 -0
  224. package/registry/timeline/files/src/components/ui/avatar.tsx +84 -66
  225. package/registry/timeline/files/src/components/ui/timeline.tsx +44 -41
  226. package/registry/timeline/files/src/lib/kit.tsx +363 -11
  227. package/registry/timeline-chart.json +1 -0
  228. package/registry/timeline.json +1 -0
  229. package/registry/toast/files/src/components/ui/toast.tsx +541 -463
  230. package/registry/toast/files/src/lib/kit.tsx +363 -11
  231. package/registry/toast.json +7 -5
  232. package/registry/treemap.json +1 -0
  233. package/registry/waterfall.json +1 -0
  234. package/registry/wheel-list.json +1 -0
  235. package/schema/components.schema.json +1 -0
  236. package/registry/alert/files/src/components/ui/button.tsx +0 -290
  237. package/registry/confetti/files/src/lib/kit.tsx +0 -320
  238. package/registry/grain/files/src/lib/kit.tsx +0 -320
  239. package/registry/toast/files/src/components/ui/button.tsx +0 -290
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. */
@@ -346,13 +347,122 @@ export function needsDevBuild(deps) {
346
347
 
347
348
  /**
348
349
  * The component a file is named for: the PascalCase export matching one of `names` ("Grain Gradient" or
349
- * "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).
350
352
  */
351
353
  export function pickMainExport(source, names = []) {
352
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]);
353
355
  const pascal = (s) => String(s ?? '').split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join('');
354
- const wanted = names.map(pascal).map((n) => n.toLowerCase());
355
- 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;
356
466
  }
357
467
 
358
468
  /** Union of dependency lists, order preserved. */
@@ -364,6 +474,203 @@ export function mergeDependencies(...lists) {
364
474
  return out;
365
475
  }
366
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
+
367
674
  // ---------------------------------------------------------------------------
368
675
  // Site URL + token guard
369
676
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expokit/cli",
3
- "version": "0.4.4",
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",
@@ -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
  }
@@ -35,7 +35,7 @@
35
35
  [
36
36
  "matchMessage / mismatchMessage",
37
37
  "string",
38
- "\"Passwords match\" / \"Passwords don't match\"",
38
+ "labels.passwordsMatch / labels.passwordsDontMatch",
39
39
  "Copy for confirm mode."
40
40
  ],
41
41
  [
@@ -67,6 +67,12 @@
67
67
  "string",
68
68
  "auto summary",
69
69
  "What screen readers announce; defaults to a summary of the data."
70
+ ],
71
+ [
72
+ "accessibilityHint",
73
+ "string",
74
+ "\"Swipe up or down to read each …\"",
75
+ "Screen-reader hint for the swipe-to-read gesture; pass your own to translate it."
70
76
  ]
71
77
  ],
72
78
  "behavior": [],
@@ -79,6 +79,12 @@
79
79
  "string",
80
80
  "auto summary",
81
81
  "What screen readers announce; defaults to a summary of the data."
82
+ ],
83
+ [
84
+ "accessibilityHint",
85
+ "string",
86
+ "\"Swipe up or down to read each …\"",
87
+ "Screen-reader hint for the swipe-to-read gesture; pass your own to translate it."
82
88
  ]
83
89
  ],
84
90
  "behavior": [],
@@ -17,21 +17,27 @@
17
17
  [
18
18
  "label",
19
19
  "string",
20
- "'Show QR Code'",
20
+ "labels.showQrCode",
21
21
  "Text on the pill."
22
22
  ],
23
23
  [
24
24
  "copyLabel",
25
25
  "string",
26
- "'Copy'",
26
+ "labels.copy",
27
27
  "Copy button text."
28
28
  ],
29
29
  [
30
30
  "copiedLabel",
31
31
  "string",
32
- "'Copied'",
32
+ "labels.copied",
33
33
  "Shown after Copy is pressed."
34
34
  ],
35
+ [
36
+ "closeLabel",
37
+ "string",
38
+ "labels.close",
39
+ "Screen-reader name of the card's close button. Set it app-wide with KitProvider's labels."
40
+ ],
35
41
  [
36
42
  "onCopy",
37
43
  "(value: string) => void",
@@ -26,6 +26,12 @@
26
26
  "\"Scratch to reveal\" / —",
27
27
  "Printed on the foil."
28
28
  ],
29
+ [
30
+ "revealHint",
31
+ "string",
32
+ "\"Double-tap to reveal\"",
33
+ "Screen-reader hint on the foil; pass your own to translate it."
34
+ ],
29
35
  [
30
36
  "threshold",
31
37
  "number",
@@ -102,7 +108,7 @@
102
108
  ],
103
109
  [
104
110
  "Screen readers",
105
- "The foil is a button labelled with label and sublabel, with the hint 'Double-tap to reveal'."
111
+ "The foil is a button labelled with label and sublabel, with the hint revealHint (default 'Double-tap to reveal')."
106
112
  ]
107
113
  ],
108
114
  "usage": null,
@@ -41,7 +41,7 @@
41
41
  [
42
42
  "cancelLabel",
43
43
  "string",
44
- "\"Cancel\"",
44
+ "labels.cancel",
45
45
  "Cancel button text."
46
46
  ],
47
47
  [
@@ -76,7 +76,7 @@
76
76
  [
77
77
  "placeholder",
78
78
  "string",
79
- "\"Select…\"",
79
+ "labels.selectPlaceholder",
80
80
  "Shown in the field while open with nothing selected."
81
81
  ],
82
82
  [
@@ -106,7 +106,7 @@
106
106
  [
107
107
  "searchPlaceholder",
108
108
  "string",
109
- "\"Search\"",
109
+ "labels.search",
110
110
  "Placeholder of the filter field."
111
111
  ],
112
112
  [