uifolio 0.6.0 → 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/README.md CHANGED
@@ -14,6 +14,7 @@ and the alias is rewritten automatically.
14
14
  # from any project, through the npm package
15
15
  npx uifolio@latest add calendar --color "#10B981" --radius 6
16
16
  pnpm dlx uifolio@latest add video-player otp
17
+ pnpm dlx uifolio@latest add horizontal-accordion storytelling
17
18
 
18
19
  # from the root of the project that contains UIfolio
19
20
  npm run uifolio -- list
@@ -31,11 +32,12 @@ node cli/uifolio.mjs add file-upload --alias "#lib" --dry-run # simulate
31
32
  | `uifolio init` | writes `uifolio.json` (dir, lib, alias) in the target project |
32
33
  | `uifolio add <names...>` | copies the component **+ transitive dependencies** (helpers `@/lib/*`) |
33
34
 
34
- Options: `--cwd <dir>`, `--dir <path>` (default `src/components/ui`), `--lib <path>` (default `src/lib`), `--alias <alias>` (default `@`), `--page <path>` (created if missing), `--interactive` (force the questions), `--force` (overwrite), `--dry-run` (simulate), `--color <hex>` and `--radius <px>`.
35
+ Options: `--cwd <dir>`, `--dir <path>` (default `src/components/ui`), `--animations <path>` (default `src/components/animations`), `--lib <path>` (default `src/lib`), `--alias <alias>` (default `@`), `--page <path>` (created if missing), `--interactive` (force the questions), `--force` (overwrite), `--dry-run` (simulate), `--color <hex>` and `--radius <px>`.
35
36
 
36
- `add` does not stop at the copy. The components always land in
37
- `src/components/ui` (override with `--dir`) and the page imports them through
38
- the `@/` alias. Without `--page`, the CLI asks where to use them:
37
+ `add` does not stop at the copy. The components land in
38
+ `src/components/ui` (override with `--dir`), the animations in
39
+ `src/components/animations` (override with `--animations`), and the page imports
40
+ them through the `@/` alias. Without `--page`, the CLI asks where to use them:
39
41
 
40
42
  1. *Do you already have a file ready to receive the component (…component…) (y/N) ?* `yes` → *In which file should the component (…component…) be copied (e.g. src/pages/calendar.tsx) ?*: the import and a ready-to-render example are added without duplication, and the file is created when missing;
41
43
  2. `no` → *Would you like to create a new folder and an import file (Y/n) ?* `yes` → *Please specify the new path (e.g. src/pages/calendar.tsx) ?*: the folder and the file are created; `no` → install only, no file is touched.
@@ -114,6 +116,14 @@ only marks the installation as complete when that is relevant.
114
116
  | OtpInput | `src/components/ui/otp-input.tsx` | 4 fields, paste, rotate + success check |
115
117
  | ColorPicker | `src/components/ui/color-picker.tsx` | advanced panel, saturation/lightness, hue, alpha, eyedropper, RGB channels, presets, HEX/RGB/HSL/RGBA |
116
118
  | Table | `src/components/ui/table.tsx` | shadcn-style typed table, uniform `px-4` cells and a wider gap before Actions, select-all checkbox, row actions |
119
+ | HorizontalAccordion | `src/components/animations/horizontal-accordion.tsx` | theme-colored panels, hover, click, arrow/Home/End keyboard |
120
+ | Storytelling | `src/components/animations/horizontal-story.tsx` | vertical scroll driven horizontal story, palettes, mobile touch fallback |
121
+ | StackingCards | `src/components/animations/stacking-cards.tsx` | cards settle on scroll, customizable gradients or solid color |
122
+ | ScrollText | `src/components/animations/scroll-text.tsx` | letters light up with their viewport position, reversible |
123
+ | ImageCrossfade | `src/components/animations/image-crossfade.tsx` | pinned crossfading images, captions, dots and customizable overlay |
124
+
125
+ The animation helpers (`data.ts`, `palette.ts`, `animation-settings-*`, `palette-*`)
126
+ are copied automatically when the installed animation needs them.
117
127
 
118
128
  Copyable helpers (the CLI follows them automatically): `src/lib/utils.ts`,
119
129
  `src/lib/video.ts`, `src/lib/vtt.ts`, `src/lib/date.ts`, `src/lib/uploader.ts`, `src/lib/color.ts`,
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)
@@ -33,19 +34,30 @@
33
34
 
34
35
  import { execFileSync } from 'node:child_process'
35
36
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
36
- import { basename, dirname, join, relative, resolve } from 'node:path'
37
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
37
38
  import { createInterface } from 'node:readline/promises'
38
39
  import { fileURLToPath } from 'node:url'
39
40
 
40
41
  const HERE = dirname(fileURLToPath(import.meta.url))
41
42
  const REGISTRY_SRC = resolve(HERE, '..', 'src')
42
43
  const REGISTRY_UI = join(REGISTRY_SRC, 'components', 'ui')
44
+ const REGISTRY_ANIMATIONS = join(REGISTRY_SRC, 'components', 'animations')
43
45
  const CONFIG_NAME = 'uifolio.json'
44
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
+
45
56
  const LIB_DEPS = ['clsx', 'tailwind-merge'] // cn() — always copied with the helpers
46
57
 
47
58
  const DEFAULT_ACCENT = '#10b981'
48
59
  const DEFAULT_COMPONENTS_DIR = 'src/components/ui'
60
+ const DEFAULT_ANIMATIONS_DIR = 'src/components/animations'
49
61
  const THEME_FILE = 'uifolio-theme.css'
50
62
 
51
63
  /** Global CSS candidates for wiring the Tailwind theme. */
@@ -146,7 +158,7 @@ function writeFailure(error, target) {
146
158
  fail(`Cannot write ${target}: ${reasons[error?.code] ?? error?.message ?? error}.`)
147
159
  }
148
160
 
149
- function listComponents() {
161
+ function listUiComponents() {
150
162
  if (!existsSync(REGISTRY_UI)) return []
151
163
  return readdirSync(REGISTRY_UI)
152
164
  .filter((name) => name.endsWith('.tsx'))
@@ -154,6 +166,24 @@ function listComponents() {
154
166
  .sort()
155
167
  }
156
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
+
157
187
  function readConfig(cwd) {
158
188
  const file = join(cwd, CONFIG_NAME)
159
189
  if (!existsSync(file)) return null
@@ -164,14 +194,19 @@ function readConfig(cwd) {
164
194
  }
165
195
  }
166
196
 
167
- /** Extrait les imports locaux (alias) d'un fichier source. */
197
+ /** Local imports (alias or relative) of a source file. */
168
198
  function localImports(source) {
169
199
  const specifiers = new Set()
170
200
  const importRe = /(?:from\s+|import\s*\(\s*|import\s+)['"]([^'"]+)['"]/g
171
201
  let match
172
202
  while ((match = importRe.exec(source)) !== null) {
173
203
  const specifier = match[1]
174
- if (specifier.startsWith('@/') || specifier.startsWith('~/')) {
204
+ if (
205
+ specifier.startsWith('@/') ||
206
+ specifier.startsWith('~/') ||
207
+ specifier.startsWith('./') ||
208
+ specifier.startsWith('../')
209
+ ) {
175
210
  specifiers.add(specifier)
176
211
  }
177
212
  }
@@ -305,12 +340,14 @@ function collectFiles(entryPath, srcRoot) {
305
340
 
306
341
  const source = readFileSync(current, 'utf8')
307
342
  for (const specifier of localImports(source)) {
308
- const base = specifier.replace(/^[@~]\//, '')
343
+ const base = specifier.startsWith('.')
344
+ ? resolve(dirname(current), specifier)
345
+ : join(srcRoot, specifier.replace(/^[@~]\//, ''))
309
346
  const candidates = [
310
- join(srcRoot, `${base}.ts`),
311
- join(srcRoot, `${base}.tsx`),
312
- join(srcRoot, base, 'index.ts'),
313
- join(srcRoot, base, 'index.tsx'),
347
+ `${base}.ts`,
348
+ `${base}.tsx`,
349
+ join(base, 'index.ts'),
350
+ join(base, 'index.tsx'),
314
351
  ]
315
352
  const found = candidates.find((candidate) => existsSync(candidate))
316
353
  if (found) queue.push(found)
@@ -877,6 +914,40 @@ const USAGE_EXAMPLES = {
877
914
  <p className="text-sm">Fade in on mount.</p>
878
915
  </FadeIn>
879
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
+ )
880
951
  `,
881
952
  },
882
953
  table: {
@@ -1036,14 +1107,14 @@ function addJsxUsage(source, exportNames) {
1036
1107
  * Writes (or completes) the chosen file: `@/` imports from the real folder of
1037
1108
  * the components, a working example (`<CalendarExample />`) and its definition.
1038
1109
  */
1039
- function writeUsage({ cwd, target, componentsDir, components, alias, dryRun }) {
1110
+ function writeUsage({ cwd, target, components, alias, dryRun }) {
1040
1111
  if (existsSync(target) && statSync(target).isDirectory()) {
1041
1112
  fail(`${relative(cwd, target)} is a folder: give a file (e.g. src/pages/${components[0].name}.tsx).`)
1042
1113
  }
1043
1114
  const created = !existsSync(target)
1044
1115
  const entries = components.map((component) => ({
1045
1116
  exportName: component.exportName,
1046
- specifier: aliasImport(cwd, componentsDir, component.name, alias),
1117
+ specifier: aliasImport(cwd, component.dir, component.name, alias),
1047
1118
  example: exampleFor(component),
1048
1119
  }))
1049
1120
  const label = relative(cwd, target)
@@ -1122,15 +1193,26 @@ const ALIASES = {
1122
1193
  shortcuts: 'command-menu',
1123
1194
  upload: 'file-upload',
1124
1195
  video: 'video-player',
1196
+ storytelling: 'horizontal-story',
1197
+ stacking: 'stacking-cards',
1125
1198
  }
1126
1199
 
1127
1200
  function commandList() {
1128
- const components = listComponents()
1129
- 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
+ }
1130
1206
  console.log('\nAvailable components:\n')
1131
1207
  for (const name of components) {
1132
1208
  console.log(` \x1b[36m${name.padEnd(20)}\x1b[0m src/components/ui/${name}.tsx`)
1133
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
+ }
1134
1216
  console.log('\nUsage: uifolio add <name> [--cwd <target project>]\n')
1135
1217
  }
1136
1218
 
@@ -1142,12 +1224,14 @@ function commandInit({ flags }) {
1142
1224
  const config = {
1143
1225
  $schema: 'https://uifolio.dev/schema.json',
1144
1226
  dir: typeof flags.dir === 'string' ? flags.dir : DEFAULT_COMPONENTS_DIR,
1227
+ animations:
1228
+ typeof flags.animations === 'string' ? flags.animations : DEFAULT_ANIMATIONS_DIR,
1145
1229
  lib: typeof flags.lib === 'string' ? flags.lib : 'src/lib',
1146
1230
  alias: typeof flags.alias === 'string' ? flags.alias : '@',
1147
1231
  }
1148
1232
  writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
1149
1233
  ok(`${CONFIG_NAME} written to ${cwd}`)
1150
- info(`dir: ${config.dir} · lib: ${config.lib} · alias: ${config.alias}`)
1234
+ info(`dir: ${config.dir} · animations: ${config.animations} · lib: ${config.lib} · alias: ${config.alias}`)
1151
1235
  info('Next: uifolio add video-player calendar …')
1152
1236
  }
1153
1237
 
@@ -1227,13 +1311,21 @@ async function commandAdd({ positional, flags }) {
1227
1311
 
1228
1312
  const prompter = createPrompter(flags)
1229
1313
  try {
1230
- // 1. Dossier d'import des composants : toujours src/components/ui.
1314
+ // 1. Dossiers d'import : composants, animations et helpers.
1231
1315
  const componentsDir = normalizeComponentsDir(
1232
1316
  typeof flags.dir === 'string' ? flags.dir : config.dir ?? DEFAULT_COMPONENTS_DIR,
1233
1317
  )
1234
1318
  if (looksLikeFile(componentsDir)) {
1235
1319
  fail(`"${componentsDir}" is a file: give a folder (e.g. ${DEFAULT_COMPONENTS_DIR}).`)
1236
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
+ }
1237
1329
 
1238
1330
  // 2. Copie des fichiers + helpers.
1239
1331
  const deps = new Set(LIB_DEPS)
@@ -1243,14 +1335,26 @@ async function commandAdd({ positional, flags }) {
1243
1335
  const components = []
1244
1336
 
1245
1337
  for (const name of names) {
1246
- const entry = join(REGISTRY_UI, `${name}.tsx`)
1247
- const files = collectFiles(entry, REGISTRY_SRC)
1248
- 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
+ })
1249
1346
 
1250
1347
  for (const [absolute, relativePath] of files) {
1251
- const isUi = relativePath.startsWith(join('components', 'ui'))
1252
- const destinationRoot = isUi ? resolve(cwd, componentsDir) : resolve(cwd, libDir)
1253
- 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)
1254
1358
  if (destinations.has(destination)) continue
1255
1359
  destinations.add(destination)
1256
1360
 
@@ -1301,7 +1405,6 @@ async function commandAdd({ positional, flags }) {
1301
1405
  writeUsage({
1302
1406
  cwd,
1303
1407
  target: pageTarget,
1304
- componentsDir,
1305
1408
  components,
1306
1409
  alias: targetAlias,
1307
1410
  dryRun,
@@ -1336,6 +1439,7 @@ function usage() {
1336
1439
  Options:
1337
1440
  --cwd <dir> target project (default: current directory)
1338
1441
  --dir <path> components folder (default: src/components/ui)
1442
+ --animations <path> animations folder (default: src/components/animations)
1339
1443
  --lib <path> helpers folder (default: src/lib)
1340
1444
  --alias <alias> target import alias (default: @)
1341
1445
  --color <hex> primary color, written in the Tailwind tokens (@theme)
@@ -1348,9 +1452,11 @@ Options:
1348
1452
 
1349
1453
  Without --page, the CLI asks whether a file is already ready to receive the
1350
1454
  components: they are added to that file, to a new folder + file, or installed
1351
- only. Files go to src/components/ui (override with --dir) and the page imports
1352
- them through the alias (@/components/ui/...). The page also gets a working
1353
- 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.
1354
1460
  Colors go through a Tailwind CSS 4 theme (uifolio-theme.css, @theme), so classes
1355
1461
  like bg-primary and text-muted-foreground stay in the code. When Tailwind is
1356
1462
  missing, "add" installs it (with your lockfile's package manager), creates the
@@ -1361,6 +1467,7 @@ installed too, as runtime dependencies, when the manifest lacks them.
1361
1467
 
1362
1468
  Examples:
1363
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
1364
1471
  node cli/uifolio.mjs add otp --color "#10B981" --radius 8
1365
1472
  node cli/uifolio.mjs add calendar --page src/pages/calendar.tsx
1366
1473
  `)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uifolio",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "CLI that copies React components (calendar, video/audio players, OTP, editor, upload) and their helpers into your project, with Tailwind CSS 4 tokens.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,6 +55,7 @@
55
55
  "cli",
56
56
  "src/components/ui",
57
57
  "!src/components/ui/stories-tokens.ts",
58
+ "src/components/animations",
58
59
  "src/lib/color.ts",
59
60
  "src/lib/date.ts",
60
61
  "src/lib/uploader.ts",
@@ -0,0 +1,71 @@
1
+ import { createContext, useContext } from 'react'
2
+
3
+ export interface AnimationSettings {
4
+ delay: number
5
+ duration: number
6
+ easing: string
7
+ }
8
+
9
+ export interface AnimationSettingsContextValue extends AnimationSettings {
10
+ setDelay: (value: number) => void
11
+ setDuration: (value: number) => void
12
+ setEasing: (value: string) => void
13
+ reset: () => void
14
+ isCustomized: boolean
15
+ }
16
+
17
+ export const EASING_OPTIONS = [
18
+ { value: 'ease-out', label: 'Soft' },
19
+ { value: 'ease-in-out', label: 'Smooth' },
20
+ { value: 'cubic-bezier(0.65, 0, 0.35, 1)', label: 'Spring' },
21
+ { value: 'cubic-bezier(0.34, 1.56, 0.64, 1)', label: 'Bounce' },
22
+ { value: 'linear', label: 'Linear' },
23
+ ] as const
24
+
25
+ const fallback: AnimationSettings = {
26
+ delay: 0,
27
+ duration: 500,
28
+ easing: 'ease-out',
29
+ }
30
+
31
+ const animationDefaults: Record<string, AnimationSettings> = {
32
+ 'horizontal-accordion': {
33
+ delay: 0,
34
+ duration: 500,
35
+ easing: 'cubic-bezier(0.65, 0, 0.35, 1)',
36
+ },
37
+ storytelling: {
38
+ delay: 0,
39
+ duration: 500,
40
+ easing: 'cubic-bezier(0.65, 0, 0.35, 1)',
41
+ },
42
+ stacking: {
43
+ delay: 0,
44
+ duration: 300,
45
+ easing: 'ease-out',
46
+ },
47
+ 'scroll-text': {
48
+ delay: 0,
49
+ duration: 300,
50
+ easing: 'ease-out',
51
+ },
52
+ 'image-crossfade': {
53
+ delay: 0,
54
+ duration: 500,
55
+ easing: 'ease-out',
56
+ },
57
+ }
58
+
59
+ export function defaultAnimationSettings(slug?: string): AnimationSettings {
60
+ return (slug && animationDefaults[slug]) || fallback
61
+ }
62
+
63
+ export const AnimationSettingsContext = createContext<AnimationSettingsContextValue | null>(null)
64
+
65
+ export function useAnimationSettings() {
66
+ const context = useContext(AnimationSettingsContext)
67
+ if (!context) {
68
+ throw new Error('useAnimationSettings must be used within AnimationSettingsProvider')
69
+ }
70
+ return context
71
+ }
@@ -0,0 +1,69 @@
1
+ import { useCallback, useMemo, useState } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import {
4
+ AnimationSettingsContext,
5
+ defaultAnimationSettings,
6
+ type AnimationSettings,
7
+ } from './animation-settings-context'
8
+
9
+ export function AnimationSettingsProvider({
10
+ current,
11
+ children,
12
+ }: {
13
+ current?: string
14
+ children: ReactNode
15
+ }) {
16
+ const key = current ?? '__default'
17
+ const [settingsBySlug, setSettingsBySlug] = useState<Record<string, AnimationSettings>>({})
18
+
19
+ const settings = settingsBySlug[key] ?? defaultAnimationSettings(current)
20
+
21
+ const update = useCallback(
22
+ (patch: Partial<AnimationSettings>) => {
23
+ setSettingsBySlug((previous) => ({
24
+ ...previous,
25
+ [key]: {
26
+ ...(previous[key] ?? defaultAnimationSettings(current)),
27
+ ...patch,
28
+ },
29
+ }))
30
+ },
31
+ [current, key],
32
+ )
33
+
34
+ const setDelay = useCallback(
35
+ (delay: number) => update({ delay: Math.max(0, Math.min(2000, delay)) }),
36
+ [update],
37
+ )
38
+ const setDuration = useCallback(
39
+ (duration: number) => update({ duration: Math.max(100, Math.min(3000, duration)) }),
40
+ [update],
41
+ )
42
+ const setEasing = useCallback((easing: string) => update({ easing }), [update])
43
+
44
+ const reset = useCallback(() => {
45
+ setSettingsBySlug((previous) => {
46
+ const next = { ...previous }
47
+ delete next[key]
48
+ return next
49
+ })
50
+ }, [key])
51
+
52
+ const value = useMemo(
53
+ () => ({
54
+ ...settings,
55
+ setDelay,
56
+ setDuration,
57
+ setEasing,
58
+ reset,
59
+ isCustomized: Boolean(settingsBySlug[key]),
60
+ }),
61
+ [reset, setDelay, setDuration, setEasing, settings, settingsBySlug, key],
62
+ )
63
+
64
+ return (
65
+ <AnimationSettingsContext.Provider value={value}>
66
+ {children}
67
+ </AnimationSettingsContext.Provider>
68
+ )
69
+ }
@@ -0,0 +1,68 @@
1
+ export interface MotionCardData {
2
+ id: string
3
+ number: string
4
+ title: string
5
+ kicker: string
6
+ description: string
7
+ gradient: string
8
+ }
9
+
10
+ const seeds: Omit<MotionCardData, 'id' | 'number'>[] = [
11
+ {
12
+ title: 'Explore',
13
+ kicker: 'Field',
14
+ description: 'Spot the signals, collect the material and open up the range of possibilities.',
15
+ gradient: 'linear-gradient(135deg, color-mix(in oklab, var(--primary) 22%, #000) 0%, var(--primary) 58%, color-mix(in oklab, var(--primary) 55%, #fff) 100%)',
16
+ },
17
+ {
18
+ title: 'Compose',
19
+ kicker: 'Workshop',
20
+ description: 'Assemble the pieces, test rhythms and let the structure breathe.',
21
+ gradient: 'linear-gradient(145deg, color-mix(in oklab, var(--primary) 38%, #000) 0%, color-mix(in oklab, var(--primary) 85%, #000) 52%, color-mix(in oklab, var(--primary) 62%, #fff) 100%)',
22
+ },
23
+ {
24
+ title: 'Publish',
25
+ kicker: 'Release',
26
+ description: 'Shape it, tell it and share a clear version to read and to live with.',
27
+ gradient: 'linear-gradient(125deg, color-mix(in oklab, var(--primary) 15%, #000) 0%, color-mix(in oklab, var(--primary) 70%, #000) 55%, color-mix(in oklab, var(--primary) 35%, #fff) 100%)',
28
+ },
29
+ {
30
+ title: 'Measure',
31
+ kicker: 'Reading',
32
+ description: 'Watch the usage, compare the signals and keep what truly makes sense.',
33
+ gradient: 'linear-gradient(155deg, color-mix(in oklab, var(--primary) 30%, #000) 0%, var(--primary) 60%, color-mix(in oklab, var(--primary) 75%, #fff) 100%)',
34
+ },
35
+ {
36
+ title: 'Iterate',
37
+ kicker: 'Loop',
38
+ description: 'Fix, refine, start again: every pass tightens the gesture and the message.',
39
+ gradient: 'linear-gradient(120deg, color-mix(in oklab, var(--primary) 12%, #000) 0%, color-mix(in oklab, var(--primary) 60%, #000) 50%, color-mix(in oklab, var(--primary) 25%, #fff) 100%)',
40
+ },
41
+ {
42
+ title: 'Spread',
43
+ kicker: 'Network',
44
+ description: 'Let the material circulate, build bridges and let the project spread.',
45
+ gradient: 'linear-gradient(135deg, color-mix(in oklab, var(--primary) 25%, #000) 0%, color-mix(in oklab, var(--primary) 90%, #000) 55%, color-mix(in oklab, var(--primary) 50%, #fff) 100%)',
46
+ },
47
+ {
48
+ title: 'Archive',
49
+ kicker: 'Memory',
50
+ description: 'Keep the traces, document the choices and make the work pass on.',
51
+ gradient: 'linear-gradient(150deg, color-mix(in oklab, var(--primary) 45%, #000) 0%, color-mix(in oklab, var(--primary) 75%, #000) 50%, color-mix(in oklab, var(--primary) 40%, #fff) 100%)',
52
+ },
53
+ {
54
+ title: 'Start over',
55
+ kicker: 'Cycle',
56
+ description: 'Set off again with what you learned, without losing the momentum or the precision of the first gesture.',
57
+ gradient: 'linear-gradient(130deg, color-mix(in oklab, var(--primary) 18%, #000) 0%, var(--primary) 55%, color-mix(in oklab, var(--primary) 70%, #fff) 100%)',
58
+ },
59
+ ]
60
+
61
+ export function makeMotionCards(count: number): MotionCardData[] {
62
+ const total = Math.max(1, Math.min(count, seeds.length))
63
+ return seeds.slice(0, total).map((seed, index) => ({
64
+ ...seed,
65
+ id: `${index}-${seed.title.toLowerCase()}`,
66
+ number: String(index + 1).padStart(2, '0'),
67
+ }))
68
+ }