uifolio 0.5.5 → 0.7.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.
package/cli/uifolio.mjs CHANGED
@@ -13,6 +13,7 @@
13
13
  * Options:
14
14
  * --cwd <dir> target project (default: current directory)
15
15
  * --dir <path> components folder (default: src/components/ui)
16
+ * --animations <path> animations folder (default: src/components/animations)
16
17
  * --lib <path> helpers folder (default: src/lib)
17
18
  * --alias <alias> target import alias (default: @)
18
19
  * --color <hex> primary color, written in the Tailwind tokens (@theme)
@@ -31,20 +32,32 @@
31
32
  * node cli/uifolio.mjs add video-player --alias "#lib" --dry-run
32
33
  */
33
34
 
35
+ import { execFileSync } from 'node:child_process'
34
36
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
35
- import { basename, dirname, join, relative, resolve } from 'node:path'
37
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
36
38
  import { createInterface } from 'node:readline/promises'
37
39
  import { fileURLToPath } from 'node:url'
38
40
 
39
41
  const HERE = dirname(fileURLToPath(import.meta.url))
40
42
  const REGISTRY_SRC = resolve(HERE, '..', 'src')
41
43
  const REGISTRY_UI = join(REGISTRY_SRC, 'components', 'ui')
44
+ const REGISTRY_ANIMATIONS = join(REGISTRY_SRC, 'components', 'animations')
42
45
  const CONFIG_NAME = 'uifolio.json'
43
46
 
47
+ /** Stand-alone animations (the providers/contexts are helpers, not entries). */
48
+ const ANIMATION_NAMES = [
49
+ 'horizontal-accordion',
50
+ 'horizontal-story',
51
+ 'image-crossfade',
52
+ 'scroll-text',
53
+ 'stacking-cards',
54
+ ]
55
+
44
56
  const LIB_DEPS = ['clsx', 'tailwind-merge'] // cn() — always copied with the helpers
45
57
 
46
58
  const DEFAULT_ACCENT = '#10b981'
47
59
  const DEFAULT_COMPONENTS_DIR = 'src/components/ui'
60
+ const DEFAULT_ANIMATIONS_DIR = 'src/components/animations'
48
61
  const THEME_FILE = 'uifolio-theme.css'
49
62
 
50
63
  /** Global CSS candidates for wiring the Tailwind theme. */
@@ -52,8 +65,32 @@ const GLOBAL_CSS_CANDIDATES = [
52
65
  'src/index.css',
53
66
  'src/app.css',
54
67
  'src/globals.css',
68
+ 'src/styles/globals.css',
69
+ 'src/styles/index.css',
70
+ 'src/main.css',
71
+ 'src/style.css',
72
+ 'app/globals.css',
55
73
  ]
56
74
 
75
+ /** Folders never scanned when looking for the project's stylesheets. */
76
+ const IGNORED_DIRS = new Set([
77
+ 'node_modules',
78
+ 'dist',
79
+ 'build',
80
+ 'out',
81
+ 'coverage',
82
+ 'public',
83
+ '.git',
84
+ '.next',
85
+ '.vite',
86
+ '.turbo',
87
+ ])
88
+
89
+ const MAX_SCAN_DEPTH = 6
90
+
91
+ /** A Tailwind CSS 4 entrypoint: `@import 'tailwindcss';` or `@tailwind base;`. */
92
+ const TAILWIND_ENTRY = /@import\s+['"]tailwindcss(?:[^'"]*)['"]|@tailwind\s+base/
93
+
57
94
  /* -------------------------------------------------------------------------- */
58
95
  /* Utilitaires */
59
96
  /* -------------------------------------------------------------------------- */
@@ -67,6 +104,10 @@ function ok(message) {
67
104
  console.log(`\x1b[32m✓\x1b[0m ${message}`)
68
105
  }
69
106
 
107
+ function warn(message) {
108
+ console.log(`\x1b[33m!\x1b[0m ${message}`)
109
+ }
110
+
70
111
  function info(message) {
71
112
  console.log(` ${message}`)
72
113
  }
@@ -117,7 +158,7 @@ function writeFailure(error, target) {
117
158
  fail(`Cannot write ${target}: ${reasons[error?.code] ?? error?.message ?? error}.`)
118
159
  }
119
160
 
120
- function listComponents() {
161
+ function listUiComponents() {
121
162
  if (!existsSync(REGISTRY_UI)) return []
122
163
  return readdirSync(REGISTRY_UI)
123
164
  .filter((name) => name.endsWith('.tsx'))
@@ -125,6 +166,24 @@ function listComponents() {
125
166
  .sort()
126
167
  }
127
168
 
169
+ function listAnimationComponents() {
170
+ if (!existsSync(REGISTRY_ANIMATIONS)) return []
171
+ return ANIMATION_NAMES.filter((name) => existsSync(join(REGISTRY_ANIMATIONS, `${name}.tsx`)))
172
+ }
173
+
174
+ function listComponents() {
175
+ return [...listUiComponents(), ...listAnimationComponents()].sort()
176
+ }
177
+
178
+ /** Absolute entry + destination kind of a registry name. */
179
+ function registryEntry(name) {
180
+ const ui = join(REGISTRY_UI, `${name}.tsx`)
181
+ if (existsSync(ui)) return { path: ui, kind: 'ui' }
182
+ const animation = join(REGISTRY_ANIMATIONS, `${name}.tsx`)
183
+ if (existsSync(animation)) return { path: animation, kind: 'animations' }
184
+ return null
185
+ }
186
+
128
187
  function readConfig(cwd) {
129
188
  const file = join(cwd, CONFIG_NAME)
130
189
  if (!existsSync(file)) return null
@@ -135,14 +194,19 @@ function readConfig(cwd) {
135
194
  }
136
195
  }
137
196
 
138
- /** Extrait les imports locaux (alias) d'un fichier source. */
197
+ /** Local imports (alias or relative) of a source file. */
139
198
  function localImports(source) {
140
199
  const specifiers = new Set()
141
200
  const importRe = /(?:from\s+|import\s*\(\s*|import\s+)['"]([^'"]+)['"]/g
142
201
  let match
143
202
  while ((match = importRe.exec(source)) !== null) {
144
203
  const specifier = match[1]
145
- if (specifier.startsWith('@/') || specifier.startsWith('~/')) {
204
+ if (
205
+ specifier.startsWith('@/') ||
206
+ specifier.startsWith('~/') ||
207
+ specifier.startsWith('./') ||
208
+ specifier.startsWith('../')
209
+ ) {
146
210
  specifiers.add(specifier)
147
211
  }
148
212
  }
@@ -276,12 +340,14 @@ function collectFiles(entryPath, srcRoot) {
276
340
 
277
341
  const source = readFileSync(current, 'utf8')
278
342
  for (const specifier of localImports(source)) {
279
- const base = specifier.replace(/^[@~]\//, '')
343
+ const base = specifier.startsWith('.')
344
+ ? resolve(dirname(current), specifier)
345
+ : join(srcRoot, specifier.replace(/^[@~]\//, ''))
280
346
  const candidates = [
281
- join(srcRoot, `${base}.ts`),
282
- join(srcRoot, `${base}.tsx`),
283
- join(srcRoot, base, 'index.ts'),
284
- join(srcRoot, base, 'index.tsx'),
347
+ `${base}.ts`,
348
+ `${base}.tsx`,
349
+ join(base, 'index.ts'),
350
+ join(base, 'index.tsx'),
285
351
  ]
286
352
  const found = candidates.find((candidate) => existsSync(candidate))
287
353
  if (found) queue.push(found)
@@ -389,19 +455,276 @@ ${darkTokens.join('\n')}
389
455
  `
390
456
  }
391
457
 
392
- /** CSS global du projet (celui qui importe Tailwind), sinon null. */
458
+ /** Every stylesheet of the project, outside the build/ignored folders. */
459
+ function listCssFiles(cwd) {
460
+ const found = []
461
+ const walk = (dir, depth) => {
462
+ if (depth > MAX_SCAN_DEPTH) return
463
+ let entries
464
+ try {
465
+ entries = readdirSync(dir, { withFileTypes: true })
466
+ } catch {
467
+ return
468
+ }
469
+ for (const entry of entries) {
470
+ const full = join(dir, entry.name)
471
+ if (entry.isDirectory()) {
472
+ if (entry.name.startsWith('.') || IGNORED_DIRS.has(entry.name)) continue
473
+ walk(full, depth + 1)
474
+ } else if (/\.css$/i.test(entry.name)) {
475
+ found.push(full)
476
+ }
477
+ }
478
+ }
479
+ walk(resolve(cwd), 0)
480
+ return found.sort()
481
+ }
482
+
483
+ /**
484
+ * CSS global du projet: celui qui importe Tailwind en priorité, quel que soit
485
+ * son chemin (src/index.css, src/styles/globals.css, app/globals.css…).
486
+ */
393
487
  function findGlobalCss(cwd) {
394
- for (const candidate of GLOBAL_CSS_CANDIDATES) {
395
- const file = join(cwd, candidate)
396
- if (!existsSync(file)) continue
397
- const source = readFileSync(file, 'utf8')
398
- if (/@import\s+['"]tailwindcss|@tailwind\b/.test(source)) return file
488
+ const known = GLOBAL_CSS_CANDIDATES.map((candidate) => join(resolve(cwd), candidate))
489
+ const candidates = [...new Set([...known, ...listCssFiles(cwd)])].filter((file) =>
490
+ existsSync(file),
491
+ )
492
+ const withTailwind = candidates.find((file) => TAILWIND_ENTRY.test(readFileSync(file, 'utf8')))
493
+ return withTailwind ?? candidates[0] ?? null
494
+ }
495
+
496
+ /** A dependency of the target project's manifest? */
497
+ function hasDependency(cwd, name) {
498
+ const file = join(cwd, 'package.json')
499
+ if (!existsSync(file)) return false
500
+ try {
501
+ const manifest = JSON.parse(readFileSync(file, 'utf8'))
502
+ return ['dependencies', 'devDependencies', 'peerDependencies'].some((key) =>
503
+ Boolean(manifest[key]?.[name]),
504
+ )
505
+ } catch {
506
+ return false
399
507
  }
400
- for (const candidate of GLOBAL_CSS_CANDIDATES) {
401
- const file = join(cwd, candidate)
402
- if (existsSync(file)) return file
508
+ }
509
+
510
+ /**
511
+ * Tailwind must be installed *and* imported by a CSS file: writing the tokens
512
+ * is not enough, without Tailwind the classes (bg-primary, fixed…) do not exist.
513
+ */
514
+ function tailwindStatus(cwd, globalCss) {
515
+ const hasTailwind = hasDependency(cwd, 'tailwindcss')
516
+ const hasEntry =
517
+ Boolean(globalCss) &&
518
+ existsSync(globalCss) &&
519
+ TAILWIND_ENTRY.test(readFileSync(globalCss, 'utf8'))
520
+ return { hasDependency: hasTailwind, hasEntry }
521
+ }
522
+
523
+ /** Package manager of the target project, from its lockfile. */
524
+ function packageManager(cwd, dev = true) {
525
+ const flag = dev ? ['-D'] : []
526
+ if (existsSync(join(cwd, 'pnpm-lock.yaml'))) return { command: 'pnpm', args: ['add', ...flag] }
527
+ if (existsSync(join(cwd, 'yarn.lock'))) return { command: 'yarn', args: ['add', ...flag] }
528
+ if (existsSync(join(cwd, 'bun.lockb')) || existsSync(join(cwd, 'bun.lock'))) {
529
+ return { command: 'bun', args: ['add', ...(dev ? ['-d'] : [])] }
403
530
  }
404
- return null
531
+ return { command: 'npm', args: ['install', ...flag] }
532
+ }
533
+
534
+ /**
535
+ * The components import npm packages (clsx, lucide-react, hls.js…): install the
536
+ * ones missing from the manifest so the copied files compile right away.
537
+ */
538
+ function installDependencies({ cwd, deps, dryRun }) {
539
+ const missing = [...deps].filter((dep) => !hasDependency(cwd, dep)).sort()
540
+ const manager = packageManager(cwd, false)
541
+ const command = [manager.command, ...manager.args, ...missing].join(' ')
542
+ if (missing.length === 0) return { missing, command }
543
+
544
+ if (dryRun) {
545
+ info(`[dry-run] ${command}`)
546
+ return { missing, command }
547
+ }
548
+ // Not a JS project: nothing to install into, print the command instead.
549
+ if (!existsSync(join(cwd, 'package.json'))) return { missing, command }
550
+
551
+ info(`Installing the npm dependencies: ${command}`)
552
+ try {
553
+ execFileSync(manager.command, [...manager.args, ...missing], {
554
+ cwd,
555
+ stdio: 'inherit',
556
+ shell: process.platform === 'win32',
557
+ })
558
+ } catch {
559
+ warn('The automatic install failed: run the command above yourself.')
560
+ return { missing, command }
561
+ }
562
+ ok(`Installed: ${missing.join(', ')}`)
563
+ return { missing: [], command }
564
+ }
565
+
566
+ const VITE_CONFIGS = [
567
+ 'vite.config.ts',
568
+ 'vite.config.js',
569
+ 'vite.config.mjs',
570
+ 'vite.config.mts',
571
+ 'vite.config.cjs',
572
+ 'vite.config.cts',
573
+ ]
574
+
575
+ /** Modules that load the global CSS in a Vite/React project. */
576
+ const ENTRY_MODULES = [
577
+ 'src/main.tsx',
578
+ 'src/main.jsx',
579
+ 'src/main.ts',
580
+ 'src/main.js',
581
+ 'src/index.tsx',
582
+ 'src/index.jsx',
583
+ 'app/layout.tsx',
584
+ 'app/layout.jsx',
585
+ ]
586
+
587
+ function findFirst(cwd, names) {
588
+ return names.map((name) => join(cwd, name)).find((file) => existsSync(file)) ?? null
589
+ }
590
+
591
+ /** Adds `import tailwindcss from '@tailwindcss/vite'` and `tailwindcss()`. */
592
+ function wireViteConfig(source) {
593
+ if (source.includes('@tailwindcss/vite')) return source
594
+ const plugins = /plugins\s*:\s*\[/.exec(source)
595
+ if (!plugins) return null
596
+ const at = plugins.index + plugins[0].length
597
+ const next = `${source.slice(0, at)}tailwindcss(), ${source.slice(at)}`
598
+ const lines = next.split('\n')
599
+ const lastImport = lines.reduce((last, line, index) => (/^\s*import\s/.test(line) ? index : last), -1)
600
+ const importLine = "import tailwindcss from '@tailwindcss/vite'"
601
+ if (lastImport === -1) return `${importLine}\n${next}`
602
+ lines.splice(lastImport + 1, 0, importLine)
603
+ return lines.join('\n')
604
+ }
605
+
606
+ /** Creates the Tailwind entrypoint when the project has no stylesheet at all. */
607
+ function defaultCssPath(cwd) {
608
+ if (existsSync(join(cwd, 'src'))) return join(cwd, 'src', 'index.css')
609
+ if (existsSync(join(cwd, 'app'))) return join(cwd, 'app', 'globals.css')
610
+ return join(cwd, 'index.css')
611
+ }
612
+
613
+ /**
614
+ * Installs Tailwind CSS 4 and wires it (CSS entry, entry module, Vite plugin)
615
+ * when the target project does not have it. Without Tailwind no utility class
616
+ * exists, so the components would render unstyled.
617
+ */
618
+ function setupTailwind({ cwd, globalCss, dryRun }) {
619
+ // Not a JS project (or nothing to install into): keep the manual instructions.
620
+ if (!existsSync(join(cwd, 'package.json'))) {
621
+ return { globalCss, reason: 'no-package' }
622
+ }
623
+
624
+ const usesVite = Boolean(findFirst(cwd, VITE_CONFIGS)) || hasDependency(cwd, 'vite')
625
+ const manager = packageManager(cwd)
626
+ const packages = usesVite ? ['tailwindcss', '@tailwindcss/vite'] : ['tailwindcss']
627
+ const command = [manager.command, ...manager.args, ...packages].join(' ')
628
+
629
+ if (!hasDependency(cwd, 'tailwindcss')) {
630
+ if (dryRun) info(`[dry-run] ${command}`)
631
+ else info(`Tailwind CSS missing, installing it: ${command}`)
632
+ }
633
+ if (!dryRun && !hasDependency(cwd, 'tailwindcss')) {
634
+ try {
635
+ execFileSync(manager.command, [...manager.args, ...packages], {
636
+ cwd,
637
+ stdio: 'inherit',
638
+ shell: process.platform === 'win32',
639
+ })
640
+ ok('Tailwind CSS installed')
641
+ } catch {
642
+ warn('The automatic install failed.')
643
+ return { globalCss, reason: 'install-failed' }
644
+ }
645
+ }
646
+
647
+ // 1. A stylesheet that imports Tailwind.
648
+ let cssFile = globalCss
649
+ if (!cssFile) {
650
+ cssFile = defaultCssPath(cwd)
651
+ if (dryRun) {
652
+ info(`[dry-run] create ${relative(cwd, cssFile)} with "@import 'tailwindcss';"`)
653
+ } else {
654
+ mkdirSync(dirname(cssFile), { recursive: true })
655
+ writeFileSync(cssFile, "@import 'tailwindcss';\n", 'utf8')
656
+ ok(`Tailwind entry created: ${relative(cwd, cssFile)}`)
657
+ }
658
+ } else if (!TAILWIND_ENTRY.test(readFileSync(cssFile, 'utf8'))) {
659
+ if (dryRun) {
660
+ info(`[dry-run] add "@import 'tailwindcss';" to ${relative(cwd, cssFile)}`)
661
+ } else {
662
+ const source = readFileSync(cssFile, 'utf8')
663
+ writeFileSync(cssFile, `@import 'tailwindcss';\n\n${source}`, 'utf8')
664
+ ok(`Tailwind imported in ${relative(cwd, cssFile)}`)
665
+ }
666
+ }
667
+
668
+ // 2. That stylesheet must be loaded by the app entry.
669
+ const entryModule = findFirst(cwd, ENTRY_MODULES)
670
+ const loadsCss = Boolean(entryModule) && readFileSync(entryModule, 'utf8').includes(basename(cssFile))
671
+ if (entryModule && !loadsCss) {
672
+ if (dryRun) {
673
+ info(`[dry-run] import ${basename(cssFile)} in ${relative(cwd, entryModule)}`)
674
+ } else {
675
+ const specifier = relative(dirname(entryModule), cssFile).replace(/\\/g, '/')
676
+ const source = readFileSync(entryModule, 'utf8')
677
+ writeFileSync(
678
+ entryModule,
679
+ `import '${specifier.startsWith('.') ? '' : './'}${specifier}'\n${source}`,
680
+ 'utf8',
681
+ )
682
+ ok(`CSS imported in ${relative(cwd, entryModule)}`)
683
+ }
684
+ }
685
+
686
+ // 3. Vite only runs Tailwind through its plugin.
687
+ if (usesVite) {
688
+ const config = findFirst(cwd, VITE_CONFIGS)
689
+ if (!config) {
690
+ warn('No vite.config.* found: add tailwindcss() to your Vite plugins.')
691
+ } else {
692
+ const source = readFileSync(config, 'utf8')
693
+ const updated = wireViteConfig(source)
694
+ if (updated === null) {
695
+ warn(`Add the tailwindcss() plugin in ${relative(cwd, config)} (no "plugins: [" found).`)
696
+ } else if (updated !== source && dryRun) {
697
+ info(`[dry-run] add tailwindcss() to ${relative(cwd, config)}`)
698
+ } else if (updated !== source) {
699
+ writeFileSync(config, updated, 'utf8')
700
+ ok(`tailwindcss() plugin added to ${relative(cwd, config)}`)
701
+ }
702
+ }
703
+ } else {
704
+ info('Tailwind 4 also needs @tailwindcss/postcss in your PostCSS config.')
705
+ }
706
+
707
+ return { globalCss: cssFile, reason: dryRun ? 'dry-run' : 'ready' }
708
+ }
709
+
710
+ /** Actionable steps instead of a silent unstyled page. */
711
+ function reportMissingTailwind({ cwd, globalCss, themeFile, status }) {
712
+ const cssLabel = globalCss ? relative(cwd, globalCss) : 'your global CSS'
713
+ const specifier = globalCss
714
+ ? `./${relative(dirname(globalCss), themeFile).replace(/\\/g, '/')}`
715
+ : `./${THEME_FILE}`
716
+ console.log('')
717
+ if (!status.hasDependency) {
718
+ const manager = packageManager(cwd)
719
+ warn('Tailwind CSS is not installed in this project: the components will render unstyled.')
720
+ info([manager.command, ...manager.args, 'tailwindcss', '@tailwindcss/vite'].join(' '))
721
+ info(`Then add "@import 'tailwindcss';" at the top of ${cssLabel}`)
722
+ info('and tailwindcss() to the Vite plugins (plugins: [react(), tailwindcss()]).')
723
+ } else {
724
+ warn(`Tailwind CSS is installed but ${cssLabel} does not import it yet.`)
725
+ info(`Add "@import 'tailwindcss';" at the top of ${cssLabel}.`)
726
+ }
727
+ info(`Then "@import '${specifier}';", or re-run "uifolio add" to wire it automatically.`)
405
728
  }
406
729
 
407
730
  /** Adds `@import './uifolio-theme.css';` right after the Tailwind import. */
@@ -430,7 +753,16 @@ function existingRadius(file) {
430
753
  }
431
754
 
432
755
  function writeTheme({ cwd, accent, radius, dryRun }) {
433
- const globalCss = findGlobalCss(cwd)
756
+ let globalCss = findGlobalCss(cwd)
757
+
758
+ // Install + wire Tailwind first: the tokens alone style nothing.
759
+ if (!tailwindStatus(cwd, globalCss).hasEntry) {
760
+ console.log('')
761
+ info('Tailwind CSS is not set up in this project: configuring it.')
762
+ globalCss = setupTailwind({ cwd, globalCss, dryRun }).globalCss
763
+ }
764
+
765
+ const status = tailwindStatus(cwd, globalCss)
434
766
  const themeFile = globalCss
435
767
  ? join(dirname(globalCss), THEME_FILE)
436
768
  : join(cwd, THEME_FILE)
@@ -450,15 +782,19 @@ function writeTheme({ cwd, accent, radius, dryRun }) {
450
782
  ok(`Tailwind theme written to ${label}`)
451
783
  info(`--primary: ${finalAccent}${finalRadius === undefined ? '' : ` · --radius: ${finalRadius}px`}`)
452
784
 
453
- if (globalCss) {
454
- const source = readFileSync(globalCss, 'utf8')
455
- const specifier = `./${basename(themeFile)}`
456
- const updated = wireThemeImport(source, specifier)
457
- if (updated !== source) writeFileSync(globalCss, updated, 'utf8')
458
- info(`imported in ${relative(cwd, globalCss)} — no hard-coded colors in the components`)
459
- } else {
460
- info(`Add "@import './${THEME_FILE}';", at the top of your global CSS (the Tailwind one).`)
785
+ // The tokens only matter once Tailwind actually generates the utilities:
786
+ // without a `@import 'tailwindcss'`, wiring the theme would look installed
787
+ // and still render an unstyled page.
788
+ if (!status.hasEntry) {
789
+ reportMissingTailwind({ cwd, globalCss, themeFile, status })
790
+ return
461
791
  }
792
+
793
+ const source = readFileSync(globalCss, 'utf8')
794
+ const specifier = `./${basename(themeFile)}`
795
+ const updated = wireThemeImport(source, specifier)
796
+ if (updated !== source) writeFileSync(globalCss, updated, 'utf8')
797
+ info(`imported in ${relative(cwd, globalCss)} — no hard-coded colors in the components`)
462
798
  }
463
799
 
464
800
  /* -------------------------------------------------------------------------- */
@@ -578,6 +914,40 @@ const USAGE_EXAMPLES = {
578
914
  <p className="text-sm">Fade in on mount.</p>
579
915
  </FadeIn>
580
916
  )
917
+ `,
918
+ },
919
+ 'horizontal-accordion': {
920
+ body: ` return <HorizontalAccordion count={6} />
921
+ `,
922
+ },
923
+ 'horizontal-story': {
924
+ body: ` return (
925
+ <HorizontalStory
926
+ count={6}
927
+ palette={{ mode: 'gradient', from: '#0EA5E9', to: '#8B5CF6' }}
928
+ />
929
+ )
930
+ `,
931
+ },
932
+ 'stacking-cards': {
933
+ body: ` return (
934
+ <StackingCards count={6} palette={{ mode: 'solid', color: '#10B981' }} />
935
+ )
936
+ `,
937
+ },
938
+ 'scroll-text': {
939
+ body: ` return (
940
+ <ScrollText
941
+ prefix="C"
942
+ text="Colors light up letter by letter as you scroll."
943
+ />
944
+ )
945
+ `,
946
+ },
947
+ 'image-crossfade': {
948
+ body: ` return (
949
+ <ImageCrossfade overlay="linear-gradient(to top, rgba(0,0,0,0.7), transparent)" />
950
+ )
581
951
  `,
582
952
  },
583
953
  table: {
@@ -737,14 +1107,14 @@ function addJsxUsage(source, exportNames) {
737
1107
  * Writes (or completes) the chosen file: `@/` imports from the real folder of
738
1108
  * the components, a working example (`<CalendarExample />`) and its definition.
739
1109
  */
740
- function writeUsage({ cwd, target, componentsDir, components, alias, dryRun }) {
1110
+ function writeUsage({ cwd, target, components, alias, dryRun }) {
741
1111
  if (existsSync(target) && statSync(target).isDirectory()) {
742
1112
  fail(`${relative(cwd, target)} is a folder: give a file (e.g. src/pages/${components[0].name}.tsx).`)
743
1113
  }
744
1114
  const created = !existsSync(target)
745
1115
  const entries = components.map((component) => ({
746
1116
  exportName: component.exportName,
747
- specifier: aliasImport(cwd, componentsDir, component.name, alias),
1117
+ specifier: aliasImport(cwd, component.dir, component.name, alias),
748
1118
  example: exampleFor(component),
749
1119
  }))
750
1120
  const label = relative(cwd, target)
@@ -823,15 +1193,26 @@ const ALIASES = {
823
1193
  shortcuts: 'command-menu',
824
1194
  upload: 'file-upload',
825
1195
  video: 'video-player',
1196
+ storytelling: 'horizontal-story',
1197
+ stacking: 'stacking-cards',
826
1198
  }
827
1199
 
828
1200
  function commandList() {
829
- const components = listComponents()
830
- if (components.length === 0) fail('No component found in the registry.')
1201
+ const components = listUiComponents()
1202
+ const animations = listAnimationComponents()
1203
+ if (components.length === 0 && animations.length === 0) {
1204
+ fail('No component found in the registry.')
1205
+ }
831
1206
  console.log('\nAvailable components:\n')
832
1207
  for (const name of components) {
833
1208
  console.log(` \x1b[36m${name.padEnd(20)}\x1b[0m src/components/ui/${name}.tsx`)
834
1209
  }
1210
+ if (animations.length > 0) {
1211
+ console.log('\nAvailable animations:\n')
1212
+ for (const name of animations) {
1213
+ console.log(` \x1b[36m${name.padEnd(20)}\x1b[0m src/components/animations/${name}.tsx`)
1214
+ }
1215
+ }
835
1216
  console.log('\nUsage: uifolio add <name> [--cwd <target project>]\n')
836
1217
  }
837
1218
 
@@ -843,12 +1224,14 @@ function commandInit({ flags }) {
843
1224
  const config = {
844
1225
  $schema: 'https://uifolio.dev/schema.json',
845
1226
  dir: typeof flags.dir === 'string' ? flags.dir : DEFAULT_COMPONENTS_DIR,
1227
+ animations:
1228
+ typeof flags.animations === 'string' ? flags.animations : DEFAULT_ANIMATIONS_DIR,
846
1229
  lib: typeof flags.lib === 'string' ? flags.lib : 'src/lib',
847
1230
  alias: typeof flags.alias === 'string' ? flags.alias : '@',
848
1231
  }
849
1232
  writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
850
1233
  ok(`${CONFIG_NAME} written to ${cwd}`)
851
- info(`dir: ${config.dir} · lib: ${config.lib} · alias: ${config.alias}`)
1234
+ info(`dir: ${config.dir} · animations: ${config.animations} · lib: ${config.lib} · alias: ${config.alias}`)
852
1235
  info('Next: uifolio add video-player calendar …')
853
1236
  }
854
1237
 
@@ -928,13 +1311,21 @@ async function commandAdd({ positional, flags }) {
928
1311
 
929
1312
  const prompter = createPrompter(flags)
930
1313
  try {
931
- // 1. Dossier d'import des composants : toujours src/components/ui.
1314
+ // 1. Dossiers d'import : composants, animations et helpers.
932
1315
  const componentsDir = normalizeComponentsDir(
933
1316
  typeof flags.dir === 'string' ? flags.dir : config.dir ?? DEFAULT_COMPONENTS_DIR,
934
1317
  )
935
1318
  if (looksLikeFile(componentsDir)) {
936
1319
  fail(`"${componentsDir}" is a file: give a folder (e.g. ${DEFAULT_COMPONENTS_DIR}).`)
937
1320
  }
1321
+ const animationsDir = normalizeComponentsDir(
1322
+ typeof flags.animations === 'string'
1323
+ ? flags.animations
1324
+ : config.animations ?? DEFAULT_ANIMATIONS_DIR,
1325
+ )
1326
+ if (looksLikeFile(animationsDir)) {
1327
+ fail(`"${animationsDir}" is a file: give a folder (e.g. ${DEFAULT_ANIMATIONS_DIR}).`)
1328
+ }
938
1329
 
939
1330
  // 2. Copie des fichiers + helpers.
940
1331
  const deps = new Set(LIB_DEPS)
@@ -944,14 +1335,26 @@ async function commandAdd({ positional, flags }) {
944
1335
  const components = []
945
1336
 
946
1337
  for (const name of names) {
947
- const entry = join(REGISTRY_UI, `${name}.tsx`)
948
- const files = collectFiles(entry, REGISTRY_SRC)
949
- components.push({ name, exportName: componentExportName(readFileSync(entry, 'utf8'), name) })
1338
+ const entry = registryEntry(name)
1339
+ const files = collectFiles(entry.path, REGISTRY_SRC)
1340
+ const dir = entry.kind === 'animations' ? animationsDir : componentsDir
1341
+ components.push({
1342
+ name,
1343
+ exportName: componentExportName(readFileSync(entry.path, 'utf8'), name),
1344
+ dir,
1345
+ })
950
1346
 
951
1347
  for (const [absolute, relativePath] of files) {
952
- const isUi = relativePath.startsWith(join('components', 'ui'))
953
- const destinationRoot = isUi ? resolve(cwd, componentsDir) : resolve(cwd, libDir)
954
- const destination = join(destinationRoot, relativePath.replace(/^(components\/ui|lib)\//, ''))
1348
+ const segments = relativePath.split(sep)
1349
+ const kind = segments[0] === 'components' ? segments[1] : segments[0]
1350
+ const destinationRoot =
1351
+ kind === 'ui'
1352
+ ? resolve(cwd, componentsDir)
1353
+ : kind === 'animations'
1354
+ ? resolve(cwd, animationsDir)
1355
+ : resolve(cwd, libDir)
1356
+ const inner = kind === 'lib' ? relativePath.slice('lib'.length + 1) : segments.slice(2).join(sep)
1357
+ const destination = join(destinationRoot, inner)
955
1358
  if (destinations.has(destination)) continue
956
1359
  destinations.add(destination)
957
1360
 
@@ -982,7 +1385,10 @@ async function commandAdd({ positional, flags }) {
982
1385
  info(`${skipped.length} file(s) already present, skipped: ${skipped.join(', ')} (use --force to overwrite)`)
983
1386
  }
984
1387
 
985
- // 3. Tokens Tailwind : les classes bg-primary & co fonctionnent partout.
1388
+ // 3. npm dependencies imported by the copied files.
1389
+ const dependencies = installDependencies({ cwd, deps, dryRun })
1390
+
1391
+ // 4. Tokens Tailwind : les classes bg-primary & co fonctionnent partout.
986
1392
  writeTheme({ cwd, accent, radius, dryRun })
987
1393
 
988
1394
  // 4. Component usage, after installation.
@@ -999,19 +1405,18 @@ async function commandAdd({ positional, flags }) {
999
1405
  writeUsage({
1000
1406
  cwd,
1001
1407
  target: pageTarget,
1002
- componentsDir,
1003
1408
  components,
1004
1409
  alias: targetAlias,
1005
1410
  dryRun,
1006
1411
  })
1007
1412
  }
1008
1413
 
1009
- if (written.size > 0 || dryRun) {
1414
+ if (targetAlias !== '@') {
1415
+ info(`Remember to align the ${targetAlias} alias in your tsconfig/vite (instead of @/).`)
1416
+ }
1417
+ if (dependencies.missing.length > 0 && !dryRun && written.size > 0) {
1010
1418
  console.log('\n Detected npm dependencies:')
1011
- info(`npm install ${[...deps].sort().join(' ')}`)
1012
- if (targetAlias !== '@') {
1013
- info(`Remember to align the ${targetAlias} alias in your tsconfig/vite (instead of @/).`)
1014
- }
1419
+ info(dependencies.command)
1015
1420
  console.log('')
1016
1421
  }
1017
1422
  } finally {
@@ -1034,6 +1439,7 @@ function usage() {
1034
1439
  Options:
1035
1440
  --cwd <dir> target project (default: current directory)
1036
1441
  --dir <path> components folder (default: src/components/ui)
1442
+ --animations <path> animations folder (default: src/components/animations)
1037
1443
  --lib <path> helpers folder (default: src/lib)
1038
1444
  --alias <alias> target import alias (default: @)
1039
1445
  --color <hex> primary color, written in the Tailwind tokens (@theme)
@@ -1046,14 +1452,22 @@ Options:
1046
1452
 
1047
1453
  Without --page, the CLI asks whether a file is already ready to receive the
1048
1454
  components: they are added to that file, to a new folder + file, or installed
1049
- only. Files go to src/components/ui (override with --dir) and the page imports
1050
- them through the alias (@/components/ui/...). The page also gets a working
1051
- example, with the data and props each component needs, so it shows immediately.
1455
+ only. Components go to src/components/ui (override with --dir), animations to
1456
+ src/components/animations (override with --animations), and the page imports
1457
+ them through the alias (@/components/ui/..., @/components/animations/...). The
1458
+ page also gets a working example, with the data and props each component needs,
1459
+ so it shows immediately.
1052
1460
  Colors go through a Tailwind CSS 4 theme (uifolio-theme.css, @theme), so classes
1053
- like bg-primary and text-muted-foreground stay in the code.
1461
+ like bg-primary and text-muted-foreground stay in the code. When Tailwind is
1462
+ missing, "add" installs it (with your lockfile's package manager), creates the
1463
+ CSS entry, imports it in the app entry and adds the tailwindcss() Vite plugin;
1464
+ without Tailwind no utility class exists and the components render unstyled.
1465
+ The npm packages the components import (clsx, lucide-react, hls.js…) are
1466
+ installed too, as runtime dependencies, when the manifest lacks them.
1054
1467
 
1055
1468
  Examples:
1056
1469
  node cli/uifolio.mjs add video-player calendar otp --cwd ../my-project
1470
+ node cli/uifolio.mjs add horizontal-accordion storytelling --cwd ../my-project
1057
1471
  node cli/uifolio.mjs add otp --color "#10B981" --radius 8
1058
1472
  node cli/uifolio.mjs add calendar --page src/pages/calendar.tsx
1059
1473
  `)