uifolio 0.4.0 → 0.5.1

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/README.md CHANGED
@@ -72,13 +72,23 @@ node cli/uifolio.mjs add file-upload --alias "#lib" --dry-run # simulate
72
72
 
73
73
  Options : `--cwd <dir>`, `--dir <chemin>` (défaut `src/components/ui`),
74
74
  `--lib <chemin>` (défaut `src/lib`), `--alias <alias>` (défaut `@`),
75
+ `--page <chemin>` (créé s'il n'existe pas), `--interactive` (forcer les questions),
75
76
  `--force` (écraser), `--dry-run` (simuler), `--color <hex>` et `--radius <px>`.
76
77
 
77
- `--color` écrit la couleur **directement dans les fichiers copiés** : les classes
78
- Tailwind (`bg-primary`, `border-primary/50`, `ring-primary/40`…) et les `var(--primary)`
79
- deviennent la valeur hex (`bg-[#10b981]`, `border-[#10b981]/50`…). Le composant garde donc
80
- la couleur choisie même si le projet cible ne définit pas `--primary`. `--radius` (seul)
81
- écrit `uifolio-theme.css` avec l'arrondi (et les tokens de couleur par défaut).
78
+ `add` ne s'arrête pas à la copie : sans `--dir` ni `--page`, la CLI demande
79
+ **dans quel dossier** copier les composants, puis **dans quel fichier** les
80
+ utiliser. La page est créée si elle manque ; si elle existe, l'import et le
81
+ `<Composant />` sont ajoutés dans son JSX (sans doublon). Réponds `-` à la
82
+ seconde question pour installer sans toucher à une page.
83
+
84
+ Les couleurs passent par des **tokens Tailwind CSS 4** : la CLI écrit
85
+ `uifolio-theme.css` (`:root`, `.dark`, puis `@theme inline` qui expose
86
+ `--color-primary`, `--color-muted-foreground`, …) et l'importe automatiquement
87
+ dans le CSS global du projet (celui qui fait `@import 'tailwindcss'`). Les
88
+ composants gardent donc `bg-primary`, `border-primary/50`, `ring-primary/40` :
89
+ aucune couleur en dur, `--color` change `--primary` dans le thème et `--radius`
90
+ y ajoute `--radius`. Sans CSS global Tailwind détecté, le fichier est écrit à la
91
+ racine et l'import à ajouter est affiché.
82
92
 
83
93
  À la fin, la CLI affiche les dépendances npm détectées, par ex. :
84
94
 
@@ -86,9 +96,6 @@ la couleur choisie même si le projet cible ne définit pas `--primary`. `--radi
86
96
  npm install clsx tailwind-merge lucide-react hls.js
87
97
  ```
88
98
 
89
- Le projet cible doit fournir les tokens du thème (`bg-background`, `border`, `text-muted-foreground`…) :
90
- copie les variables CSS de `src/index.css` (`:root`, `.dark`, `@theme`) si ton projet ne les a pas.
91
-
92
99
  ## Thèmes clair/sombre
93
100
 
94
101
  - Tokens CSS : `:root` (clair) et `.dark` (sombre) dans `src/index.css`,
package/cli/uifolio.mjs CHANGED
@@ -6,22 +6,25 @@
6
6
  * Commandes :
7
7
  * uifolio list liste des composants disponibles
8
8
  * uifolio init [--cwd dir] écrit uifolio.json (config cible)
9
- * uifolio add <noms...> [options] copie les composants + helpers
10
- * --page <chemin> crée ou adapte la page cible
9
+ * uifolio add <noms...> [options] copie les composants + helpers,
10
+ * écrit les tokens Tailwind et utilise
11
+ * le composant dans une page
11
12
  *
12
13
  * Options :
13
14
  * --cwd <dir> projet cible (défaut : répertoire courant)
14
15
  * --dir <chemin> dossier des composants (défaut : src/components/ui)
15
16
  * --lib <chemin> dossier des helpers (défaut : src/lib)
16
17
  * --alias <alias> alias d'import cible (défaut : @)
17
- * --color <hex> intègre cette couleur aux fichiers copiés (plus de --primary)
18
+ * --color <hex> couleur primaire, écrite dans les tokens Tailwind (@theme)
18
19
  * --radius <px> écrit --radius dans uifolio-theme.css
20
+ * --page <chemin> fichier où utiliser le composant (créé s'il n'existe pas)
21
+ * --interactive force les questions même quand stdin n'est pas un terminal
19
22
  * --force écrase les fichiers existants
20
- * --page <chemin> chemin de la page (ex : src/pages/calendar.tsx)
21
- * crée la page si elle n'existe pas, sinon y ajoute
22
- * l'import et un appel de composant
23
23
  * --dry-run affiche ce qui serait copié sans rien écrire
24
24
  *
25
+ * Sans --dir / --page, la CLI demande interactivement (terminal) dans quel
26
+ * dossier copier les composants et dans quel fichier les utiliser.
27
+ *
25
28
  * Exemples :
26
29
  * node cli/uifolio.mjs add calendar otp --cwd ../mon-projet
27
30
  * node cli/uifolio.mjs add calendar --color "#10B981" --page src/pages/calendar.tsx
@@ -29,7 +32,8 @@
29
32
  */
30
33
 
31
34
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
32
- import { dirname, join, relative, resolve } from 'node:path'
35
+ import { basename, dirname, join, relative, resolve } from 'node:path'
36
+ import { createInterface } from 'node:readline/promises'
33
37
  import { fileURLToPath } from 'node:url'
34
38
 
35
39
  const HERE = dirname(fileURLToPath(import.meta.url))
@@ -39,6 +43,21 @@ const CONFIG_NAME = 'uifolio.json'
39
43
 
40
44
  const LIB_DEPS = ['clsx', 'tailwind-merge'] // cn() — toujours copié avec les helpers
41
45
 
46
+ const DEFAULT_ACCENT = '#10b981'
47
+ const DEFAULT_COMPONENTS_DIR = 'src/components/ui'
48
+ const THEME_FILE = 'uifolio-theme.css'
49
+
50
+ /** Fichiers CSS globaux candidats pour brancher le thème Tailwind. */
51
+ const GLOBAL_CSS_CANDIDATES = [
52
+ 'src/index.css',
53
+ 'src/app.css',
54
+ 'src/globals.css',
55
+ 'src/styles.css',
56
+ 'src/main.css',
57
+ 'app/globals.css',
58
+ 'styles/globals.css',
59
+ ]
60
+
42
61
  /* -------------------------------------------------------------------------- */
43
62
  /* Utilitaires */
44
63
  /* -------------------------------------------------------------------------- */
@@ -124,21 +143,19 @@ function externalDeps(source) {
124
143
  return [...deps]
125
144
  }
126
145
 
127
- /**
128
- * Liste les pages cx (fichiers .tsx dans src/pages/) du projet cible.
129
- */
146
+ /** Nom réellement exporté par le fichier (otp-input → OtpInput). */
147
+ function componentExportName(source, fallback) {
148
+ const matches = [...source.matchAll(/export\s+function\s+([A-Z][A-Za-z0-9_]*)/g)].map((match) => match[1])
149
+ return matches[0] ?? fallback
150
+ }
151
+
152
+ /** Fichiers .tsx de src/pages/ du projet cible, triés. */
130
153
  function listPagesInCwd(cwd) {
131
- if (!cwd || typeof cwd !== 'string') return []
132
154
  try {
133
- const cwdPath = resolve(cwd)
134
- const srcRoot = join(cwdPath, 'src')
135
- if (!existsSync(srcRoot)) return []
136
- const pagesDir = join(srcRoot, 'pages')
155
+ const pagesDir = join(resolve(cwd), 'src', 'pages')
137
156
  if (!existsSync(pagesDir)) return []
138
- const entries = readdirSync(pagesDir)
139
- if (!Array.isArray(entries)) return []
140
- return entries
141
- .filter((name) => typeof name === 'string' && name.endsWith('.tsx'))
157
+ return readdirSync(pagesDir)
158
+ .filter((name) => name.endsWith('.tsx'))
142
159
  .map((name) => join('src', 'pages', name))
143
160
  .sort()
144
161
  } catch {
@@ -146,71 +163,65 @@ function listPagesInCwd(cwd) {
146
163
  }
147
164
  }
148
165
 
166
+ /** Fichiers où l'usage du composant peut atterrir (indices affichés). */
167
+ function usageCandidates(cwd) {
168
+ const app = existsSync(join(resolve(cwd), 'src', 'App.tsx')) ? 'src/App.tsx' : null
169
+ const pages = listPagesInCwd(cwd).filter((page) => page !== app)
170
+ return app ? [app, ...pages] : pages
171
+ }
172
+
149
173
  /**
150
- * Demande interactivement à l'utilisateur quelle page utiliser, ou retourne
151
- * null si l'entrée n'est pas valide / la saisie est vide.
174
+ * Saisie interactive : uniquement sur un terminal (ou avec --interactive),
175
+ * jamais en CI / pipe sans données, pour ne pas bloquer l'installation.
152
176
  */
153
- function choosePageInteraction(pages, cwd) {
154
- if (!Array.isArray(pages) || pages.length === 0) return null
155
- if (pages.length === 1) {
156
- info(`Une page existe déjà : ${pages[0]} (utilisée par défaut)`)
157
- try {
158
- return join(resolve(cwd), pages[0])
159
- } catch {
160
- return null
177
+ function createPrompter(flags) {
178
+ const allowed = Boolean(flags.interactive) || process.stdin.isTTY === true
179
+ if (!allowed) return null
180
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
181
+ const queued = []
182
+ let waiting = null
183
+ let closed = false
184
+
185
+ rl.on('line', (line) => {
186
+ if (waiting) {
187
+ const resolveLine = waiting
188
+ waiting = null
189
+ resolveLine(line)
190
+ } else {
191
+ queued.push(line)
192
+ }
193
+ })
194
+ rl.on('close', () => {
195
+ closed = true
196
+ if (waiting) {
197
+ const resolveLine = waiting
198
+ waiting = null
199
+ resolveLine(null)
161
200
  }
162
- }
163
- let cwdPath = cwd
164
- try {
165
- cwdPath = resolve(cwd)
166
- } catch {
167
- cwdPath = process.cwd()
168
- }
169
-
170
- console.log('\nPlusieurs pages existent dans src/pages/. Choisis celle où importer le/des composant(s) :')
171
- pages.forEach((p, index) => {
172
- console.log(` ${index + 1}. ${p}`)
173
201
  })
174
- console.log(' 0. Ne pas importer dans une page')
175
202
 
176
- const answer = promptPageChoice()
177
- if (answer === null) {
178
- info('Aucune page sélectionnée : les composants seront copiés sans être importés dans une page.')
179
- return null
180
- }
181
- if (answer === 0) {
182
- info('Import dans une page annulé.')
183
- return null
184
- }
185
- const selected = pages[answer - 1]
186
- if (!selected) {
187
- info('Choix invalide : aucune page sélectionnée.')
188
- return null
189
- }
190
- info(`Page retenue : ${selected}`)
191
- try {
192
- return join(cwdPath, selected)
193
- } catch {
194
- return null
195
- }
196
- }
203
+ return {
204
+ async ask(question, fallback) {
205
+ const suffix = fallback ? ` (${fallback})` : ''
206
+ process.stdout.write(`\n \x1b[36m?\x1b[0m ${question}${suffix} : `)
197
207
 
198
- /**
199
- * Lecture synchrone d'une ligne depuis stdin (pour l'interaction CLI).
200
- * Retourne null en cas d'erreur de lecture ou d'EOF.
201
- */
202
- function promptPageChoice() {
203
- const chunk = Buffer.alloc(1024)
204
- try {
205
- const n = process.stdin.read(0, chunk.length, chunk)
206
- if (n <= 0) return null
207
- const text = chunk.toString('utf8', 0, n).trim()
208
- if (text === '') return null
209
- const parsed = Number(text)
210
- if (!Number.isInteger(parsed)) return null
211
- return parsed
212
- } catch {
213
- return null
208
+ let answer
209
+ if (queued.length > 0) answer = queued.shift()
210
+ else if (closed) answer = null
211
+ else answer = await new Promise((resolveLine) => {
212
+ waiting = resolveLine
213
+ })
214
+
215
+ // stdin fermé (pipe sans données) : on garde la valeur par défaut.
216
+ if (answer === null) return fallback
217
+ const trimmed = answer.trim()
218
+ return trimmed === '' ? fallback : trimmed
219
+ },
220
+ close() {
221
+ if (closed) return
222
+ closed = true
223
+ rl.close()
224
+ },
214
225
  }
215
226
  }
216
227
 
@@ -250,47 +261,6 @@ function rewriteAlias(source, targetAlias) {
250
261
  return source.replace(/(['"])@\//g, `$1${targetAlias}/`)
251
262
  }
252
263
 
253
- /**
254
- * Remplace les tokens `--primary` par la couleur choisie, directement dans le code
255
- * copié : `bg-primary` → `bg-[#10b981]`, `var(--primary)` → `#10b981`.
256
- * Le composant garde alors la couleur demandée sans dépendre de la variable du projet.
257
- */
258
- function personalizeColor(source, accent) {
259
- if (!accent) return source
260
- const foreground = isLightHex(accent) ? '#0f172a' : '#ffffff'
261
- return source
262
- .replace(/var\(--primary-foreground\)/g, foreground)
263
- .replace(/var\(--primary\)/g, accent)
264
- .replace(
265
- /\b((?:[a-z0-9-]+:)*)(bg|text|border(?:-[xytrblse])?|ring(?:-offset)?|fill|stroke|from|via|to|divide|outline|shadow|accent|caret|decoration|placeholder)-primary(-foreground)?(\/\d+)?\b/g,
266
- (_match, prefix, utility, isForeground, opacity = '') =>
267
- `${prefix}${utility}-[${isForeground ? foreground : accent}]${opacity}`,
268
- )
269
- }
270
-
271
- function copyFile({ to, source, dryRun, force, targetAlias, accent }) {
272
- if (existsSync(to) && !force) return 'skipped'
273
- if (!dryRun) {
274
- mkdirSync(dirname(to), { recursive: true })
275
- writeFileSync(to, rewriteAlias(personalizeColor(source, accent), targetAlias), 'utf8')
276
- }
277
- return 'written'
278
- }
279
-
280
- /* -------------------------------------------------------------------------- */
281
- /* Commandes */
282
- /* -------------------------------------------------------------------------- */
283
-
284
- const ALIASES = {
285
- otp: 'otp-input',
286
- editor: 'rich-text-editor',
287
- panels: 'resizable',
288
- shortcuts: 'command-menu',
289
- upload: 'file-upload',
290
- video: 'video-player',
291
- }
292
-
293
-
294
264
  function normalizeHex(value) {
295
265
  let next = String(value).trim()
296
266
  if (!next.startsWith('#')) next = `#${next}`
@@ -311,35 +281,309 @@ function isLightHex(hex) {
311
281
  return 0.299 * r + 0.587 * g + 0.114 * b > 160
312
282
  }
313
283
 
284
+ /* -------------------------------------------------------------------------- */
285
+ /* Thème Tailwind CSS 4 */
286
+ /* -------------------------------------------------------------------------- */
287
+
288
+ /**
289
+ * Les composants utilisent uniquement des classes Tailwind (`bg-primary`,
290
+ * `text-muted-foreground`, `border-border`…). Pour que ces classes existent,
291
+ * Tailwind 4 doit connaître les tokens via `@theme` : un simple `:root`
292
+ * ne génère aucune classe utilitaire.
293
+ */
294
+ function buildThemeCss({ accent, radius }) {
295
+ const foreground = isLightHex(accent) ? '#0f172a' : '#ffffff'
296
+ const rootTokens = [
297
+ ' --background: #f8fafc;',
298
+ ' --foreground: #0f172a;',
299
+ ' --card: #ffffff;',
300
+ ' --card-foreground: #0f172a;',
301
+ ' --muted: #f1f5f9;',
302
+ ' --muted-foreground: #64748b;',
303
+ ' --border: #e2e8f0;',
304
+ ` --primary: ${accent};`,
305
+ ` --primary-foreground: ${foreground};`,
306
+ ` --accent: color-mix(in oklab, ${accent} 18%, var(--background));`,
307
+ ' --accent-foreground: var(--foreground);',
308
+ ` --ring: ${accent}80;`,
309
+ ]
310
+ if (radius !== undefined) rootTokens.push(` --radius: ${radius}px;`)
311
+
312
+ const darkTokens = [
313
+ ' --background: #0b1220;',
314
+ ' --foreground: #e2e8f0;',
315
+ ' --card: #101a2b;',
316
+ ' --card-foreground: #e2e8f0;',
317
+ ' --muted: #1e293b;',
318
+ ' --muted-foreground: #94a3b8;',
319
+ ' --border: rgba(255, 255, 255, 0.12);',
320
+ ` --primary: ${accent};`,
321
+ ` --primary-foreground: ${foreground};`,
322
+ ` --accent: color-mix(in oklab, ${accent} 24%, #0b1220);`,
323
+ ' --accent-foreground: #e2e8f0;',
324
+ ` --ring: ${accent}80;`,
325
+ ' color-scheme: dark;',
326
+ ]
327
+
328
+ return `/* uifolio — tokens Tailwind CSS 4 : les classes bg-primary, text-muted-foreground… viennent d'ici. */
329
+ :root {
330
+ ${rootTokens.join('\n')}
331
+ }
332
+
333
+ .dark {
334
+ ${darkTokens.join('\n')}
335
+ }
336
+
337
+ @custom-variant dark (&:is(.dark *));
338
+
339
+ @theme inline {
340
+ --color-background: var(--background);
341
+ --color-foreground: var(--foreground);
342
+ --color-card: var(--card);
343
+ --color-card-foreground: var(--card-foreground);
344
+ --color-muted: var(--muted);
345
+ --color-muted-foreground: var(--muted-foreground);
346
+ --color-border: var(--border);
347
+ --color-primary: var(--primary);
348
+ --color-primary-foreground: var(--primary-foreground);
349
+ --color-accent: var(--accent);
350
+ --color-accent-foreground: var(--accent-foreground);
351
+ --color-ring: var(--ring);
352
+ }
353
+ `
354
+ }
355
+
356
+ /** CSS global du projet (celui qui importe Tailwind), sinon null. */
357
+ function findGlobalCss(cwd) {
358
+ for (const candidate of GLOBAL_CSS_CANDIDATES) {
359
+ const file = join(cwd, candidate)
360
+ if (!existsSync(file)) continue
361
+ const source = readFileSync(file, 'utf8')
362
+ if (/@import\s+['"]tailwindcss|@tailwind\b/.test(source)) return file
363
+ }
364
+ for (const candidate of GLOBAL_CSS_CANDIDATES) {
365
+ const file = join(cwd, candidate)
366
+ if (existsSync(file)) return file
367
+ }
368
+ return null
369
+ }
370
+
371
+ /** Ajoute `@import './uifolio-theme.css';` juste après l'import de Tailwind. */
372
+ function wireThemeImport(source, specifier) {
373
+ const line = `@import '${specifier}';`
374
+ if (source.includes(specifier)) return source
375
+ const lines = source.split('\n')
376
+ const at = lines.findIndex((entry) => /@import\s+['"]tailwindcss|@tailwind\s/.test(entry))
377
+ if (at === -1) return `${line}\n${source}`
378
+ lines.splice(at + 1, 0, line)
379
+ return lines.join('\n')
380
+ }
381
+
382
+ /** `--primary` déjà défini dans un CSS (hex uniquement), sinon null. */
383
+ function existingAccent(file) {
384
+ if (!file || !existsSync(file)) return null
385
+ const match = /--primary:\s*(#[0-9a-f]{6})\s*;/i.exec(readFileSync(file, 'utf8'))
386
+ return match ? match[1].toLowerCase() : null
387
+ }
388
+
389
+ /** `--radius` déjà défini dans un CSS, sinon undefined. */
390
+ function existingRadius(file) {
391
+ if (!file || !existsSync(file)) return undefined
392
+ const match = /--radius:\s*(\d+(?:\.\d+)?)px\s*;/.exec(readFileSync(file, 'utf8'))
393
+ return match ? Math.round(Number(match[1])) : undefined
394
+ }
395
+
314
396
  function writeTheme({ cwd, accent, radius, dryRun }) {
315
- if (!accent && radius === undefined) return
397
+ const globalCss = findGlobalCss(cwd)
398
+ const themeFile = globalCss
399
+ ? join(dirname(globalCss), THEME_FILE)
400
+ : join(cwd, THEME_FILE)
401
+ // Sans --color / --radius, on garde le thème déjà en place (projet ou UIfolio).
402
+ const finalAccent = accent ?? existingAccent(themeFile) ?? existingAccent(globalCss) ?? DEFAULT_ACCENT
403
+ const finalRadius = radius ?? existingRadius(themeFile)
404
+ const css = buildThemeCss({ accent: finalAccent, radius: finalRadius })
405
+ const label = relative(cwd, themeFile)
406
+
407
+ if (dryRun) {
408
+ ok(`[dry-run] thème Tailwind aurait été écrit dans ${label}`)
409
+ if (globalCss) info(`[dry-run] importé dans ${relative(cwd, globalCss)}`)
410
+ return
411
+ }
316
412
 
317
- const rounded = radius === undefined ? null : Math.round(Number(radius))
318
- if (rounded !== null && (!Number.isFinite(rounded) || rounded < 0 || rounded > 24)) {
319
- fail(`Arrondi invalide : ${radius}. Utilise une valeur entre 0 et 24.`)
413
+ writeFileSync(themeFile, css, 'utf8')
414
+ ok(`thème Tailwind écrit dans ${label}`)
415
+ info(`--primary: ${finalAccent}${finalRadius === undefined ? '' : ` · --radius: ${finalRadius}px`}`)
416
+
417
+ if (globalCss) {
418
+ const source = readFileSync(globalCss, 'utf8')
419
+ const specifier = `./${basename(themeFile)}`
420
+ const updated = wireThemeImport(source, specifier)
421
+ if (updated !== source) writeFileSync(globalCss, updated, 'utf8')
422
+ info(`importé dans ${relative(cwd, globalCss)} — aucune couleur en dur dans les composants`)
423
+ } else {
424
+ info(`Ajoute « @import './${THEME_FILE}'; » en haut de ton CSS global (celui de Tailwind).`)
320
425
  }
426
+ }
427
+
428
+ /* -------------------------------------------------------------------------- */
429
+ /* Usage du composant dans une page */
430
+ /* -------------------------------------------------------------------------- */
431
+
432
+ /** Chemin d'import relatif, sans extension, en slashs. */
433
+ function importSpecifier(fromDir, target) {
434
+ const rel = relative(fromDir, target).replace(/\\/g, '/')
435
+ return rel.startsWith('.') ? rel : `./${rel}`
436
+ }
321
437
 
322
- const tokens = []
323
- // La couleur est déjà écrite dans les fichiers copiés : on n'écrit pas de token.
324
- if (!accent) {
325
- const fallback = '#10b981'
326
- tokens.push(
327
- ` --primary: ${fallback};`,
328
- ' --primary-foreground: #ffffff;',
329
- ` --ring: ${fallback}80;`,
330
- ` --accent: color-mix(in oklab, ${fallback} 18%, var(--background));`,
331
- ' --accent-foreground: var(--foreground);',
332
- )
438
+ /** Insère les imports après le dernier import du fichier. */
439
+ function addImports(source, lines) {
440
+ const missing = lines.filter((line) => !source.includes(line))
441
+ if (missing.length === 0) return source
442
+
443
+ const all = source.split('\n')
444
+ let last = -1
445
+ for (let index = 0; index < all.length; index += 1) {
446
+ if (/^\s*import\s/.test(all[index])) last = index
447
+ }
448
+ if (last === -1) return `${missing.join('\n')}\n\n${source}`
449
+
450
+ // Un import peut tenir sur plusieurs lignes : on avance jusqu'à la fin.
451
+ let end = last
452
+ while (
453
+ end < all.length - 1 &&
454
+ end - last < 20 &&
455
+ !/from\s+['"][^'"]+['"];?\s*$/.test(all[end]) &&
456
+ !/^import\s+['"][^'"]+['"];?\s*$/.test(all[end])
457
+ ) {
458
+ end += 1
333
459
  }
334
- if (rounded !== null) tokens.push(` --radius: ${rounded}px;`)
335
- if (tokens.length === 0) return
336
460
 
337
- const file = join(cwd, 'uifolio-theme.css')
338
- if (!dryRun) writeFileSync(file, `:root {\n${tokens.join('\n')}\n}\n`, 'utf8')
339
- ok(`${dryRun ? '[dry-run] ' : ''}Thème écrit dans ${relative(cwd, file)}`)
340
- if (accent) info(`couleur ${accent} : déjà intégrée aux composants copiés`)
341
- if (rounded !== null) info(`arrondi ${rounded}px`)
342
- info(`Importe ${relative(cwd, file)} dans ton CSS global.`)
461
+ all.splice(end + 1, 0, ...missing)
462
+ return all.join('\n')
463
+ }
464
+
465
+ /**
466
+ * Fin de la balise ouvrante du `<` à `start`, sans se laisser piéger par les
467
+ * `>`/apostrophes des attributs et des textes JSX.
468
+ */
469
+ function findOpeningTagEnd(source, start) {
470
+ let braceDepth = 0
471
+ for (let index = start + 1; index < source.length; index += 1) {
472
+ const char = source[index]
473
+ if (braceDepth === 0 && (char === '"' || char === "'")) {
474
+ const closing = source.indexOf(char, index + 1)
475
+ if (closing === -1) return null
476
+ index = closing
477
+ continue
478
+ }
479
+ if (char === '{') braceDepth += 1
480
+ else if (char === '}') braceDepth = Math.max(0, braceDepth - 1)
481
+ else if (char === '>' && braceDepth === 0) {
482
+ return { end: index, selfClosing: source[index - 1] === '/' }
483
+ }
484
+ }
485
+ return null
486
+ }
487
+
488
+ /** Ajoute `<Composant />` dans le JSX retourné, sinon un exemple exporté. */
489
+ function addJsxUsage(source, exportNames) {
490
+ const missing = exportNames.filter((name) => !new RegExp(`<${name}[\\s/>]`).test(source))
491
+ if (missing.length === 0) return { source, added: [] }
492
+
493
+ const returnMatch = /\breturn\b/.exec(source)
494
+ let cursor = returnMatch ? returnMatch.index + returnMatch[0].length : -1
495
+ while (cursor !== -1 && cursor < source.length && /[\s(]/.test(source[cursor])) cursor += 1
496
+ const rootIndex = cursor !== -1 && source[cursor] === '<' ? cursor : -1
497
+ const tag = rootIndex === -1 ? null : findOpeningTagEnd(source, rootIndex)
498
+
499
+ if (tag) {
500
+ const lineStart = source.lastIndexOf('\n', rootIndex) + 1
501
+ const indent = /^\s*/.exec(source.slice(lineStart, rootIndex))?.[0] ?? ''
502
+ const childIndent = `${indent} `
503
+ const children = missing.map((name) => `${childIndent}<${name} />`).join('\n')
504
+
505
+ // Fragment déjà présent : on insère juste après `<>`.
506
+ if (source.startsWith('<>', rootIndex)) {
507
+ const next = `${source.slice(0, rootIndex + 2)}\n${children}${source.slice(rootIndex + 2)}`
508
+ return { source: next, added: missing }
509
+ }
510
+
511
+ // Racine auto-fermante : on l'enveloppe dans un fragment.
512
+ if (tag.selfClosing) {
513
+ const element = source.slice(rootIndex, tag.end + 1)
514
+ const next =
515
+ `${source.slice(0, rootIndex)}<>\n${children}\n${indent}${element}\n${indent}</>${source.slice(tag.end + 1)}`
516
+ return { source: next, added: missing }
517
+ }
518
+
519
+ // Élément ouvrant : les composants deviennent ses premiers enfants.
520
+ const next = `${source.slice(0, tag.end + 1)}\n${children}${source.slice(tag.end + 1)}`
521
+ return { source: next, added: missing }
522
+ }
523
+
524
+ // JSX introuvable (page sans `return` JSX) : on laisse un exemple utilisable.
525
+ const example = missing
526
+ .map((name) => `\nexport function ${name}Example() {\n return <${name} />\n}\n`)
527
+ .join('\n')
528
+ return { source: `${source.trimEnd()}\n${example}`, added: missing }
529
+ }
530
+
531
+ /**
532
+ * Écrit (ou complète) le fichier choisi : imports depuis le dossier réel des
533
+ * composants + appel `<Composant />` dans le JSX.
534
+ */
535
+ function writeUsage({ cwd, target, componentsDir, components, dryRun }) {
536
+ const created = !existsSync(target)
537
+ const lines = components.map(({ name, exportName }) =>
538
+ `import { ${exportName} } from '${importSpecifier(dirname(target), join(resolve(cwd, componentsDir), name))}'`,
539
+ )
540
+ const label = relative(cwd, target)
541
+ const names = components.map((component) => component.exportName)
542
+
543
+ if (created) {
544
+ const body = [
545
+ ...lines,
546
+ '',
547
+ `export default function ${names[0]}Page() {`,
548
+ ' return (',
549
+ ' <main className="space-y-6 p-6">',
550
+ ...names.map((name) => ` <${name} />`),
551
+ ' </main>',
552
+ ' )',
553
+ '}',
554
+ '',
555
+ ].join('\n')
556
+ if (!dryRun) {
557
+ mkdirSync(dirname(target), { recursive: true })
558
+ writeFileSync(target, body, 'utf8')
559
+ }
560
+ ok(`${dryRun ? '[dry-run] ' : ''}page créée : ${label}`)
561
+ } else {
562
+ const source = readFileSync(target, 'utf8')
563
+ const withImports = addImports(source, lines)
564
+ const { source: updated, added } = addJsxUsage(withImports, names)
565
+ if (!dryRun) writeFileSync(target, updated, 'utf8')
566
+ ok(`${dryRun ? '[dry-run] ' : ''}usage ajouté dans ${label}`)
567
+ if (added.length < names.length) {
568
+ const already = names.filter((name) => !added.includes(name))
569
+ info(`déjà présent(s), non dupliqué(s) : ${already.join(', ')}`)
570
+ }
571
+ }
572
+
573
+ for (const name of names) info(`· <${name} />`)
574
+ }
575
+
576
+ /* -------------------------------------------------------------------------- */
577
+ /* Commandes */
578
+ /* -------------------------------------------------------------------------- */
579
+
580
+ const ALIASES = {
581
+ otp: 'otp-input',
582
+ editor: 'rich-text-editor',
583
+ panels: 'resizable',
584
+ shortcuts: 'command-menu',
585
+ upload: 'file-upload',
586
+ video: 'video-player',
343
587
  }
344
588
 
345
589
  function commandList() {
@@ -359,7 +603,7 @@ function commandInit({ flags }) {
359
603
 
360
604
  const config = {
361
605
  $schema: 'https://uifolio.dev/schema.json',
362
- dir: typeof flags.dir === 'string' ? flags.dir : 'src/components/ui',
606
+ dir: typeof flags.dir === 'string' ? flags.dir : DEFAULT_COMPONENTS_DIR,
363
607
  lib: typeof flags.lib === 'string' ? flags.lib : 'src/lib',
364
608
  alias: typeof flags.alias === 'string' ? flags.alias : '@',
365
609
  }
@@ -369,159 +613,130 @@ function commandInit({ flags }) {
369
613
  info('Puis : uifolio add video-player calendar …')
370
614
  }
371
615
 
616
+ /**
617
+ * Demande le fichier où utiliser le composant, après l'installation.
618
+ * La page proposée par défaut est créée si elle n'existe pas.
619
+ */
620
+ async function chooseUsageTarget({ prompter, cwd, names }) {
621
+ const suggestion = `src/pages/${names[0]}.tsx`
622
+ const candidates = usageCandidates(cwd)
623
+ if (candidates.length > 0) info(`fichiers existants : ${candidates.join(', ')}`)
624
+
625
+ const answer = await prompter.ask(
626
+ `Dans quel fichier utiliser ${names.join(', ')} ? « - » pour ne pas l'utiliser`,
627
+ suggestion,
628
+ )
629
+ if (!answer || answer === '-') {
630
+ info('le composant est installé mais n’est utilisé dans aucun fichier.')
631
+ return null
632
+ }
633
+ return resolve(cwd, answer)
634
+ }
372
635
 
373
- function commandAdd({ positional, flags }) {
636
+ async function commandAdd({ positional, flags }) {
374
637
  const names = positional.map((name) => ALIASES[name] ?? name)
375
-
376
638
  if (names.length === 0) fail('Aucun composant demandé. Ex. : uifolio add calendar otp')
377
639
 
378
640
  const cwd = resolve(flags.cwd ?? process.cwd())
379
641
  const config = readConfig(cwd) ?? {}
380
- const pageFlag = typeof flags.page === 'string' ? flags.page : undefined
381
- let pageTarget = pageFlag ? resolve(cwd, pageFlag) : null
382
-
383
- // Détermine la page cible effective : --page explicite, choix interactif, ou aucune.
384
- if (!pageTarget) {
385
- const pages = listPagesInCwd(cwd)
386
- if (pages.length > 0) {
387
- const chosen = choosePageInteraction(pages, cwd)
388
- if (chosen) pageTarget = chosen
389
- }
390
- }
391
-
392
- const createPage = Boolean(pageTarget) && !existsSync(pageTarget)
393
- let pageImports = []
394
-
395
- const componentsDir = typeof flags.dir === 'string' ? flags.dir : config.dir ?? 'src/components/ui'
396
- const libDir = typeof flags.lib === 'string' ? flags.lib : config.lib ?? 'src/lib'
397
- const targetAlias = typeof flags.alias === 'string' ? flags.alias : config.alias ?? '@'
398
642
  const dryRun = Boolean(flags['dry-run'])
399
643
  const force = Boolean(flags.force)
644
+ const targetAlias = typeof flags.alias === 'string' ? flags.alias : config.alias ?? '@'
645
+ const libDir = typeof flags.lib === 'string' ? flags.lib : config.lib ?? 'src/lib'
646
+
400
647
  const colorFlag = typeof flags.color === 'string' ? flags.color : undefined
401
648
  const accent = colorFlag === undefined ? null : normalizeHex(colorFlag)
402
649
  if (colorFlag !== undefined && !accent) {
403
650
  fail(`Couleur invalide : ${colorFlag}. Utilise un hex comme #10B981.`)
404
651
  }
405
652
 
653
+ let radius
654
+ if (typeof flags.radius === 'string') {
655
+ radius = Math.round(Number(flags.radius))
656
+ if (!Number.isFinite(radius) || radius < 0 || radius > 24) {
657
+ fail(`Arrondi invalide : ${flags.radius}. Utilise une valeur entre 0 et 24.`)
658
+ }
659
+ }
660
+
406
661
  const known = listComponents()
407
662
  const unknown = names.filter((name) => !known.includes(name))
408
663
  if (unknown.length > 0) {
409
664
  fail(`Composant(s) inconnu(s) : ${unknown.join(', ')}. Lance « uifolio list ».`)
410
665
  }
411
-
412
666
  if (!dryRun && !existsSync(cwd)) fail(`Répertoire cible introuvable : ${cwd}`)
413
- if (pageTarget && !existsSync(pageTarget) && !dryRun) {
414
- const bad = pageFlag ?? relative(cwd, pageTarget)
415
- fail(`Page cible introuvable : ${bad}.`)
416
- }
417
- if (pageTarget && !existsSync(pageTarget) && dryRun) {
418
- info(`[dry-run] page cible introuvable : ${pageFlag ?? relative(cwd, pageTarget)} (simulation uniquement).`)
419
- pageTarget = null
420
- }
421
-
422
- const deps = new Set(LIB_DEPS)
423
- const written = new Set()
424
- const skipped = []
425
-
426
- // Prépare les imports qui seront injectés dans la page cible.
427
- for (const name of names) {
428
- if (!pageTarget) continue
429
- const importPath = relative(cwd, join(componentsDir, `${name}.tsx`))
430
- pageImports.push({ name, importPath })
431
- }
432
-
433
- for (const name of names) {
434
- const entry = join(REGISTRY_UI, `${name}.tsx`)
435
- const files = collectFiles(entry, REGISTRY_SRC)
436
-
437
- for (const [absolute, relativePath] of files) {
438
- const isUi = relativePath.startsWith(join('components', 'ui'))
439
- const destinationRoot = isUi ? resolve(cwd, componentsDir) : resolve(cwd, libDir)
440
- const destination = join(destinationRoot, relativePath.replace(/^(components\/ui|lib)\//, ''))
441
-
442
- if (written.has(destination)) continue
443
-
444
- const source = readFileSync(absolute, 'utf8')
445
- for (const dep of externalDeps(source)) deps.add(dep)
446
667
 
447
- const status = copyFile({
448
- to: destination,
449
- source,
450
- dryRun,
451
- force,
452
- targetAlias,
453
- accent,
454
- })
455
-
456
- const label = relative(cwd, destination)
457
- if (status === 'written') written.add(label)
458
- else skipped.push(label)
668
+ const prompter = createPrompter(flags)
669
+ try {
670
+ // 1. Dossier d'import des composants.
671
+ let componentsDir = typeof flags.dir === 'string' ? flags.dir : config.dir ?? DEFAULT_COMPONENTS_DIR
672
+ if (prompter && typeof flags.dir !== 'string') {
673
+ const answer = await prompter.ask('Dans quel dossier copier les composants', componentsDir)
674
+ if (answer) componentsDir = answer
675
+ }
676
+ componentsDir = componentsDir.replace(/^\.\//, '').replace(/\/+$/, '')
677
+
678
+ // 2. Copie des fichiers + helpers.
679
+ const deps = new Set(LIB_DEPS)
680
+ const written = new Set()
681
+ const destinations = new Set()
682
+ const skipped = []
683
+ const components = []
684
+
685
+ for (const name of names) {
686
+ const entry = join(REGISTRY_UI, `${name}.tsx`)
687
+ const files = collectFiles(entry, REGISTRY_SRC)
688
+ components.push({ name, exportName: componentExportName(readFileSync(entry, 'utf8'), name) })
689
+
690
+ for (const [absolute, relativePath] of files) {
691
+ const isUi = relativePath.startsWith(join('components', 'ui'))
692
+ const destinationRoot = isUi ? resolve(cwd, componentsDir) : resolve(cwd, libDir)
693
+ const destination = join(destinationRoot, relativePath.replace(/^(components\/ui|lib)\//, ''))
694
+ if (destinations.has(destination)) continue
695
+ destinations.add(destination)
696
+
697
+ const source = readFileSync(absolute, 'utf8')
698
+ for (const dep of externalDeps(source)) deps.add(dep)
699
+
700
+ const label = relative(cwd, destination)
701
+ const shouldWrite = force || !existsSync(destination)
702
+ if (shouldWrite) {
703
+ if (!dryRun) {
704
+ mkdirSync(dirname(destination), { recursive: true })
705
+ writeFileSync(destination, rewriteAlias(source, targetAlias), 'utf8')
706
+ }
707
+ written.add(label)
708
+ } else {
709
+ skipped.push(label)
710
+ }
711
+ }
459
712
  }
460
- }
461
713
 
462
- const action = dryRun ? '[dry-run] auraient été copiés' : 'copié(s)'
463
- ok(`${written.size} fichier(s) ${action} :`)
464
- for (const label of [...written].sort()) info(`· ${label}`)
465
- if (skipped.length > 0) {
466
- info(`${skipped.length} fichier(s) déjà présent(s), ignoré(s) : ${skipped.join(', ')} (–force pour écraser)`)
467
- }
468
- if (accent && written.size > 0) {
469
- info(`couleur ${accent} intégrée (bg-primary → bg-[${accent}]), aucun --primary requis`)
470
- }
714
+ ok(`${written.size} fichier(s) ${dryRun ? '[dry-run] auraient été copiés' : 'copié(s)'} :`)
715
+ for (const label of [...written].sort()) info(`· ${label}`)
716
+ if (skipped.length > 0) {
717
+ info(`${skipped.length} fichier(s) déjà présent(s), ignoré(s) : ${skipped.join(', ')} (–force pour écraser)`)
718
+ }
471
719
 
472
- writeTheme({
473
- cwd,
474
- accent,
475
- radius: typeof flags.radius === 'string' ? flags.radius : undefined,
476
- dryRun,
477
- })
720
+ // 3. Tokens Tailwind : les classes bg-primary & co fonctionnent partout.
721
+ writeTheme({ cwd, accent, radius, dryRun })
478
722
 
479
- if (written.size > 0 || dryRun) {
480
- console.log('\n Dépendances npm détectées :')
481
- info(`npm install ${[...deps].sort().join(' ')}`)
482
- if (targetAlias !== '@') {
483
- info(`Pense à aligner l'alias ${targetAlias} dans ton tsconfig/vite (remplace de @/).`)
723
+ // 4. Utilisation du composant, après installation.
724
+ let pageTarget = typeof flags.page === 'string' ? resolve(cwd, flags.page) : null
725
+ if (!pageTarget && prompter) {
726
+ pageTarget = await chooseUsageTarget({ prompter, cwd, names })
484
727
  }
485
- console.log('')
486
- } // Crée ou adapte la page cible si une page cible a été sélectionnée.
487
- if (pageTarget) {
488
- const dir = dirname(pageTarget)
489
- if (createPage) {
490
- if (!dryRun) {
491
- mkdirSync(dir, { recursive: true })
492
- writeFileSync(
493
- pageTarget,
494
- `import { ${pageImports.map((p) => p.name).join(', ')} } from '${relative(dir, join(cwd, componentsDir)).replace(/\\/g, '/')}'\n'\n` +
495
- `export default function Page() {\n` +
496
- ` return (<>\n` +
497
- pageImports.map((p) => ` <${p.name} />`).join('\n') +
498
- `\n </>)\n` +
499
- `}\n`,
500
- 'utf8',
501
- )
502
- }
503
- ok(`${dryRun ? '[dry-run] ' : ''}Page créée : ${relative(cwd, pageTarget)}`)
504
- for (const p of pageImports) {
505
- info(`· <${p.name} /> importé depuis ${relative(cwd, join(componentsDir, `${p.name}.tsx`))}`)
506
- }
507
- } else {
508
- if (dryRun) {
509
- info(`[dry-run] page existante détectée : ${relative(cwd, pageTarget)}`)
510
- } else {
511
- const source = readFileSync(pageTarget, 'utf8')
512
- const rendered =
513
- pageImports.map((p) => `import { ${p.name} } from '${relative(dirname(pageTarget), join(cwd, componentsDir)).replace(/\\/g, '/')}';`).join('\n')
514
- const functionPoint = source.indexOf('function ')
515
- const insertPoint =
516
- functionPoint !== -1 ? functionPoint : source.lastIndexOf('</')
517
- const updatedSource =
518
- insertPoint === -1
519
- ? source + '\n' + rendered + '\n' + pageImports.map((p) => `<${p.name} />`).join('\n')
520
- : source.slice(0, insertPoint) + '\n' + rendered + '\n' + source.slice(insertPoint)
521
- writeFileSync(pageTarget, updatedSource, 'utf8')
728
+ if (pageTarget) writeUsage({ cwd, target: pageTarget, componentsDir, components, dryRun })
729
+
730
+ if (written.size > 0 || dryRun) {
731
+ console.log('\n Dépendances npm détectées :')
732
+ info(`npm install ${[...deps].sort().join(' ')}`)
733
+ if (targetAlias !== '@') {
734
+ info(`Pense à aligner l'alias ${targetAlias} dans ton tsconfig/vite (remplace de @/).`)
522
735
  }
523
- ok(`Page existante mise à jour : ${relative(cwd, pageTarget)}`)
736
+ console.log('')
524
737
  }
738
+ } finally {
739
+ prompter?.close()
525
740
  }
526
741
  }
527
742
 
@@ -542,55 +757,47 @@ Options :
542
757
  --dir <chemin> dossier composants (défaut : src/components/ui)
543
758
  --lib <chemin> dossier helpers (défaut : src/lib)
544
759
  --alias <alias> alias d'import cible (défaut : @)
545
- --color <hex> intègre cette couleur aux fichiers copiés (sans --primary)
760
+ --color <hex> couleur primaire, écrite dans les tokens Tailwind (@theme)
546
761
  --radius <px> arrondi global (0–24) dans uifolio-theme.css
762
+ --page <chemin> fichier où utiliser le composant (ex : src/pages/calendar.tsx) ;
763
+ créé s'il n'existe pas, sinon l'import + <Composant /> y sont ajoutés
764
+ --interactive force les questions (dossier, fichier) hors terminal
547
765
  --force écrase les fichiers existants
548
766
  --dry-run simule sans écrire
549
- --page <chemin> page cible (ex : src/pages/calendar.tsx). Avec --page, la CLI crée la page
550
- si elle n'existe pas, ou y ajoute l'import + l'appel du/des composant(s).
551
- Sans --page et quand des pages existent déjà dans src/pages, la CLI demande
552
- interactivement laquelle utiliser avant d'importer.
767
+
768
+ Sans --dir ni --page, la CLI demande dans quel dossier copier les composants
769
+ puis dans quel fichier les utiliser — l'installation ne s'arrête pas à la copie.
770
+ Les couleurs passent par un thème Tailwind CSS 4 (uifolio-theme.css, @theme) :
771
+ les classes bg-primary, text-muted-foreground… restent dans le code.
553
772
 
554
773
  Exemples :
555
774
  node cli/uifolio.mjs add video-player calendar otp --cwd ../mon-projet
556
775
  node cli/uifolio.mjs add otp --color "#10B981" --radius 8
557
- node cli/uifolio.mjs add otp --alias "#lib" --dry-run
558
776
  node cli/uifolio.mjs add calendar --page src/pages/calendar.tsx
559
777
  `)
560
778
  }
561
779
 
562
- const { positional, flags } = parseArgs(process.argv.slice(2))
563
- const command = positional.shift()
564
-
565
- // Requête interactive de la page cible avant toute écriture, sauf si --page est déjà passé.
566
- if (command === 'add' && typeof flags.page !== 'string') {
567
- let cwd
568
- try {
569
- cwd = resolve(flags.cwd ?? process.cwd())
570
- } catch {
571
- cwd = process.cwd()
572
- }
573
- const pages = listPagesInCwd(cwd)
574
- if (pages.length > 0) {
575
- const target = choosePageInteraction(pages, cwd)
576
- if (target) flags.page = target
780
+ async function main() {
781
+ const { positional, flags } = parseArgs(process.argv.slice(2))
782
+ const command = positional.shift()
783
+
784
+ switch (command) {
785
+ case 'list':
786
+ commandList()
787
+ break
788
+ case 'init':
789
+ commandInit({ positional, flags })
790
+ break
791
+ case 'add':
792
+ await commandAdd({ positional, flags })
793
+ break
794
+ case undefined:
795
+ case 'help':
796
+ usage()
797
+ break
798
+ default:
799
+ fail(`Commande inconnue : ${command}. Lance « uifolio » sans argument.`)
577
800
  }
578
801
  }
579
802
 
580
- switch (command) {
581
- case 'list':
582
- commandList()
583
- break
584
- case 'init':
585
- commandInit({ positional, flags })
586
- break
587
- case 'add':
588
- commandAdd({ positional, flags })
589
- break
590
- case undefined:
591
- case 'help':
592
- usage()
593
- break
594
- default:
595
- fail(`Commande inconnue : ${command}. Lance « uifolio » sans argument.`)
596
- }
803
+ await main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uifolio",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "CLI qui copie des composants React (calendrier, lecteurs vidéo/audio, OTP, éditeur, upload) et leurs helpers dans ton projet, avec des tokens Tailwind CSS 4.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -53,6 +53,7 @@
53
53
  "files": [
54
54
  "cli",
55
55
  "src/components/ui",
56
+ "!src/components/ui/stories-tokens.ts",
56
57
  "src/lib/color.ts",
57
58
  "src/lib/date.ts",
58
59
  "src/lib/uploader.ts",
@@ -30,7 +30,7 @@ export function AudioPlayer({
30
30
  variant = 'card',
31
31
  loop = false,
32
32
  className,
33
- }: AudioPlayerProps) {
33
+ }: Readonly<AudioPlayerProps>) {
34
34
  const audioRef = useRef<HTMLAudioElement>(null)
35
35
  const [playing, setPlaying] = useState(false)
36
36
  const [currentTime, setCurrentTime] = useState(0)
@@ -54,7 +54,7 @@ export function Calendar({
54
54
  disabled,
55
55
  manualInput = true,
56
56
  className,
57
- }: CalendarProps) {
57
+ }: Readonly<CalendarProps>) {
58
58
  const [internalValue, setInternalValue] = useState<CalendarValue>(defaultValue ?? null)
59
59
  const currentValue = value !== undefined ? value : internalValue
60
60
 
@@ -30,7 +30,7 @@ export function Choice({
30
30
  name,
31
31
  value,
32
32
  className,
33
- }: ChoiceProps) {
33
+ }: Readonly<ChoiceProps>) {
34
34
  const id = useId()
35
35
  const padding = Math.max(2, Math.round(size * 0.1))
36
36
  const thumb = Math.max(6, size - padding * 2)
@@ -43,12 +43,12 @@ function ChannelField({
43
43
  value,
44
44
  disabled,
45
45
  onChange,
46
- }: {
46
+ }: Readonly<{
47
47
  label: string
48
48
  value: number
49
49
  disabled: boolean
50
50
  onChange: (value: number) => void
51
- }) {
51
+ }>) {
52
52
  const [draft, setDraft] = useState<string | null>(null)
53
53
 
54
54
  return (
@@ -123,7 +123,7 @@ export function ColorPicker({
123
123
  open,
124
124
  defaultOpen = false,
125
125
  onOpenChange,
126
- }: ColorPickerProps) {
126
+ }: Readonly<ColorPickerProps>) {
127
127
  const generatedId = useId()
128
128
  const inputId = id ?? generatedId
129
129
  const panelId = `${inputId}-panel`
@@ -37,7 +37,7 @@ export function CommandMenu({
37
37
  emptyText = 'Aucun résultat.',
38
38
  emptyState,
39
39
  className,
40
- }: CommandMenuProps) {
40
+ }: Readonly<CommandMenuProps>) {
41
41
  const [internalOpen, setInternalOpen] = useState(defaultOpen)
42
42
  const isOpen = open ?? internalOpen
43
43
  const [query, setQuery] = useState('')
@@ -43,7 +43,7 @@ export function FileUpload({
43
43
  uploader,
44
44
  onChange,
45
45
  className,
46
- }: FileUploadProps) {
46
+ }: Readonly<FileUploadProps>) {
47
47
  const inputId = useId()
48
48
  const [items, setItems] = useState<UploadItem[]>([])
49
49
  const [dragging, setDragging] = useState(false)
@@ -126,11 +126,11 @@ function IconSvg({
126
126
  data,
127
127
  color,
128
128
  className,
129
- }: {
129
+ }: Readonly<{
130
130
  data: IconData | null
131
131
  color: string
132
132
  className?: string
133
- }) {
133
+ }>) {
134
134
  if (!data) {
135
135
  return <span className={cn('inline-block rounded bg-muted', className)} />
136
136
  }
@@ -149,11 +149,11 @@ export function IconGlyph({
149
149
  icon,
150
150
  color = 'currentColor',
151
151
  className,
152
- }: {
152
+ }: Readonly<{
153
153
  icon: string
154
154
  color?: string
155
155
  className?: string
156
- }) {
156
+ }>) {
157
157
  const [data, setData] = useState<IconData | null>(() => iconCache.get(icon) ?? null)
158
158
  const [prevIcon, setPrevIcon] = useState(icon)
159
159
  const loadRef = useRef<Promise<void> | null>(null)
@@ -186,7 +186,7 @@ export function IconPicker({
186
186
  onChange,
187
187
  placeholder = 'Choisir une icône',
188
188
  className,
189
- }: IconPickerProps) {
189
+ }: Readonly<IconPickerProps>) {
190
190
  const [open, setOpen] = useState(false)
191
191
  const [search, setSearch] = useState('')
192
192
  const [icons, setIcons] = useState<string[]>([])
@@ -21,7 +21,7 @@ export function FadeIn({
21
21
  duration = 520,
22
22
  className,
23
23
  style,
24
- }: MotionProps) {
24
+ }: Readonly<MotionProps>) {
25
25
  const reduced = useReducedMotion()
26
26
  return (
27
27
  <div
@@ -45,10 +45,12 @@ export function SlideIn({
45
45
  style,
46
46
  direction = 'up',
47
47
  distance = 20,
48
- }: MotionProps & {
49
- direction?: 'up' | 'down' | 'left' | 'right'
50
- distance?: number
51
- }) {
48
+ }: Readonly<
49
+ MotionProps & {
50
+ direction?: 'up' | 'down' | 'left' | 'right'
51
+ distance?: number
52
+ }
53
+ >) {
52
54
  const reduced = useReducedMotion()
53
55
  const offset = {
54
56
  up: `translate3d(0, ${distance}px, 0)`,
@@ -82,7 +84,7 @@ export function ScaleIn({
82
84
  className,
83
85
  style,
84
86
  from = 0.92,
85
- }: MotionProps & { from?: number }) {
87
+ }: Readonly<MotionProps & { from?: number }>) {
86
88
  const reduced = useReducedMotion()
87
89
  return (
88
90
  <div
@@ -108,13 +110,13 @@ export function Stagger({
108
110
  duration = 460,
109
111
  className,
110
112
  itemClassName,
111
- }: {
113
+ }: Readonly<{
112
114
  children: ReactNode
113
115
  delay?: number
114
116
  duration?: number
115
117
  className?: string
116
118
  itemClassName?: string
117
- }) {
119
+ }>) {
118
120
  return (
119
121
  <div className={className}>
120
122
  {Children.map(children, (child, index) => (
@@ -39,7 +39,7 @@ export function OtpInput({
39
39
  error = false,
40
40
  className,
41
41
  inputClassName,
42
- }: OtpInputProps) {
42
+ }: Readonly<OtpInputProps>) {
43
43
  const [internalValue, setInternalValue] = useState(defaultValue)
44
44
  const current = value ?? internalValue
45
45
  const complete = current.length === length && /^\d+$/.test(current)
@@ -10,11 +10,11 @@ export function ResizablePanelGroup({
10
10
  direction = 'horizontal',
11
11
  className,
12
12
  children,
13
- }: {
13
+ }: Readonly<{
14
14
  direction?: ResizableDirection
15
15
  className?: string
16
16
  children: ReactNode
17
- }) {
17
+ }>) {
18
18
  return (
19
19
  <div
20
20
  data-resizable-group
@@ -35,12 +35,12 @@ export function ResizablePanel({
35
35
  minSize = 10,
36
36
  className,
37
37
  children,
38
- }: {
38
+ }: Readonly<{
39
39
  defaultSize?: number
40
40
  minSize?: number
41
41
  className?: string
42
42
  children: ReactNode
43
- }) {
43
+ }>) {
44
44
  return (
45
45
  <div
46
46
  data-resizable-panel
@@ -54,7 +54,7 @@ export function ResizablePanel({
54
54
  )
55
55
  }
56
56
 
57
- export function ResizableHandle({ className }: { className?: string }) {
57
+ export function ResizableHandle({ className }: Readonly<{ className?: string }>) {
58
58
  const ref = useRef<HTMLDivElement>(null)
59
59
  const [orientation, setOrientation] = useState<'horizontal' | 'vertical'>('vertical')
60
60
 
@@ -43,7 +43,7 @@ export function RichTextEditor({
43
43
  toolbar = true,
44
44
  minHeight = 220,
45
45
  className,
46
- }: RichTextEditorProps) {
46
+ }: Readonly<RichTextEditorProps>) {
47
47
  const editorRef = useRef<HTMLDivElement>(null)
48
48
  const [internalValue, setInternalValue] = useState(defaultValue)
49
49
  const html = value ?? internalValue
@@ -254,12 +254,12 @@ function ToolButton({
254
254
  active,
255
255
  onClick,
256
256
  children,
257
- }: {
257
+ }: Readonly<{
258
258
  label: string
259
259
  active?: boolean
260
260
  onClick: () => void
261
261
  children: ReactNode
262
- }) {
262
+ }>) {
263
263
  return (
264
264
  <button
265
265
  type="button"
@@ -37,7 +37,7 @@ export function Stories({
37
37
  duration = 5000,
38
38
  className,
39
39
  textBackground = 'linear-gradient(160deg, color-mix(in oklab, var(--primary) 35%, #000) 0%, var(--primary) 55%, color-mix(in oklab, var(--primary) 65%, #fff) 100%)',
40
- }: StoriesProps) {
40
+ }: Readonly<StoriesProps>) {
41
41
  const [internalOpen, setInternalOpen] = useState(defaultOpen)
42
42
  const isOpen = open ?? internalOpen
43
43
  const [index, setIndex] = useState(initialIndex)
@@ -45,7 +45,7 @@ export function TimePicker({
45
45
  disabled = false,
46
46
  label,
47
47
  className,
48
- }: TimePickerProps) {
48
+ }: Readonly<TimePickerProps>) {
49
49
  const [internalValue, setInternalValue] = useState(defaultValue)
50
50
  const current = value ?? internalValue
51
51
  const inputValue = toInputValue(current, withSeconds)
@@ -284,7 +284,7 @@ export function VideoPlayer({
284
284
  onTimeUpdate,
285
285
  onError,
286
286
  ref,
287
- }: VideoPlayerProps) {
287
+ }: Readonly<VideoPlayerProps>) {
288
288
  const { sources, signature } = useResolvedSources(sourceInput)
289
289
 
290
290
  const containerRef = useRef<HTMLDivElement>(null)
@@ -1,18 +0,0 @@
1
- const accentToScheme: Record<string, { dark: string; light: string; foreground: string; background: string }> = {
2
- '#10b981': {
3
- dark: '#0f172a',
4
- light: '#0f172a',
5
- foreground: '#f1f5f9',
6
- background: '#ecfdf5',
7
- },
8
- default: {
9
- dark: '#f8fafc',
10
- light: '#0f172a',
11
- foreground: '#0f172a',
12
- background: '#ffffff',
13
- },
14
- }
15
-
16
- export function accentTokens(accent: string) {
17
- return accentToScheme[accent] ?? accentToScheme.default
18
- }