uifolio 0.6.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uifolio",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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
+ }
@@ -0,0 +1,142 @@
1
+ import { useRef, useState } from 'react'
2
+ import type { KeyboardEvent } from 'react'
3
+ import { ArrowRight } from 'lucide-react'
4
+ import { cn } from '@/lib/utils'
5
+ import { makeMotionCards } from './data'
6
+
7
+ export interface HorizontalAccordionProps {
8
+ count?: number
9
+ delay?: number
10
+ duration?: number
11
+ easing?: string
12
+ }
13
+
14
+ export function HorizontalAccordion({
15
+ count = 6,
16
+ delay = 0,
17
+ duration = 500,
18
+ easing = 'cubic-bezier(0.65, 0, 0.35, 1)',
19
+ }: HorizontalAccordionProps) {
20
+ const cards = makeMotionCards(count)
21
+ const [active, setActive] = useState(0)
22
+ const current = Math.min(active, cards.length - 1)
23
+ const compact = count > 5
24
+ const buttons = useRef<Array<HTMLButtonElement | null>>([])
25
+
26
+ const go = (index: number) => {
27
+ const next = (index + cards.length) % cards.length
28
+ setActive(next)
29
+ buttons.current[next]?.focus()
30
+ }
31
+
32
+ const handleKeyDown = (index: number, event: KeyboardEvent<HTMLButtonElement>) => {
33
+ if (event.key === 'ArrowRight') {
34
+ event.preventDefault()
35
+ go(index + 1)
36
+ }
37
+ if (event.key === 'ArrowLeft') {
38
+ event.preventDefault()
39
+ go(index - 1)
40
+ }
41
+ if (event.key === 'Home') {
42
+ event.preventDefault()
43
+ go(0)
44
+ }
45
+ if (event.key === 'End') {
46
+ event.preventDefault()
47
+ go(cards.length - 1)
48
+ }
49
+ }
50
+
51
+ return (
52
+ <div className="flex h-[400px] gap-1.5 overflow-hidden rounded-sm border bg-card/60 p-1.5 sm:h-[460px] sm:gap-2 sm:p-2">
53
+ {cards.map((card, index) => {
54
+ const isActive = index === current
55
+ return (
56
+ <button
57
+ key={card.id}
58
+ ref={(element) => {
59
+ buttons.current[index] = element
60
+ }}
61
+ type="button"
62
+ onClick={() => setActive(index)}
63
+ onPointerEnter={(event) => {
64
+ if (event.pointerType === 'mouse') setActive(index)
65
+ }}
66
+ onFocus={() => setActive(index)}
67
+ onKeyDown={(event) => handleKeyDown(index, event)}
68
+ aria-pressed={isActive}
69
+ aria-label={card.title}
70
+ tabIndex={isActive ? 0 : -1}
71
+ style={{
72
+ transitionDelay: `${delay}ms`,
73
+ transitionDuration: `${duration}ms`,
74
+ transitionTimingFunction: easing,
75
+ }}
76
+ className={cn(
77
+ 'relative min-w-0 basis-0 overflow-hidden rounded-sm border text-left outline-none transition-[flex-grow,background-color,color] focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',
78
+ isActive
79
+ ? compact
80
+ ? 'grow-[4.2] border-primary/50 bg-primary text-primary-foreground shadow-lg max-sm:grow-[6.2]'
81
+ : 'grow-[4.2] border-primary/50 bg-primary text-primary-foreground shadow-lg'
82
+ : compact
83
+ ? 'grow-[0.7] border-border bg-muted/30 text-muted-foreground hover:bg-muted hover:text-foreground max-sm:grow-[0.3]'
84
+ : 'grow-[0.7] border-border bg-muted/30 text-muted-foreground hover:bg-muted hover:text-foreground',
85
+ )}
86
+ >
87
+ <span
88
+ className={cn(
89
+ 'absolute right-2 top-1 font-mono text-5xl font-black leading-none tracking-tighter transition-opacity sm:text-7xl',
90
+ isActive ? 'opacity-20' : 'opacity-10',
91
+ !isActive && compact && 'max-sm:hidden',
92
+ )}
93
+ >
94
+ {card.number}
95
+ </span>
96
+
97
+ <span
98
+ className={cn(
99
+ 'absolute bottom-3 left-3 whitespace-nowrap text-[10px] font-semibold uppercase tracking-[0.2em] [writing-mode:vertical-rl] sm:bottom-4 sm:left-4',
100
+ isActive ? 'opacity-0' : 'opacity-100',
101
+ compact && 'max-sm:left-1/2 max-sm:-translate-x-1/2 max-sm:text-[8px] max-sm:tracking-[0.12em]',
102
+ )}
103
+ >
104
+ {card.title}
105
+ </span>
106
+
107
+ <span
108
+ className={cn(
109
+ 'absolute inset-x-0 bottom-0 block p-4 transition-all sm:p-5',
110
+ isActive ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0',
111
+ )}
112
+ style={{
113
+ transitionDelay: `${delay}ms`,
114
+ transitionDuration: `${duration}ms`,
115
+ transitionTimingFunction: easing,
116
+ }}
117
+ >
118
+ <span className="block text-[9px] font-bold uppercase tracking-[0.22em] opacity-55">
119
+ Étape {card.number} · {card.kicker}
120
+ </span>
121
+ <span
122
+ className={cn(
123
+ 'display mt-2 block text-3xl leading-[0.95] sm:text-5xl',
124
+ compact && 'max-sm:text-2xl',
125
+ )}
126
+ >
127
+ {card.title}
128
+ </span>
129
+ <span className="mt-3 block max-w-xs text-[11px] leading-relaxed opacity-65">
130
+ {card.description}
131
+ </span>
132
+ <span className="mt-4 inline-flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.16em]">
133
+ Continuer
134
+ <ArrowRight className="size-3.5" />
135
+ </span>
136
+ </span>
137
+ </button>
138
+ )
139
+ })}
140
+ </div>
141
+ )
142
+ }
@@ -0,0 +1,204 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { ArrowDown } from 'lucide-react'
3
+ import { cn } from '@/lib/utils'
4
+ import { makeMotionCards } from './data'
5
+ import { cardBackground, paletteAura, type CardPalette } from './palette'
6
+
7
+ export interface HorizontalStoryProps {
8
+ count?: number
9
+ palette?: CardPalette
10
+ delay?: number
11
+ duration?: number
12
+ easing?: string
13
+ }
14
+
15
+ export function HorizontalStory({
16
+ count = 6,
17
+ palette,
18
+ delay = 0,
19
+ duration = 500,
20
+ easing = 'cubic-bezier(0.65, 0, 0.35, 1)',
21
+ }: HorizontalStoryProps) {
22
+ const cards = makeMotionCards(count).map((card) => ({
23
+ ...card,
24
+ gradient: cardBackground(palette, card.gradient),
25
+ }))
26
+ const section = useRef<HTMLDivElement>(null)
27
+ const viewport = useRef<HTMLDivElement>(null)
28
+ const track = useRef<HTMLDivElement>(null)
29
+ const flat = palette?.mode === 'solid'
30
+ const [progress, setProgress] = useState(0)
31
+ const [distance, setDistance] = useState(0)
32
+
33
+ useEffect(() => {
34
+ const measure = () => {
35
+ const trackElement = track.current
36
+ const viewportElement = viewport.current
37
+ if (!trackElement || !viewportElement) return
38
+ setDistance(Math.max(0, trackElement.scrollWidth - viewportElement.clientWidth))
39
+ }
40
+
41
+ measure()
42
+ const observer = new ResizeObserver(measure)
43
+ if (track.current) observer.observe(track.current)
44
+ if (viewport.current) observer.observe(viewport.current)
45
+ window.addEventListener('resize', measure)
46
+ return () => {
47
+ observer.disconnect()
48
+ window.removeEventListener('resize', measure)
49
+ }
50
+ }, [count])
51
+
52
+ useEffect(() => {
53
+ let frame = 0
54
+ const update = () => {
55
+ cancelAnimationFrame(frame)
56
+ frame = requestAnimationFrame(() => {
57
+ const element = section.current
58
+ if (!element) return
59
+ const total = element.offsetHeight - window.innerHeight + 57
60
+ const top = element.getBoundingClientRect().top - 57
61
+ setProgress(total > 0 ? Math.min(1, Math.max(0, -top / total)) : 0)
62
+ })
63
+ }
64
+
65
+ update()
66
+ window.addEventListener('scroll', update, { passive: true })
67
+ window.addEventListener('resize', update)
68
+ return () => {
69
+ window.removeEventListener('scroll', update)
70
+ window.removeEventListener('resize', update)
71
+ cancelAnimationFrame(frame)
72
+ }
73
+ }, [])
74
+
75
+ const active = Math.min(cards.length - 1, Math.round(progress * (cards.length - 1)))
76
+
77
+ return (
78
+ <>
79
+ <div
80
+ ref={section}
81
+ className="relative hidden md:block"
82
+ style={{ height: `calc(100dvh + ${(cards.length - 1) * 60}vh)` }}
83
+ >
84
+ <div className="sticky top-[57px] h-[calc(100dvh-57px)] overflow-hidden rounded-sm border bg-card/60">
85
+ <div
86
+ aria-hidden
87
+ className="pointer-events-none absolute inset-0"
88
+ style={{
89
+ background: paletteAura(palette),
90
+ }}
91
+ />
92
+
93
+ <div className="absolute inset-x-0 top-0 z-20 flex items-center justify-between px-5 py-4">
94
+ <span className="inline-flex items-center gap-2 font-mono text-[9px] uppercase tracking-[0.22em] text-muted-foreground">
95
+ <ArrowDown className="size-3" />
96
+ Scroll pour avancer
97
+ </span>
98
+ <span className="font-mono text-[9px] uppercase tracking-[0.22em] text-muted-foreground">
99
+ {String(active + 1).padStart(2, '0')} / {String(cards.length).padStart(2, '0')}
100
+ </span>
101
+ </div>
102
+
103
+ <div ref={viewport} className="flex h-full items-center overflow-hidden">
104
+ <div
105
+ ref={track}
106
+ className="flex w-max items-center gap-5 pl-[6vw] pr-[12vw] will-change-transform"
107
+ style={{ transform: `translate3d(-${progress * distance}px, 0, 0)` }}
108
+ >
109
+ {cards.map((card, index) => {
110
+ const isActive = index === active
111
+ return (
112
+ <article
113
+ key={card.id}
114
+ aria-current={isActive ? 'step' : undefined}
115
+ className={cn(
116
+ 'relative h-[min(62vh,500px)] w-[min(68vw,580px)] shrink-0 overflow-hidden rounded-sm border text-left transition-[opacity,border-color,transform]',
117
+ isActive
118
+ ? 'border-primary/50 opacity-100'
119
+ : 'border-border opacity-45',
120
+ )}
121
+ data-motion-card
122
+ style={{
123
+ background: card.gradient,
124
+ transitionDelay: `${delay}ms`,
125
+ transitionDuration: `${duration}ms`,
126
+ transitionTimingFunction: easing,
127
+ }}
128
+ >
129
+ {!flat && (
130
+ <>
131
+ <span data-card-scrim className="absolute inset-0 bg-black/35" />
132
+ <span data-card-scrim className="absolute inset-x-0 bottom-0 h-2/3 bg-gradient-to-t from-black/85 via-black/30 to-transparent" />
133
+ </>
134
+ )}
135
+
136
+ <span className="absolute right-5 top-3 font-mono text-7xl font-black leading-none tracking-tighter text-white/15">
137
+ {card.number}
138
+ </span>
139
+ <span className="absolute left-5 top-5 text-[9px] font-semibold uppercase tracking-[0.22em] text-white/60">
140
+ Chapitre {card.number} · {card.kicker}
141
+ </span>
142
+
143
+ <span className="absolute inset-x-0 bottom-0 block p-6">
144
+ <span className="display block text-4xl leading-none text-white lg:text-5xl">
145
+ {card.title}
146
+ </span>
147
+ <span className="mt-3 block max-w-md text-[12px] leading-relaxed text-white/70">
148
+ {card.description}
149
+ </span>
150
+ </span>
151
+ </article>
152
+ )
153
+ })}
154
+ </div>
155
+ </div>
156
+
157
+ <div className="absolute inset-x-0 bottom-0 z-20 px-5 py-4">
158
+ <div className="h-px overflow-hidden bg-border">
159
+ <div
160
+ className="h-full bg-primary transition-[width] duration-150"
161
+ style={{ width: `${Math.round(progress * 100)}%` }}
162
+ />
163
+ </div>
164
+ </div>
165
+ </div>
166
+ </div>
167
+
168
+ <div className="md:hidden">
169
+ <div className="flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
170
+ {cards.map((card) => (
171
+ <article
172
+ key={card.id}
173
+ className="relative h-[340px] w-[82%] shrink-0 snap-center overflow-hidden rounded-sm border border-border"
174
+ data-motion-card
175
+ style={{ background: card.gradient }}
176
+ >
177
+ {!flat && (
178
+ <>
179
+ <span data-card-scrim className="absolute inset-0 bg-black/35" />
180
+ <span data-card-scrim className="absolute inset-x-0 bottom-0 h-2/3 bg-gradient-to-t from-black/85 via-black/30 to-transparent" />
181
+ </>
182
+ )}
183
+ <span className="absolute left-4 top-4 font-mono text-[10px] tracking-[0.2em] text-white/70">
184
+ {card.number}
185
+ </span>
186
+ <span className="absolute inset-x-0 bottom-0 block p-5">
187
+ <span className="block text-[9px] font-semibold uppercase tracking-[0.22em] text-white/60">
188
+ {card.kicker}
189
+ </span>
190
+ <span className="display mt-1.5 block text-3xl leading-none text-white">
191
+ {card.title}
192
+ </span>
193
+ <span className="mt-2 block text-[11px] leading-relaxed text-white/70">
194
+ {card.description}
195
+ </span>
196
+ </span>
197
+ </article>
198
+ ))}
199
+ </div>
200
+ <p className="mt-1 text-[10px] text-muted-foreground">Glisse horizontalement.</p>
201
+ </div>
202
+ </>
203
+ )
204
+ }