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.
@@ -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
+ }
@@ -0,0 +1,276 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { useReducedMotion } from '@/lib/use-reduced-motion'
3
+
4
+ export interface CrossfadeImage {
5
+ src: string
6
+ alt: string
7
+ kicker: string
8
+ title: string
9
+ description: string
10
+ }
11
+
12
+ const defaultOverlay =
13
+ 'linear-gradient(to top, rgb(0 0 0 / 0.55), rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.35))'
14
+
15
+ const defaultImages: CrossfadeImage[] = [
16
+ {
17
+ src: '/crossfade-1.svg',
18
+ alt: 'Hills at sunset, in amber and coral tones.',
19
+ kicker: '01 — Light',
20
+ title: 'Dawn',
21
+ description: 'The day settles over the reliefs, the colors warm up.',
22
+ },
23
+ {
24
+ src: '/crossfade-2.svg',
25
+ alt: 'Turquoise sea horizon under a pale moon.',
26
+ kicker: '02 — Tide',
27
+ title: 'Ocean',
28
+ description: 'A horizon line, two tones, and the motion that ties them together.',
29
+ },
30
+ {
31
+ src: '/crossfade-3.svg',
32
+ alt: 'Deep forest crossed by a green mist.',
33
+ kicker: '03 — Canopy',
34
+ title: 'Forest',
35
+ description: 'Silhouettes answer each other in filtered light.',
36
+ },
37
+ {
38
+ src: '/crossfade-4.svg',
39
+ alt: 'Nocturne mountains under an indigo sky and a clear moon.',
40
+ kicker: '04 — Night',
41
+ title: 'Night',
42
+ description: 'When the light drops, the outlines become secrets.',
43
+ },
44
+ ]
45
+
46
+ function clamp(value: number) {
47
+ return Math.min(1, Math.max(0, value))
48
+ }
49
+
50
+ function StaticGallery({ images }: { images: CrossfadeImage[] }) {
51
+ return (
52
+ <div className="grid gap-3 sm:grid-cols-2">
53
+ {images.map((image) => (
54
+ <figure key={image.src} className="overflow-hidden rounded-sm border bg-card">
55
+ <img
56
+ src={image.src}
57
+ alt={image.alt}
58
+ loading="lazy"
59
+ decoding="async"
60
+ className="aspect-[16/10] w-full object-cover"
61
+ />
62
+ <figcaption className="px-3 py-2.5">
63
+ <span className="text-[9px] font-semibold uppercase tracking-[0.16em] text-primary">
64
+ {image.kicker}
65
+ </span>
66
+ <p className="mt-0.5 text-sm font-semibold">{image.title}</p>
67
+ </figcaption>
68
+ </figure>
69
+ ))}
70
+ </div>
71
+ )
72
+ }
73
+
74
+ export interface ImageCrossfadeProps {
75
+ images?: CrossfadeImage[]
76
+ overlay?: string
77
+ delay?: number
78
+ duration?: number
79
+ easing?: string
80
+ }
81
+
82
+ export function ImageCrossfade({
83
+ images = defaultImages,
84
+ overlay = defaultOverlay,
85
+ delay = 0,
86
+ duration = 500,
87
+ easing = 'ease-out',
88
+ }: ImageCrossfadeProps) {
89
+ const list = images.length > 0 ? images : defaultImages
90
+ const section = useRef<HTMLDivElement>(null)
91
+ const [progress, setProgress] = useState(0)
92
+ const reduced = useReducedMotion()
93
+
94
+ useEffect(() => {
95
+ if (reduced) return
96
+
97
+ let frame = 0
98
+ const update = () => {
99
+ cancelAnimationFrame(frame)
100
+ frame = requestAnimationFrame(() => {
101
+ const element = section.current
102
+ if (!element) return
103
+ const total = element.offsetHeight - window.innerHeight + 57
104
+ const top = element.getBoundingClientRect().top - 57
105
+ setProgress(total > 0 ? Math.min(1, Math.max(0, -top / total)) : 0)
106
+ })
107
+ }
108
+
109
+ update()
110
+ window.addEventListener('scroll', update, { passive: true })
111
+ window.addEventListener('resize', update)
112
+ return () => {
113
+ window.removeEventListener('scroll', update)
114
+ window.removeEventListener('resize', update)
115
+ cancelAnimationFrame(frame)
116
+ }
117
+ }, [reduced])
118
+
119
+ if (reduced) return <StaticGallery images={list} />
120
+
121
+ const slideCount = list.length
122
+ const maxIndex = Math.max(1, slideCount - 1)
123
+ const position = slideCount <= 1 ? 0 : progress * maxIndex
124
+ const index = Math.min(slideCount - 1, Math.floor(position))
125
+ const blend = slideCount <= 1 ? 0 : position - index
126
+ const textT = clamp((blend - 0.44) / 0.12)
127
+ const textEased = textT * textT * (3 - 2 * textT)
128
+ const active = Math.min(slideCount - 1, Math.round(position))
129
+ const scale = 1.02 + progress * 0.04
130
+
131
+ const goTo = (target: number) => {
132
+ const element = section.current
133
+ if (!element) return
134
+ const total = element.offsetHeight - window.innerHeight + 57
135
+ const top = element.getBoundingClientRect().top + window.scrollY - 57
136
+ window.scrollTo({
137
+ top: top + total * (target / maxIndex),
138
+ behavior: 'smooth',
139
+ })
140
+ }
141
+
142
+ return (
143
+ <div
144
+ ref={section}
145
+ className="relative"
146
+ style={{ height: `${Math.max(2, slideCount) * 100}vh` }}
147
+ >
148
+ <div className="sticky top-[57px] flex h-[calc(100dvh-57px)] items-center justify-center px-0 py-2 sm:px-4 sm:py-4">
149
+ <div className="relative flex h-[min(calc(100dvh-85px),680px)] w-full max-w-5xl flex-col overflow-hidden rounded-sm border bg-card shadow-xl sm:flex-row">
150
+ <div className="relative h-[42%] w-full shrink-0 overflow-hidden bg-muted sm:h-auto sm:min-h-0 sm:flex-1">
151
+ {list.map((image, imageIndex) => {
152
+ const isCurrent = slideCount > 1 && imageIndex === index
153
+ const isNext = slideCount > 1 && imageIndex === index + 1
154
+ const visible = slideCount <= 1 ? imageIndex === 0 : isCurrent || isNext
155
+ return (
156
+ <img
157
+ key={image.src}
158
+ src={image.src}
159
+ alt={image.alt}
160
+ loading="eager"
161
+ decoding="async"
162
+ draggable={false}
163
+ aria-hidden={imageIndex !== active}
164
+ data-image-slide
165
+ className="absolute inset-0 h-full w-full object-cover will-change-[clip-path,transform]"
166
+ style={{
167
+ opacity: visible ? 1 : 0,
168
+ zIndex: isCurrent ? 2 : isNext ? 1 : 0,
169
+ clipPath: isCurrent ? `inset(0 0 ${blend * 100}% 0)` : undefined,
170
+ transform: `scale(${scale})`,
171
+ transitionProperty: 'opacity',
172
+ transitionDelay: `${delay}ms`,
173
+ transitionDuration: `${duration}ms`,
174
+ transitionTimingFunction: easing,
175
+ }}
176
+ />
177
+ )
178
+ })}
179
+
180
+ <div
181
+ aria-hidden
182
+ className="pointer-events-none absolute inset-0"
183
+ style={{ background: overlay }}
184
+ />
185
+
186
+ <div className="absolute inset-x-0 top-0 flex items-center justify-between gap-3 px-4 py-3 text-white sm:px-5">
187
+ <span className="text-[9px] font-semibold uppercase tracking-[0.2em] text-white/80">
188
+ Images fondues
189
+ </span>
190
+ <span className="font-mono text-[9px] uppercase tracking-[0.18em] text-white/80">
191
+ {String(active + 1).padStart(2, '0')} / {String(slideCount).padStart(2, '0')}
192
+ </span>
193
+ </div>
194
+ </div>
195
+
196
+ <div className="relative flex min-h-0 flex-1 flex-col justify-between border-t bg-card p-5 sm:w-[43%] sm:flex-none sm:border-l sm:border-t-0 sm:p-7">
197
+ <div className="relative min-h-0 flex-1">
198
+ {list.map((image, imageIndex) => {
199
+ const opacity =
200
+ slideCount <= 1
201
+ ? imageIndex === 0
202
+ ? 1
203
+ : 0
204
+ : imageIndex === index
205
+ ? 1 - textEased
206
+ : imageIndex === index + 1
207
+ ? textEased
208
+ : 0
209
+ return (
210
+ <div
211
+ key={image.src}
212
+ data-text-slide
213
+ data-active={imageIndex === active ? 'true' : undefined}
214
+ aria-hidden={imageIndex !== active}
215
+ className="pointer-events-none absolute inset-0 flex flex-col justify-center"
216
+ style={{
217
+ opacity,
218
+ zIndex: imageIndex === index ? 2 : imageIndex === index + 1 ? 1 : 0,
219
+ transitionProperty: 'opacity',
220
+ transitionDelay: `${delay}ms`,
221
+ transitionDuration: `${duration}ms`,
222
+ transitionTimingFunction: easing,
223
+ }}
224
+ >
225
+ <p className="text-[9px] font-semibold uppercase tracking-[0.2em] text-primary">
226
+ {image.kicker}
227
+ </p>
228
+ <h3 className="mt-2 text-[clamp(1.6rem,3vw,3rem)] font-semibold leading-none tracking-tight">
229
+ {image.title}
230
+ </h3>
231
+ <p className="mt-3 max-w-sm text-[12px] leading-relaxed text-muted-foreground sm:text-[13px]">
232
+ {image.description}
233
+ </p>
234
+ </div>
235
+ )
236
+ })}
237
+ </div>
238
+
239
+ <div className="relative z-10 mt-4 flex items-center justify-between gap-3">
240
+ <div className="flex items-center gap-1.5">
241
+ {list.map((image, imageIndex) => (
242
+ <button
243
+ key={image.src}
244
+ type="button"
245
+ onClick={() => goTo(imageIndex)}
246
+ aria-label={`Afficher ${image.title}`}
247
+ aria-current={imageIndex === active ? 'true' : undefined}
248
+ className={`h-1.5 rounded-full transition-all ${
249
+ imageIndex === active
250
+ ? 'w-7 bg-primary'
251
+ : 'w-3 bg-border hover:bg-muted-foreground/60'
252
+ }`}
253
+ />
254
+ ))}
255
+ </div>
256
+ <span className="font-mono text-[9px] uppercase tracking-[0.18em] text-muted-foreground">
257
+ {String(active + 1).padStart(2, '0')} / {String(slideCount).padStart(2, '0')}
258
+ </span>
259
+ </div>
260
+
261
+ <div
262
+ aria-hidden
263
+ className="absolute inset-y-8 left-0 hidden w-px overflow-hidden bg-border sm:block"
264
+ >
265
+ <div className="w-px bg-primary" style={{ height: `${progress * 100}%` }} />
266
+ </div>
267
+ </div>
268
+
269
+ <div aria-hidden className="absolute inset-x-0 bottom-0 h-px overflow-hidden bg-border">
270
+ <div className="h-px bg-primary" style={{ width: `${progress * 100}%` }} />
271
+ </div>
272
+ </div>
273
+ </div>
274
+ </div>
275
+ )
276
+ }
@@ -0,0 +1,24 @@
1
+ import { createContext, useContext } from 'react'
2
+ import type { CardPalette } from './palette'
3
+
4
+ export type CardPaletteMode = 'theme' | 'gradient' | 'solid'
5
+
6
+ export interface CardPaletteContextValue {
7
+ mode: CardPaletteMode
8
+ from: string
9
+ to: string
10
+ solid: string
11
+ palette: CardPalette | undefined
12
+ setMode: (mode: CardPaletteMode) => void
13
+ setFrom: (color: string) => void
14
+ setTo: (color: string) => void
15
+ setSolid: (color: string) => void
16
+ }
17
+
18
+ export const CardPaletteContext = createContext<CardPaletteContextValue | null>(null)
19
+
20
+ export function useCardPalette() {
21
+ const context = useContext(CardPaletteContext)
22
+ if (!context) throw new Error('useCardPalette must be used within CardPaletteProvider')
23
+ return context
24
+ }
@@ -0,0 +1,28 @@
1
+ import { useState, type ReactNode } from 'react'
2
+ import type { CardPalette } from './palette'
3
+ import {
4
+ CardPaletteContext,
5
+ type CardPaletteMode,
6
+ } from './palette-context'
7
+
8
+ export function CardPaletteProvider({ children }: { children: ReactNode }) {
9
+ const [mode, setMode] = useState<CardPaletteMode>('theme')
10
+ const [from, setFrom] = useState('#0EA5E9')
11
+ const [to, setTo] = useState('#8B5CF6')
12
+ const [solid, setSolid] = useState('#10B981')
13
+
14
+ const palette: CardPalette | undefined =
15
+ mode === 'theme'
16
+ ? undefined
17
+ : mode === 'gradient'
18
+ ? { mode: 'gradient', from, to }
19
+ : { mode: 'solid', color: solid }
20
+
21
+ return (
22
+ <CardPaletteContext.Provider
23
+ value={{ mode, from, to, solid, palette, setMode, setFrom, setTo, setSolid }}
24
+ >
25
+ {children}
26
+ </CardPaletteContext.Provider>
27
+ )
28
+ }