@dickpy/dsh-imagegen 1.5.8 → 1.5.10

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,224 @@
1
+ /* reactbits.dev "GooeyNav" port, theme-following. The gooey merge uses an SVG
2
+ * alpha-contrast filter (blur + feColorMatrix) instead of the CSS
3
+ * blur+contrast trick, so colored particles survive on any surface: the pill
4
+ * and ink follow the host theme via [data-theme]. */
5
+
6
+ .container {
7
+ --gooey-time: 1500ms;
8
+ --gooey-color-1: #5227ff;
9
+ --gooey-color-2: #ff9ffc;
10
+ --gooey-color-3: #b19eef;
11
+ --gooey-color-4: #22d3ee;
12
+ /* light surface (default): dark ink pill, white text */
13
+ --gooey-pill: #17171c;
14
+ --gooey-pill-ink: #ffffff;
15
+ --gooey-ink: #5b5e6b;
16
+ position: relative;
17
+ font-size: 13px;
18
+ }
19
+
20
+ .container[data-theme='dark'] {
21
+ --gooey-pill: #ffffff;
22
+ --gooey-pill-ink: #17171c;
23
+ --gooey-ink: #ffffff;
24
+ }
25
+
26
+ .gooSvg {
27
+ position: absolute;
28
+ width: 0;
29
+ height: 0;
30
+ }
31
+
32
+ .container nav {
33
+ display: flex;
34
+ position: relative;
35
+ transform: translate3d(0, 0, 0.01px);
36
+ }
37
+
38
+ .container nav ul {
39
+ display: flex;
40
+ gap: 6px;
41
+ list-style: none;
42
+ padding: 0 4px;
43
+ margin: 0;
44
+ position: relative;
45
+ z-index: 3;
46
+ }
47
+
48
+ .container nav li {
49
+ border-radius: 100vw;
50
+ position: relative;
51
+ transition:
52
+ background-color 0.3s ease,
53
+ color 0.3s ease,
54
+ box-shadow 0.3s ease;
55
+ color: var(--gooey-ink);
56
+ }
57
+
58
+ .container nav li button {
59
+ display: inline-block;
60
+ padding: 5px 13px;
61
+ font: inherit;
62
+ color: inherit;
63
+ background: none;
64
+ border: none;
65
+ cursor: pointer;
66
+ white-space: nowrap;
67
+ }
68
+
69
+ .container nav li.active {
70
+ color: var(--gooey-pill-ink);
71
+ }
72
+
73
+ .container nav li::after {
74
+ content: '';
75
+ position: absolute;
76
+ inset: 0;
77
+ border-radius: 10px;
78
+ background: var(--gooey-pill);
79
+ opacity: 0;
80
+ transform: scale(0);
81
+ transition: all 0.3s ease;
82
+ z-index: -1;
83
+ }
84
+
85
+ .container nav li.active::after {
86
+ opacity: 1;
87
+ transform: scale(1);
88
+ }
89
+
90
+ .effect {
91
+ position: absolute;
92
+ left: 0;
93
+ top: 0;
94
+ width: 0;
95
+ height: 0;
96
+ opacity: 1;
97
+ pointer-events: none;
98
+ display: grid;
99
+ place-items: center;
100
+ z-index: 1;
101
+ }
102
+
103
+ .effectText {
104
+ color: var(--gooey-ink);
105
+ transition: color 0.3s ease;
106
+ font-size: 13px;
107
+ }
108
+
109
+ .effectText.active {
110
+ color: var(--gooey-pill-ink);
111
+ }
112
+
113
+ .effectFilter {
114
+ filter: url(#dsh-gooey-nav-goo);
115
+ }
116
+
117
+ .effectFilter::after {
118
+ content: '';
119
+ position: absolute;
120
+ inset: 0;
121
+ background: var(--gooey-pill);
122
+ transform: scale(0);
123
+ opacity: 0;
124
+ z-index: -1;
125
+ border-radius: 100vw;
126
+ }
127
+
128
+ .effectFilter.active::after {
129
+ animation: gooeyPill 0.3s ease both;
130
+ }
131
+
132
+ @keyframes gooeyPill {
133
+ to {
134
+ transform: scale(1);
135
+ opacity: 1;
136
+ }
137
+ }
138
+
139
+ .particle,
140
+ .point {
141
+ display: block;
142
+ opacity: 0;
143
+ width: 14px;
144
+ height: 14px;
145
+ border-radius: 100%;
146
+ transform-origin: center;
147
+ }
148
+
149
+ .particle {
150
+ --time: 1500ms;
151
+ position: absolute;
152
+ top: calc(50% - 7px);
153
+ left: calc(50% - 7px);
154
+ animation: gooeyParticle calc(var(--time)) ease 1 -350ms;
155
+ }
156
+
157
+ .point {
158
+ background: var(--color);
159
+ opacity: 1;
160
+ animation: gooeyPoint calc(var(--time)) ease 1 -350ms;
161
+ }
162
+
163
+ @keyframes gooeyParticle {
164
+ 0% {
165
+ transform: rotate(0deg) translate(calc(var(--start-x)), calc(var(--start-y)));
166
+ opacity: 1;
167
+ animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45);
168
+ }
169
+
170
+ 70% {
171
+ transform: rotate(calc(var(--rotate) * 0.5)) translate(calc(var(--end-x) * 1.2), calc(var(--end-y) * 1.2));
172
+ opacity: 1;
173
+ animation-timing-function: ease;
174
+ }
175
+
176
+ 85% {
177
+ transform: rotate(calc(var(--rotate) * 0.66)) translate(calc(var(--end-x)), calc(var(--end-y)));
178
+ opacity: 1;
179
+ }
180
+
181
+ 100% {
182
+ transform: rotate(calc(var(--rotate) * 1.2)) translate(calc(var(--end-x) * 0.5), calc(var(--end-y) * 0.5));
183
+ opacity: 1;
184
+ }
185
+ }
186
+
187
+ @keyframes gooeyPoint {
188
+ 0% {
189
+ transform: scale(0);
190
+ opacity: 0;
191
+ animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45);
192
+ }
193
+
194
+ 25% {
195
+ transform: scale(calc(var(--scale) * 0.25));
196
+ }
197
+
198
+ 38% {
199
+ opacity: 1;
200
+ }
201
+
202
+ 65% {
203
+ transform: scale(var(--scale));
204
+ opacity: 1;
205
+ animation-timing-function: ease;
206
+ }
207
+
208
+ 85% {
209
+ transform: scale(var(--scale));
210
+ opacity: 1;
211
+ }
212
+
213
+ 100% {
214
+ transform: scale(0);
215
+ opacity: 0;
216
+ }
217
+ }
218
+
219
+ @media (prefers-reduced-motion: reduce) {
220
+ .particle,
221
+ .point {
222
+ animation: none;
223
+ }
224
+ }
@@ -0,0 +1,185 @@
1
+ /** reactbits.dev "GooeyNav" port (controlled, dependency-free): the active
2
+ * pill merges with a radial burst of gooey particles through a
3
+ * blur+contrast filter. The original renders white ink on dark surfaces,
4
+ * so the palette flips per detected surface to stay readable in the host
5
+ * theme. */
6
+
7
+ import { useEffect, useRef, type ReactNode } from 'react'
8
+ import { detectLightSurface } from './surface-theme.ts'
9
+ import css from './GooeyNav.module.css'
10
+
11
+ export interface GooeyNavItem {
12
+ key: string
13
+ label: ReactNode
14
+ }
15
+
16
+ const ANIMATION_TIME = 600
17
+ const PARTICLE_COUNT = 15
18
+ const PARTICLE_DISTANCES = [90, 10]
19
+ const PARTICLE_R = 100
20
+ const TIME_VARIANCE = 300
21
+ const COLORS = [1, 2, 3, 1, 2, 3, 1, 4]
22
+
23
+ export function GooeyNav(props: {
24
+ items: GooeyNavItem[]
25
+ activeIndex: number
26
+ onSelect: (index: number) => void
27
+ ariaLabel?: string
28
+ }): React.JSX.Element {
29
+ const { items, activeIndex, onSelect, ariaLabel } = props
30
+ const containerRef = useRef<HTMLDivElement>(null)
31
+ const navRef = useRef<HTMLUListElement>(null)
32
+ const filterRef = useRef<HTMLSpanElement>(null)
33
+ const textRef = useRef<HTMLSpanElement>(null)
34
+ const burstRef = useRef<(li: HTMLElement) => void>(() => {})
35
+ const timersRef = useRef<number[]>([])
36
+ const activeIndexRef = useRef(activeIndex)
37
+ activeIndexRef.current = activeIndex
38
+
39
+ const updateEffectPosition = (element: HTMLElement): void => {
40
+ const container = containerRef.current
41
+ const filter = filterRef.current
42
+ const text = textRef.current
43
+ if (container === null || filter === null || text === null) return
44
+ const containerRect = container.getBoundingClientRect()
45
+ const pos = element.getBoundingClientRect()
46
+ const styles = {
47
+ left: `${pos.x - containerRect.x}px`,
48
+ top: `${pos.y - containerRect.y}px`,
49
+ width: `${pos.width}px`,
50
+ height: `${pos.height}px`,
51
+ }
52
+ Object.assign(filter.style, styles)
53
+ Object.assign(text.style, styles)
54
+ text.textContent = element.textContent
55
+ }
56
+
57
+ // One-time setup: the particle burst timers must survive selection changes,
58
+ // so they live outside the activeIndex-driven effect below.
59
+ useEffect(() => {
60
+ const container = containerRef.current
61
+ const filter = filterRef.current
62
+ const text = textRef.current
63
+ if (container === null || filter === null || text === null) return
64
+ container.dataset.theme = detectLightSurface(container) ? 'light' : 'dark'
65
+ let disposed = false
66
+ const noise = (amount: number): number => amount / 2 - Math.random() * amount
67
+ const getXY = (distance: number, pointIndex: number, totalPoints: number): [number, number] => {
68
+ const angle = ((360 + noise(8)) / totalPoints) * pointIndex * (Math.PI / 180)
69
+ return [distance * Math.cos(angle), distance * Math.sin(angle)]
70
+ }
71
+ const makeParticles = (element: HTMLElement): void => {
72
+ const bubbleTime = ANIMATION_TIME * 2 + TIME_VARIANCE
73
+ element.style.setProperty('--time', `${bubbleTime}ms`)
74
+ for (let i = 0; i < PARTICLE_COUNT; i++) {
75
+ const time = ANIMATION_TIME * 2 + noise(TIME_VARIANCE * 2)
76
+ const rotateSeed = noise(PARTICLE_R / 10)
77
+ const start = getXY(PARTICLE_DISTANCES[0] as number, PARTICLE_COUNT - i, PARTICLE_COUNT)
78
+ const end = getXY((PARTICLE_DISTANCES[1] as number) + noise(7), PARTICLE_COUNT - i, PARTICLE_COUNT)
79
+ const scale = 1 + noise(0.2)
80
+ const color = COLORS[Math.floor(Math.random() * COLORS.length)] ?? 1
81
+ const rotate = rotateSeed > 0 ? (rotateSeed + PARTICLE_R / 20) * 10 : (rotateSeed - PARTICLE_R / 20) * 10
82
+ const timer = window.setTimeout(() => {
83
+ if (disposed) return
84
+ const particle = document.createElement('span')
85
+ const point = document.createElement('span')
86
+ particle.className = css.particle
87
+ point.className = css.point
88
+ particle.style.setProperty('--start-x', `${start[0] as number}px`)
89
+ particle.style.setProperty('--start-y', `${start[1] as number}px`)
90
+ particle.style.setProperty('--end-x', `${end[0] as number}px`)
91
+ particle.style.setProperty('--end-y', `${end[1] as number}px`)
92
+ particle.style.setProperty('--time', `${time}ms`)
93
+ particle.style.setProperty('--scale', `${scale}`)
94
+ particle.style.setProperty('--color', `var(--gooey-color-${color}, currentColor)`)
95
+ particle.style.setProperty('--rotate', `${rotate}deg`)
96
+ particle.appendChild(point)
97
+ element.appendChild(particle)
98
+ if (typeof window.requestAnimationFrame === 'function') {
99
+ window.requestAnimationFrame(() => { element.classList.add(css.active) })
100
+ } else {
101
+ element.classList.add(css.active)
102
+ }
103
+ const removeTimer = window.setTimeout(() => { particle.remove() }, time)
104
+ timersRef.current.push(removeTimer)
105
+ }, 30)
106
+ timersRef.current.push(timer)
107
+ }
108
+ }
109
+ burstRef.current = (li: HTMLElement): void => {
110
+ updateEffectPosition(li)
111
+ for (const particle of [...filter.querySelectorAll(`.${css.particle}`)]) particle.remove()
112
+ text.classList.remove(css.active)
113
+ void text.offsetWidth
114
+ text.classList.add(css.active)
115
+ makeParticles(filter)
116
+ }
117
+ const positionActive = (): void => {
118
+ const nav = navRef.current
119
+ const activeLi = nav?.querySelectorAll('li')[activeIndexRef.current]
120
+ if (activeLi) {
121
+ updateEffectPosition(activeLi)
122
+ text.classList.add(css.active)
123
+ }
124
+ }
125
+ positionActive()
126
+ const observer = new ResizeObserver(positionActive)
127
+ observer.observe(container)
128
+ return () => {
129
+ disposed = true
130
+ for (const timer of timersRef.current) window.clearTimeout(timer)
131
+ timersRef.current = []
132
+ observer.disconnect()
133
+ }
134
+ }, [])
135
+
136
+ // External selection changes only reposition the pill (no burst); clicks
137
+ // animate through burstRef above.
138
+ useEffect(() => {
139
+ const nav = navRef.current
140
+ const text = textRef.current
141
+ const activeLi = nav?.querySelectorAll('li')[activeIndex]
142
+ if (activeLi) {
143
+ updateEffectPosition(activeLi)
144
+ text?.classList.add(css.active)
145
+ }
146
+ }, [activeIndex])
147
+
148
+ return (
149
+ <div className={css.container} ref={containerRef}>
150
+ <svg className={css.gooSvg} aria-hidden="true" focusable="false">
151
+ <defs>
152
+ {/* Alpha-contrast goo: merges the pill and particles without
153
+ crushing their colors the way CSS contrast() would. */}
154
+ <filter id="dsh-gooey-nav-goo" x="-150%" y="-150%" width="400%" height="400%">
155
+ <feGaussianBlur in="SourceGraphic" stdDeviation="6" result="blur" />
156
+ <feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9" />
157
+ </filter>
158
+ </defs>
159
+ </svg>
160
+ <nav aria-label={ariaLabel} role="tablist">
161
+ <ul ref={navRef}>
162
+ {items.map((item, index) => (
163
+ <li key={item.key} className={activeIndex === index ? css.active : undefined}>
164
+ <button
165
+ type="button"
166
+ role="tab"
167
+ aria-selected={activeIndex === index}
168
+ onClick={event => {
169
+ if (index === activeIndex) return
170
+ const li = event.currentTarget.closest('li')
171
+ if (li !== null) burstRef.current(li)
172
+ onSelect(index)
173
+ }}
174
+ >
175
+ {item.label}
176
+ </button>
177
+ </li>
178
+ ))}
179
+ </ul>
180
+ </nav>
181
+ <span className={`${css.effect} ${css.effectFilter}`} ref={filterRef} />
182
+ <span className={`${css.effect} ${css.effectText}`} ref={textRef} />
183
+ </div>
184
+ )
185
+ }