@a4ui/core 0.11.1 → 0.12.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 +36 -26
- package/dist/full.css +3042 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +8 -1
- package/src/charts/BarChart.tsx +55 -0
- package/src/charts/DonutChart.tsx +127 -0
- package/src/charts/Sparkline.tsx +101 -0
- package/src/charts/index.ts +5 -0
- package/src/commerce/CartLine.tsx +84 -0
- package/src/commerce/CartSummary.tsx +70 -0
- package/src/commerce/FilterGroup.tsx +70 -0
- package/src/commerce/PriceTag.tsx +62 -0
- package/src/commerce/ProductCard.tsx +121 -0
- package/src/commerce/ProductGrid.tsx +27 -0
- package/src/commerce/QuantityStepper.tsx +59 -0
- package/src/commerce/index.ts +10 -0
- package/src/elements.tsx +167 -0
- package/src/index.ts +166 -0
- package/src/layout/AppShell.tsx +102 -0
- package/src/layout/ChristmasBackground.tsx +177 -0
- package/src/layout/EffectsToggle.tsx +41 -0
- package/src/layout/NavGroup.tsx +45 -0
- package/src/layout/SnowScenery.tsx +76 -0
- package/src/layout/SpaceBackground.tsx +459 -0
- package/src/layout/ThemeToggle.tsx +40 -0
- package/src/layout/ThemedScenery.tsx +179 -0
- package/src/layout/sceneEffects.ts +49 -0
- package/src/lib/cn.ts +17 -0
- package/src/lib/effects.ts +71 -0
- package/src/lib/media.ts +27 -0
- package/src/lib/motion.ts +191 -0
- package/src/lib/theme.ts +113 -0
- package/src/lib/virtual.ts +33 -0
- package/src/styles/space.css +510 -0
- package/src/styles/tokens.css +213 -0
- package/src/themes/index.ts +102 -0
- package/src/themes/palettes.ts +368 -0
- package/src/ui/Accordion.tsx +65 -0
- package/src/ui/Affix.tsx +72 -0
- package/src/ui/Alert.tsx +55 -0
- package/src/ui/AlertDialog.tsx +58 -0
- package/src/ui/Anchor.tsx +100 -0
- package/src/ui/Avatar.tsx +34 -0
- package/src/ui/AvatarGroup.tsx +55 -0
- package/src/ui/BackToTop.tsx +51 -0
- package/src/ui/Badge.tsx +46 -0
- package/src/ui/BottomNavigation.tsx +65 -0
- package/src/ui/Breadcrumb.tsx +64 -0
- package/src/ui/Button.tsx +49 -0
- package/src/ui/Calendar.tsx +40 -0
- package/src/ui/CalendarHeatmap.tsx +180 -0
- package/src/ui/Card.tsx +84 -0
- package/src/ui/Carousel.tsx +121 -0
- package/src/ui/Cascader.tsx +158 -0
- package/src/ui/Checkbox.tsx +35 -0
- package/src/ui/Clock.tsx +221 -0
- package/src/ui/Collapse.tsx +48 -0
- package/src/ui/ColorPicker.tsx +89 -0
- package/src/ui/Combobox.tsx +67 -0
- package/src/ui/Command.tsx +158 -0
- package/src/ui/Comment.tsx +91 -0
- package/src/ui/ContextMenu.tsx +58 -0
- package/src/ui/Countdown.tsx +123 -0
- package/src/ui/DataGrid.tsx +177 -0
- package/src/ui/DateField.tsx +177 -0
- package/src/ui/DateRangePicker.tsx +204 -0
- package/src/ui/DateTimeField.tsx +68 -0
- package/src/ui/Descriptions.tsx +56 -0
- package/src/ui/Drawer.tsx +72 -0
- package/src/ui/Dropdown.tsx +71 -0
- package/src/ui/Dropzone.tsx +88 -0
- package/src/ui/Empty.tsx +53 -0
- package/src/ui/FileUpload.tsx +198 -0
- package/src/ui/FloatingActionButton.tsx +48 -0
- package/src/ui/Form.tsx +108 -0
- package/src/ui/Highlight.tsx +53 -0
- package/src/ui/HoverCard.tsx +42 -0
- package/src/ui/Image.tsx +66 -0
- package/src/ui/Input.tsx +38 -0
- package/src/ui/Kbd.tsx +25 -0
- package/src/ui/List.tsx +71 -0
- package/src/ui/Marquee.tsx +48 -0
- package/src/ui/Mentions.tsx +170 -0
- package/src/ui/Meter.tsx +57 -0
- package/src/ui/Modal.tsx +106 -0
- package/src/ui/MultiSelect.tsx +236 -0
- package/src/ui/NotificationCenter.tsx +103 -0
- package/src/ui/NumberInput.tsx +48 -0
- package/src/ui/PageHeader.tsx +60 -0
- package/src/ui/Pagination.tsx +62 -0
- package/src/ui/Popover.tsx +42 -0
- package/src/ui/Portal.tsx +34 -0
- package/src/ui/Progress.tsx +55 -0
- package/src/ui/RadioGroup.tsx +65 -0
- package/src/ui/Rating.tsx +98 -0
- package/src/ui/Result.tsx +75 -0
- package/src/ui/RingProgress.tsx +75 -0
- package/src/ui/SegmentedControl.tsx +61 -0
- package/src/ui/Select.tsx +55 -0
- package/src/ui/Separator.tsx +39 -0
- package/src/ui/Skeleton.tsx +23 -0
- package/src/ui/Slider.tsx +57 -0
- package/src/ui/Sortable.tsx +174 -0
- package/src/ui/SpeedDial.tsx +75 -0
- package/src/ui/Spinner.tsx +33 -0
- package/src/ui/Splitter.tsx +115 -0
- package/src/ui/Stat.tsx +74 -0
- package/src/ui/Stepper.tsx +134 -0
- package/src/ui/Switch.tsx +44 -0
- package/src/ui/Table.tsx +128 -0
- package/src/ui/Tabs.tsx +72 -0
- package/src/ui/TagInput.tsx +85 -0
- package/src/ui/Textarea.tsx +38 -0
- package/src/ui/TimeField.tsx +298 -0
- package/src/ui/Timeline.tsx +89 -0
- package/src/ui/Toast.tsx +68 -0
- package/src/ui/Toggle.tsx +41 -0
- package/src/ui/ToggleGroup.tsx +59 -0
- package/src/ui/Tooltip.tsx +42 -0
- package/src/ui/Tour.tsx +197 -0
- package/src/ui/Transfer.tsx +112 -0
- package/src/ui/Tree.tsx +100 -0
- package/src/ui/TreeSelect.tsx +151 -0
- package/src/ui/VirtualList.tsx +81 -0
- package/src/ui/internal/CalendarCore.tsx +276 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
// The "space glass" backdrop: a fixed layer (z-0) behind all content. Stars are
|
|
2
|
+
// generated individually with random position / size / brightness / bloom (no
|
|
3
|
+
// tiled pattern, no clutter), a subset twinkles, and shooting stars cross
|
|
4
|
+
// occasionally. The nebula/aurora/planets are static (cheap). No pointer
|
|
5
|
+
// parallax — moving the whole field behind the frosted-glass cards forced a
|
|
6
|
+
// per-frame re-blur that felt laggy. All motion is skipped under motionReduced()
|
|
7
|
+
// (CSS honours the same via the html:not(.force-motion) reduced-motion guard).
|
|
8
|
+
import { createEffect, onCleanup, onMount } from 'solid-js'
|
|
9
|
+
import { Satellite } from 'lucide-solid'
|
|
10
|
+
|
|
11
|
+
import { motionReduced } from '../lib/motion'
|
|
12
|
+
import { useTheme } from '../lib/theme'
|
|
13
|
+
import { bindPointerFx } from './sceneEffects'
|
|
14
|
+
|
|
15
|
+
const SHOOTER_DIRS = [
|
|
16
|
+
{ angle: 22, x: [0, 32], y: [-6, 18] },
|
|
17
|
+
{ angle: -24, x: [0, 32], y: [55, 82] },
|
|
18
|
+
{ angle: 158, x: [62, 96], y: [-6, 22] },
|
|
19
|
+
{ angle: 202, x: [62, 96], y: [52, 80] },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
// Star tints per theme. Dark sky → light stars; light sky → darker warm/cool
|
|
23
|
+
// specks (white stars would vanish on a near-white background).
|
|
24
|
+
const DARK_TINTS = ['255,255,255', '180,210,255', '255,228,170', '210,185,255', '165,230,255']
|
|
25
|
+
const LIGHT_TINTS = ['198,146,28', '176,120,18', '214,162,46', '160,110,28', '190,134,34'] // golden, for the light sky
|
|
26
|
+
|
|
27
|
+
// Custom vector rocket (nose points up in the viewBox) with a flickering exhaust
|
|
28
|
+
// flame trail — used in the light theme; rotated to the travel direction at launch.
|
|
29
|
+
const ROCKET_SVG = `<svg viewBox="0 0 24 82" width="100%" height="100%" style="display:block;overflow:visible">
|
|
30
|
+
<g class="rk-flame">
|
|
31
|
+
<path d="M8.5 31 C 8 50, 11 70, 12 80 C 13 70, 16 50, 15.5 31 Z" fill="#ff6a00" opacity="0.8"/>
|
|
32
|
+
<path d="M9.6 31 C 9.4 47, 11.2 60, 12 70 C 12.8 60, 14.6 47, 14.4 31 Z" fill="#ffb020" opacity="0.95"/>
|
|
33
|
+
<path d="M10.6 31 C 10.6 43, 11.6 52, 12 60 C 12.4 52, 13.4 43, 13.4 31 Z" fill="#fff2b0"/>
|
|
34
|
+
</g>
|
|
35
|
+
<path d="M12 2 C 16 8, 16 20, 15 32 L 9 32 C 8 20, 8 8, 12 2 Z" fill="#e6edf7"/>
|
|
36
|
+
<path d="M12 2 C 15 8, 15 13, 14.6 15 L 9.4 15 C 9 13, 9 8, 12 2 Z" fill="#ef5a3a"/>
|
|
37
|
+
<path d="M9 26 L 4.6 34 L 9 31 Z" fill="#ef5a3a"/>
|
|
38
|
+
<path d="M15 26 L 19.4 34 L 15 31 Z" fill="#ef5a3a"/>
|
|
39
|
+
<circle cx="12" cy="19" r="2.3" fill="#8fd0ff" stroke="#2f6aa8" stroke-width="1"/>
|
|
40
|
+
</svg>`
|
|
41
|
+
const STAR_COUNT = 66
|
|
42
|
+
const STAR_TRAVELERS = 7 // stars that visibly glide across, satellite-style
|
|
43
|
+
|
|
44
|
+
function buildStars(tints: string[]): string {
|
|
45
|
+
const rnd = Math.random
|
|
46
|
+
const pick = () => tints[Math.floor(rnd() * tints.length)]
|
|
47
|
+
let html = ''
|
|
48
|
+
// Static + twinkling field.
|
|
49
|
+
for (let i = 0; i < STAR_COUNT; i++) {
|
|
50
|
+
const bright = rnd() < 0.12 // larger/brighter "hero" stars
|
|
51
|
+
const size = bright ? 3 + rnd() * 1.8 : 1.2 + rnd() * 1.8
|
|
52
|
+
const op = bright ? 0.85 + rnd() * 0.15 : 0.3 + rnd() * 0.55
|
|
53
|
+
const bloom = bright ? 6 + rnd() * 8 : 1.5 + rnd() * 4
|
|
54
|
+
const c = pick()
|
|
55
|
+
const motion =
|
|
56
|
+
rnd() < 0.4
|
|
57
|
+
? `animation:twinkle ${(2.6 + rnd() * 3.5).toFixed(1)}s ease-in-out ${(rnd() * 4).toFixed(1)}s infinite;`
|
|
58
|
+
: ''
|
|
59
|
+
html +=
|
|
60
|
+
`<span style="position:absolute;left:${(rnd() * 100).toFixed(2)}%;top:${(rnd() * 100).toFixed(2)}%;` +
|
|
61
|
+
`width:${size.toFixed(1)}px;height:${size.toFixed(1)}px;border-radius:50%;` +
|
|
62
|
+
`background:rgba(${c},${op.toFixed(2)});box-shadow:0 0 ${bloom.toFixed(1)}px rgba(${c},${(op * 0.8).toFixed(2)});${motion}"></span>`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A few "traveling" stars that visibly glide across the screen, like the
|
|
66
|
+
// satellite: each is one small element moving via its own linear transform
|
|
67
|
+
// (cheap). Starts off one edge and ends off the other, so the loop reset
|
|
68
|
+
// happens off-screen (no jump). Negative delay spreads them out immediately.
|
|
69
|
+
for (let i = 0; i < STAR_TRAVELERS; i++) {
|
|
70
|
+
const c = pick()
|
|
71
|
+
const size = (1.8 + rnd() * 1.8).toFixed(1)
|
|
72
|
+
const op = (0.7 + rnd() * 0.3).toFixed(2)
|
|
73
|
+
const bloom = (4 + rnd() * 6).toFixed(1)
|
|
74
|
+
const top = (4 + rnd() * 86).toFixed(1)
|
|
75
|
+
const rtl = rnd() < 0.5
|
|
76
|
+
const startLeft = rtl ? '106%' : '-6%'
|
|
77
|
+
const dx = rtl ? '-118vw' : '118vw'
|
|
78
|
+
const dy = `${((rnd() * 2 - 1) * 16).toFixed(1)}vh`
|
|
79
|
+
const dur = 34 + rnd() * 34 // gentle glide across the viewport
|
|
80
|
+
const delay = (-rnd() * dur).toFixed(1)
|
|
81
|
+
html +=
|
|
82
|
+
`<span style="position:absolute;left:${startLeft};top:${top}%;` +
|
|
83
|
+
`width:${size}px;height:${size}px;border-radius:50%;` +
|
|
84
|
+
`background:rgba(${c},${op});box-shadow:0 0 ${bloom}px rgba(${c},${(Number(op) * 0.8).toFixed(2)});` +
|
|
85
|
+
`--dx:${dx};--dy:${dy};animation:starDrift ${dur.toFixed(1)}s linear ${delay}s infinite;"></span>`
|
|
86
|
+
}
|
|
87
|
+
return html
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The "space glass" backdrop: a fixed, `aria-hidden` layer (z-0) meant to sit
|
|
92
|
+
* behind all page content (this is {@link AppShell}'s default `background`).
|
|
93
|
+
* Renders a randomized starfield (with twinkle + occasional shooting stars,
|
|
94
|
+
* or rockets in light theme), static nebula/planets, gyroscope parallax on
|
|
95
|
+
* phones, and cursor-glow/magnetic-button effects. Takes no props; reacts to
|
|
96
|
+
* {@link useTheme} for star tints and to `motionReduced()` to skip all motion.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```tsx
|
|
100
|
+
* <SpaceBackground />
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export function SpaceBackground() {
|
|
104
|
+
let root!: HTMLDivElement
|
|
105
|
+
let starfieldEl!: HTMLDivElement
|
|
106
|
+
const theme = useTheme()
|
|
107
|
+
|
|
108
|
+
// (Re)generate the starfield with theme-appropriate colours — on mount and
|
|
109
|
+
// whenever the theme flips (white stars would be invisible on the light sky).
|
|
110
|
+
createEffect(() => {
|
|
111
|
+
const tints = theme() === 'light' ? LIGHT_TINTS : DARK_TINTS
|
|
112
|
+
if (starfieldEl) starfieldEl.innerHTML = buildStars(tints)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
onMount(() => {
|
|
116
|
+
const cleanups: Array<() => void> = []
|
|
117
|
+
const timers: number[] = []
|
|
118
|
+
const bind = (
|
|
119
|
+
target: EventTarget,
|
|
120
|
+
type: string,
|
|
121
|
+
handler: EventListener,
|
|
122
|
+
opts?: AddEventListenerOptions,
|
|
123
|
+
) => {
|
|
124
|
+
target.addEventListener(type, handler, opts)
|
|
125
|
+
cleanups.push(() => target.removeEventListener(type, handler, opts))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- Gyroscope parallax (phones): tilt the device → the starfield and
|
|
129
|
+
// constellations shift by depth. rAF-throttled, subtle (±16px), skipped
|
|
130
|
+
// under motionReduced(). iOS 13+ needs a one-time gesture to grant motion.
|
|
131
|
+
let gpRaf = 0
|
|
132
|
+
let gpX = 0
|
|
133
|
+
let gpY = 0
|
|
134
|
+
const applyParallax = (): void => {
|
|
135
|
+
gpRaf = 0
|
|
136
|
+
if (starfieldEl)
|
|
137
|
+
starfieldEl.style.transform = `translate3d(${gpX.toFixed(1)}px, ${gpY.toFixed(1)}px, 0)`
|
|
138
|
+
root.querySelectorAll<HTMLElement>('.constellation').forEach((el) => {
|
|
139
|
+
el.style.transform = `translate3d(${(gpX * 1.8).toFixed(1)}px, ${(gpY * 1.8).toFixed(1)}px, 0)`
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
// Calibrate to the first reading so it works at any holding angle (an iPad
|
|
143
|
+
// is held much flatter than a phone) — parallax is relative to that.
|
|
144
|
+
let baseG: number | null = null
|
|
145
|
+
let baseB: number | null = null
|
|
146
|
+
const onOrient = (e: DeviceOrientationEvent): void => {
|
|
147
|
+
if (motionReduced()) return
|
|
148
|
+
if (e.gamma == null || e.beta == null) return
|
|
149
|
+
if (baseG === null) {
|
|
150
|
+
baseG = e.gamma
|
|
151
|
+
baseB = e.beta
|
|
152
|
+
}
|
|
153
|
+
const g = Math.max(-25, Math.min(25, e.gamma - baseG)) // left-right, from baseline
|
|
154
|
+
const b = Math.max(-25, Math.min(25, e.beta - (baseB ?? 0))) // front-back, from baseline
|
|
155
|
+
gpX = (g / 25) * 16
|
|
156
|
+
gpY = (b / 25) * 16
|
|
157
|
+
if (!gpRaf) gpRaf = requestAnimationFrame(applyParallax)
|
|
158
|
+
}
|
|
159
|
+
const DOE = (
|
|
160
|
+
window as unknown as { DeviceOrientationEvent?: { requestPermission?: () => Promise<string> } }
|
|
161
|
+
).DeviceOrientationEvent
|
|
162
|
+
if (DOE) {
|
|
163
|
+
if (typeof DOE.requestPermission === 'function') {
|
|
164
|
+
// iOS / iPadOS 13+: motion access needs Safari's "Motion & Orientation
|
|
165
|
+
// Access" enabled AND a user gesture. Ask on the first tap (click is the
|
|
166
|
+
// most reliable activation), and stop asking once granted.
|
|
167
|
+
let granted = false
|
|
168
|
+
const ask = (): void => {
|
|
169
|
+
if (granted) return
|
|
170
|
+
DOE.requestPermission?.()
|
|
171
|
+
.then((s) => {
|
|
172
|
+
if (s === 'granted') {
|
|
173
|
+
granted = true
|
|
174
|
+
bind(window, 'deviceorientation', onOrient as EventListener)
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
.catch(() => {})
|
|
178
|
+
}
|
|
179
|
+
bind(window, 'click', ask as EventListener)
|
|
180
|
+
bind(window, 'touchend', ask as EventListener)
|
|
181
|
+
} else {
|
|
182
|
+
bind(window, 'deviceorientation', onOrient as EventListener)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
cleanups.push(() => {
|
|
186
|
+
if (gpRaf) cancelAnimationFrame(gpRaf)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// Launch a shooting star from (leftCss, topCss) at `angle`, with the given
|
|
190
|
+
// length/travel/duration. fill:forwards holds the final (invisible) frame so
|
|
191
|
+
// it can't flash back to its base style at the start position when it ends.
|
|
192
|
+
const launchShooter = (
|
|
193
|
+
leftCss: string,
|
|
194
|
+
topCss: string,
|
|
195
|
+
angle: number,
|
|
196
|
+
len: number,
|
|
197
|
+
dist: number,
|
|
198
|
+
dur: number,
|
|
199
|
+
): void => {
|
|
200
|
+
const el = document.createElement('div')
|
|
201
|
+
el.className = 'shooter'
|
|
202
|
+
el.style.width = `${len}px`
|
|
203
|
+
el.style.left = leftCss
|
|
204
|
+
el.style.top = topCss
|
|
205
|
+
root.appendChild(el)
|
|
206
|
+
const anim = el.animate(
|
|
207
|
+
[
|
|
208
|
+
{ opacity: 0, transform: `rotate(${angle}deg) translateX(0px) scaleX(.4)` },
|
|
209
|
+
{
|
|
210
|
+
opacity: 1,
|
|
211
|
+
transform: `rotate(${angle}deg) translateX(${dist * 0.35}px) scaleX(1)`,
|
|
212
|
+
offset: 0.5,
|
|
213
|
+
},
|
|
214
|
+
{ opacity: 0, transform: `rotate(${angle}deg) translateX(${dist}px) scaleX(1)` },
|
|
215
|
+
],
|
|
216
|
+
{ duration: dur, easing: 'cubic-bezier(.3,0,.15,1)', fill: 'forwards' },
|
|
217
|
+
)
|
|
218
|
+
anim.onfinish = () => el.remove()
|
|
219
|
+
timers.push(window.setTimeout(() => el.remove(), dur + 250)) // safety net for cleanup
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// A rocket 🚀 that flies off toward `angle` (used in the light theme instead
|
|
223
|
+
// of shooting stars). The emoji nose points up-right, so rotate by angle+45.
|
|
224
|
+
const launchRocket = (
|
|
225
|
+
leftCss: string,
|
|
226
|
+
topCss: string,
|
|
227
|
+
angle: number,
|
|
228
|
+
size: number,
|
|
229
|
+
dist: number,
|
|
230
|
+
dur: number,
|
|
231
|
+
): void => {
|
|
232
|
+
const el = document.createElement('div')
|
|
233
|
+
const h = size * (82 / 24)
|
|
234
|
+
el.style.cssText = `position:absolute;left:${leftCss};top:${topCss};width:${size.toFixed(0)}px;height:${h.toFixed(0)}px;pointer-events:none;filter:drop-shadow(0 0 5px rgba(255,170,80,.55));`
|
|
235
|
+
el.innerHTML = ROCKET_SVG
|
|
236
|
+
root.appendChild(el)
|
|
237
|
+
const rad = (angle * Math.PI) / 180
|
|
238
|
+
const ex = Math.cos(rad) * dist
|
|
239
|
+
const ey = Math.sin(rad) * dist
|
|
240
|
+
const rot = angle + 90 // SVG nose points up (-90°)
|
|
241
|
+
const anim = el.animate(
|
|
242
|
+
[
|
|
243
|
+
{ opacity: 0, transform: `translate(0px,0px) rotate(${rot}deg) scale(.5)` },
|
|
244
|
+
{
|
|
245
|
+
opacity: 1,
|
|
246
|
+
transform: `translate(${(ex * 0.12).toFixed(0)}px,${(ey * 0.12).toFixed(0)}px) rotate(${rot}deg) scale(1)`,
|
|
247
|
+
offset: 0.14,
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
opacity: 1,
|
|
251
|
+
transform: `translate(${(ex * 0.82).toFixed(0)}px,${(ey * 0.82).toFixed(0)}px) rotate(${rot}deg) scale(1)`,
|
|
252
|
+
offset: 0.82,
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
opacity: 0,
|
|
256
|
+
transform: `translate(${ex.toFixed(0)}px,${ey.toFixed(0)}px) rotate(${rot}deg) scale(.85)`,
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
{ duration: dur, easing: 'cubic-bezier(.25,0,.2,1)', fill: 'forwards' },
|
|
260
|
+
)
|
|
261
|
+
anim.onfinish = () => el.remove()
|
|
262
|
+
timers.push(window.setTimeout(() => el.remove(), dur + 250))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Click on empty background → a shooting star (dark) or a rocket (light) from
|
|
266
|
+
// the click point. Deliberate action, so it runs even under reduced motion;
|
|
267
|
+
// ignore clicks on interactive UI.
|
|
268
|
+
const INTERACTIVE =
|
|
269
|
+
'a, button, input, select, textarea, label, [role="button"], [role="tab"], [role="menuitem"], [role="dialog"], [contenteditable]'
|
|
270
|
+
bind(document, 'click', ((e: MouseEvent) => {
|
|
271
|
+
if ((e.target as Element | null)?.closest?.(INTERACTIVE)) return
|
|
272
|
+
if (theme() === 'light') {
|
|
273
|
+
launchRocket(
|
|
274
|
+
`${e.clientX}px`,
|
|
275
|
+
`${e.clientY}px`,
|
|
276
|
+
-90 + (Math.random() * 80 - 40), // up-and-out from the click
|
|
277
|
+
16 + Math.random() * 10,
|
|
278
|
+
260 + Math.random() * 280,
|
|
279
|
+
950 + Math.random() * 700,
|
|
280
|
+
)
|
|
281
|
+
} else {
|
|
282
|
+
launchShooter(
|
|
283
|
+
`${e.clientX}px`,
|
|
284
|
+
`${e.clientY}px`,
|
|
285
|
+
Math.random() * 360,
|
|
286
|
+
80 + Math.random() * 120,
|
|
287
|
+
300 + Math.random() * 380,
|
|
288
|
+
700 + Math.random() * 700,
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
}) as EventListener)
|
|
292
|
+
|
|
293
|
+
if (!motionReduced()) {
|
|
294
|
+
// Ambient: shooting stars (dark) / rockets rising from the bottom (light).
|
|
295
|
+
const spawnAmbient = () => {
|
|
296
|
+
if (theme() === 'light') {
|
|
297
|
+
launchRocket(
|
|
298
|
+
`${(5 + Math.random() * 90).toFixed(0)}vw`,
|
|
299
|
+
'104vh',
|
|
300
|
+
-90 + (Math.random() * 30 - 15),
|
|
301
|
+
13 + Math.random() * 9,
|
|
302
|
+
window.innerHeight * (1.1 + Math.random() * 0.35),
|
|
303
|
+
5200 + Math.random() * 3500,
|
|
304
|
+
)
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
const dir = SHOOTER_DIRS[Math.floor(Math.random() * SHOOTER_DIRS.length)]
|
|
308
|
+
launchShooter(
|
|
309
|
+
`${dir.x[0] + Math.random() * (dir.x[1] - dir.x[0])}vw`,
|
|
310
|
+
`${dir.y[0] + Math.random() * (dir.y[1] - dir.y[0])}vh`,
|
|
311
|
+
dir.angle + (Math.random() * 16 - 8),
|
|
312
|
+
90 + Math.random() * 90,
|
|
313
|
+
380 + Math.random() * 300,
|
|
314
|
+
850 + Math.random() * 550,
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
const loopAmbient = () => {
|
|
318
|
+
spawnAmbient()
|
|
319
|
+
timers.push(window.setTimeout(loopAmbient, 4500 + Math.random() * 6000))
|
|
320
|
+
}
|
|
321
|
+
timers.push(window.setTimeout(loopAmbient, 1500))
|
|
322
|
+
|
|
323
|
+
// Cursor glow + magnetic buttons + card edge-glow (shared across themes).
|
|
324
|
+
cleanups.push(bindPointerFx(root))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
onCleanup(() => {
|
|
328
|
+
cleanups.forEach((fn) => fn())
|
|
329
|
+
timers.forEach((t) => clearTimeout(t))
|
|
330
|
+
})
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
return (
|
|
334
|
+
<div id="space" ref={root} aria-hidden="true">
|
|
335
|
+
<div class="sky" />
|
|
336
|
+
<div class="aurora" />
|
|
337
|
+
<div id="starfield" ref={starfieldEl} />
|
|
338
|
+
{/* Constellations (thin SVG lines between stars) */}
|
|
339
|
+
<svg
|
|
340
|
+
class="constellation"
|
|
341
|
+
style={{ position: 'absolute', top: '6%', left: '58%', width: '260px', height: '170px' }}
|
|
342
|
+
viewBox="0 0 260 170"
|
|
343
|
+
>
|
|
344
|
+
<g
|
|
345
|
+
class="cline"
|
|
346
|
+
fill="none"
|
|
347
|
+
stroke="color-mix(in srgb, hsl(var(--accent)) 65%, white)"
|
|
348
|
+
stroke-width="2"
|
|
349
|
+
>
|
|
350
|
+
<line x1="12" y1="120" x2="70" y2="60" />
|
|
351
|
+
<line x1="70" y1="60" x2="140" y2="80" />
|
|
352
|
+
<line x1="140" y1="80" x2="205" y2="30" />
|
|
353
|
+
<line x1="140" y1="80" x2="170" y2="140" />
|
|
354
|
+
</g>
|
|
355
|
+
<g class="cdot" fill="hsl(var(--foreground))">
|
|
356
|
+
<circle cx="12" cy="120" r="3.6" />
|
|
357
|
+
<circle cx="70" cy="60" r="4.2" />
|
|
358
|
+
<circle cx="140" cy="80" r="3.6" />
|
|
359
|
+
<circle cx="205" cy="30" r="3.8" />
|
|
360
|
+
<circle cx="170" cy="140" r="3.4" />
|
|
361
|
+
</g>
|
|
362
|
+
</svg>
|
|
363
|
+
<svg
|
|
364
|
+
class="constellation"
|
|
365
|
+
style={{ position: 'absolute', bottom: '8%', left: '10%', width: '200px', height: '130px' }}
|
|
366
|
+
viewBox="0 0 200 130"
|
|
367
|
+
>
|
|
368
|
+
<g
|
|
369
|
+
class="cline"
|
|
370
|
+
fill="none"
|
|
371
|
+
stroke="color-mix(in srgb, hsl(var(--accent)) 65%, white)"
|
|
372
|
+
stroke-width="2"
|
|
373
|
+
>
|
|
374
|
+
<line x1="10" y1="20" x2="60" y2="55" />
|
|
375
|
+
<line x1="60" y1="55" x2="120" y2="40" />
|
|
376
|
+
<line x1="120" y1="40" x2="180" y2="90" />
|
|
377
|
+
</g>
|
|
378
|
+
<g class="cdot" fill="hsl(var(--foreground))">
|
|
379
|
+
<circle cx="10" cy="20" r="3.4" />
|
|
380
|
+
<circle cx="60" cy="55" r="4" />
|
|
381
|
+
<circle cx="120" cy="40" r="3.6" />
|
|
382
|
+
<circle cx="180" cy="90" r="3.8" />
|
|
383
|
+
</g>
|
|
384
|
+
</svg>
|
|
385
|
+
{/* Ringed planet (top-right) */}
|
|
386
|
+
<div class="floaty slow" style={{ position: 'absolute', top: '88px', right: '-30px' }}>
|
|
387
|
+
<div
|
|
388
|
+
class="planet"
|
|
389
|
+
style={{
|
|
390
|
+
width: '120px',
|
|
391
|
+
height: '120px',
|
|
392
|
+
background:
|
|
393
|
+
'radial-gradient(circle at 32% 28%, color-mix(in srgb, hsl(var(--primary)) 78%, white), hsl(var(--primary)) 70%, color-mix(in srgb, hsl(var(--primary)) 68%, black))',
|
|
394
|
+
opacity: '.7',
|
|
395
|
+
'box-shadow': '0 0 60px hsl(var(--primary) / .35)',
|
|
396
|
+
}}
|
|
397
|
+
/>
|
|
398
|
+
</div>
|
|
399
|
+
{/* Small planet (bottom-left) */}
|
|
400
|
+
<div class="floaty" style={{ position: 'absolute', bottom: '60px', left: '-24px' }}>
|
|
401
|
+
<div
|
|
402
|
+
class="planet-glow"
|
|
403
|
+
style={{
|
|
404
|
+
width: '72px',
|
|
405
|
+
height: '72px',
|
|
406
|
+
background:
|
|
407
|
+
'radial-gradient(circle at 36% 30%, color-mix(in srgb, hsl(var(--accent)) 75%, white), hsl(var(--accent)) 72%, color-mix(in srgb, hsl(var(--accent)) 66%, black))',
|
|
408
|
+
opacity: '.6',
|
|
409
|
+
'box-shadow': '0 0 44px hsl(var(--accent) / .4)',
|
|
410
|
+
}}
|
|
411
|
+
/>
|
|
412
|
+
</div>
|
|
413
|
+
{/* Distant moon (center-right) */}
|
|
414
|
+
<div class="floaty slow" style={{ position: 'absolute', top: '52%', right: '6%' }}>
|
|
415
|
+
<div
|
|
416
|
+
class="planet-glow"
|
|
417
|
+
style={{
|
|
418
|
+
width: '26px',
|
|
419
|
+
height: '26px',
|
|
420
|
+
background: 'radial-gradient(circle at 35% 30%, hsl(199 60% 90%), hsl(210 30% 62%))',
|
|
421
|
+
opacity: '.5',
|
|
422
|
+
}}
|
|
423
|
+
/>
|
|
424
|
+
</div>
|
|
425
|
+
<div
|
|
426
|
+
id="cursorGlow"
|
|
427
|
+
style={{
|
|
428
|
+
position: 'absolute',
|
|
429
|
+
width: '520px',
|
|
430
|
+
height: '520px',
|
|
431
|
+
'border-radius': '50%',
|
|
432
|
+
background: 'radial-gradient(circle, hsl(var(--primary) / .10), transparent 70%)',
|
|
433
|
+
transform: 'translate(-50%,-50%)',
|
|
434
|
+
opacity: '0',
|
|
435
|
+
transition: 'opacity .4s ease',
|
|
436
|
+
}}
|
|
437
|
+
/>
|
|
438
|
+
<div
|
|
439
|
+
id="satellite"
|
|
440
|
+
style={{
|
|
441
|
+
position: 'absolute',
|
|
442
|
+
top: '14%',
|
|
443
|
+
left: '-6%',
|
|
444
|
+
transform: 'rotate(-18deg)',
|
|
445
|
+
filter: 'drop-shadow(0 0 4px hsl(var(--accent) / .8))',
|
|
446
|
+
}}
|
|
447
|
+
>
|
|
448
|
+
<Satellite
|
|
449
|
+
style={{
|
|
450
|
+
width: '28px',
|
|
451
|
+
height: '28px',
|
|
452
|
+
color: 'color-mix(in srgb, hsl(var(--accent)) 55%, white)',
|
|
453
|
+
opacity: '.85',
|
|
454
|
+
}}
|
|
455
|
+
/>
|
|
456
|
+
</div>
|
|
457
|
+
</div>
|
|
458
|
+
)
|
|
459
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Theme toggle: the icon shows the CURRENT theme (moon when dark, sun when
|
|
2
|
+
// light), not the theme you'd switch to. Styled as a 36×36 icon button to match
|
|
3
|
+
// EffectsToggle — lucide icons, no emoji (consistent with the rest of the UI).
|
|
4
|
+
import { Moon, Sun } from 'lucide-solid'
|
|
5
|
+
import { Show, type JSX } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { toggleTheme, useTheme } from '../lib/theme'
|
|
8
|
+
|
|
9
|
+
/** Props for {@link ThemeToggle}. */
|
|
10
|
+
interface ThemeToggleProps {
|
|
11
|
+
/** Accessible label / tooltip text. Defaults to `'Toggle theme'`. */
|
|
12
|
+
label?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Icon button that flips between dark and light theme. The icon shows the
|
|
17
|
+
* CURRENT theme (moon when dark, sun when light), not the theme you'd switch
|
|
18
|
+
* to. Wraps {@link useTheme} + {@link toggleTheme}.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* <ThemeToggle label="Switch theme" />
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export function ThemeToggle(props: ThemeToggleProps): JSX.Element {
|
|
26
|
+
const theme = useTheme()
|
|
27
|
+
return (
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
onClick={toggleTheme}
|
|
31
|
+
class="grid h-9 w-9 place-items-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
32
|
+
aria-label={props.label ?? 'Toggle theme'}
|
|
33
|
+
title={props.label ?? 'Toggle theme'}
|
|
34
|
+
>
|
|
35
|
+
<Show when={theme() === 'dark'} fallback={<Sun class="h-[18px] w-[18px]" />}>
|
|
36
|
+
<Moon class="h-[18px] w-[18px]" />
|
|
37
|
+
</Show>
|
|
38
|
+
</button>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Generic themed backdrop for non-space themes: a token-tinted nebula (recolors
|
|
2
|
+
// with the active theme) plus a field of slowly floating motif glyphs. It also
|
|
3
|
+
// carries the same live effects as SpaceBackground so every theme feels alive —
|
|
4
|
+
// cursor glow, magnetic buttons, card edge-glow (shared via bindPointerFx), plus
|
|
5
|
+
// motif glyphs that streak across ambiently and launch on a background click.
|
|
6
|
+
// One small, shared component — every extra theme only adds a tiny `motifs`
|
|
7
|
+
// array. Motion is skipped under motionReduced() (the CSS honours the same guard).
|
|
8
|
+
import { createEffect, onCleanup, onMount, type JSX } from 'solid-js'
|
|
9
|
+
|
|
10
|
+
import { motionReduced } from '../lib/motion'
|
|
11
|
+
import { bindPointerFx } from './sceneEffects'
|
|
12
|
+
|
|
13
|
+
/** Props for {@link ThemedScenery}. */
|
|
14
|
+
export interface ThemedSceneryProps {
|
|
15
|
+
/** Emoji/text glyphs scattered and gently floating across the backdrop. */
|
|
16
|
+
motifs: string[]
|
|
17
|
+
/** How many glyphs to render. @default 16 */
|
|
18
|
+
count?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildMotifs(motifs: string[], count: number, animate: boolean): string {
|
|
22
|
+
const rnd = Math.random
|
|
23
|
+
const pick = () => motifs[Math.floor(rnd() * motifs.length)]
|
|
24
|
+
let html = ''
|
|
25
|
+
for (let i = 0; i < count; i++) {
|
|
26
|
+
const size = 22 + rnd() * 40
|
|
27
|
+
const op = 0.08 + rnd() * 0.16
|
|
28
|
+
const left = (rnd() * 100).toFixed(2)
|
|
29
|
+
const top = (rnd() * 100).toFixed(2)
|
|
30
|
+
const rot = (rnd() * 40 - 20).toFixed(1)
|
|
31
|
+
const dur = (6 + rnd() * 8).toFixed(1)
|
|
32
|
+
const delay = (-rnd() * 8).toFixed(1)
|
|
33
|
+
const anim = animate
|
|
34
|
+
? `--r:${rot}deg;--dur:${dur}s;--delay:${delay}s;`
|
|
35
|
+
: `transform:rotate(${rot}deg);animation:none;`
|
|
36
|
+
html +=
|
|
37
|
+
`<span class="motif" style="left:${left}%;top:${top}%;font-size:${size.toFixed(0)}px;` +
|
|
38
|
+
`opacity:${op.toFixed(2)};${anim}">${pick()}</span>`
|
|
39
|
+
}
|
|
40
|
+
return html
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Ignore clicks on real UI — a background click is a deliberate "launch" gesture.
|
|
44
|
+
const INTERACTIVE =
|
|
45
|
+
'a, button, input, select, textarea, label, [role="button"], [role="tab"], [role="menuitem"], [role="dialog"], [contenteditable]'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A lightweight, theme-tinted backdrop for non-`space` themes: the same
|
|
49
|
+
* token-driven nebula as {@link SpaceBackground} plus floating motif glyphs you
|
|
50
|
+
* pass in, cursor glow, magnetic buttons, card edge-glow, and motifs that streak
|
|
51
|
+
* across (ambiently and on a background click). Mount it as {@link AppShell}'s
|
|
52
|
+
* `background` (fixed z-0 layer behind all content). Motion is skipped under
|
|
53
|
+
* `motionReduced()`.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```tsx
|
|
57
|
+
* <AppShell background={<ThemedScenery motifs={['🦕', '🌿', '🦴']} />}>…</AppShell>
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export function ThemedScenery(props: ThemedSceneryProps): JSX.Element {
|
|
61
|
+
let root!: HTMLDivElement
|
|
62
|
+
let field!: HTMLDivElement
|
|
63
|
+
|
|
64
|
+
// Regenerate when the motifs change (e.g. switching themes) or motion pref flips.
|
|
65
|
+
createEffect(() => {
|
|
66
|
+
field.innerHTML = buildMotifs(props.motifs, props.count ?? 16, !motionReduced())
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
onMount(() => {
|
|
70
|
+
const cleanups: Array<() => void> = []
|
|
71
|
+
const timers: number[] = []
|
|
72
|
+
|
|
73
|
+
// Launch one motif glyph flying from (leftCss, topCss) toward `angle`.
|
|
74
|
+
const launchFlyer = (
|
|
75
|
+
leftCss: string,
|
|
76
|
+
topCss: string,
|
|
77
|
+
angle: number,
|
|
78
|
+
dist: number,
|
|
79
|
+
dur: number,
|
|
80
|
+
size: number,
|
|
81
|
+
): void => {
|
|
82
|
+
const el = document.createElement('span')
|
|
83
|
+
el.textContent = props.motifs[Math.floor(Math.random() * props.motifs.length)]
|
|
84
|
+
el.style.cssText =
|
|
85
|
+
`position:absolute;left:${leftCss};top:${topCss};font-size:${size.toFixed(0)}px;` +
|
|
86
|
+
`pointer-events:none;filter:drop-shadow(0 0 6px hsl(var(--primary) / 0.5));`
|
|
87
|
+
root.appendChild(el)
|
|
88
|
+
const rad = (angle * Math.PI) / 180
|
|
89
|
+
const ex = Math.cos(rad) * dist
|
|
90
|
+
const ey = Math.sin(rad) * dist
|
|
91
|
+
const anim = el.animate(
|
|
92
|
+
[
|
|
93
|
+
{ opacity: 0, transform: 'translate(0,0) rotate(0deg) scale(.6)' },
|
|
94
|
+
{
|
|
95
|
+
opacity: 1,
|
|
96
|
+
transform: `translate(${(ex * 0.15).toFixed(0)}px,${(ey * 0.15).toFixed(0)}px) rotate(18deg) scale(1)`,
|
|
97
|
+
offset: 0.15,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
opacity: 1,
|
|
101
|
+
transform: `translate(${(ex * 0.85).toFixed(0)}px,${(ey * 0.85).toFixed(0)}px) rotate(-14deg) scale(1)`,
|
|
102
|
+
offset: 0.85,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
opacity: 0,
|
|
106
|
+
transform: `translate(${ex.toFixed(0)}px,${ey.toFixed(0)}px) rotate(8deg) scale(.8)`,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
{ duration: dur, easing: 'cubic-bezier(.25,0,.2,1)', fill: 'forwards' },
|
|
110
|
+
)
|
|
111
|
+
anim.onfinish = () => el.remove()
|
|
112
|
+
timers.push(window.setTimeout(() => el.remove(), dur + 300))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Background click → a motif shoots up-and-out from the click point. Runs
|
|
116
|
+
// even under reduced motion (it's a deliberate action), like SpaceBackground.
|
|
117
|
+
const onClick = (e: MouseEvent): void => {
|
|
118
|
+
if ((e.target as Element | null)?.closest?.(INTERACTIVE)) return
|
|
119
|
+
launchFlyer(
|
|
120
|
+
`${e.clientX}px`,
|
|
121
|
+
`${e.clientY}px`,
|
|
122
|
+
-90 + (Math.random() * 80 - 40),
|
|
123
|
+
260 + Math.random() * 260,
|
|
124
|
+
950 + Math.random() * 700,
|
|
125
|
+
22 + Math.random() * 14,
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
document.addEventListener('click', onClick)
|
|
129
|
+
cleanups.push(() => document.removeEventListener('click', onClick))
|
|
130
|
+
|
|
131
|
+
if (!motionReduced()) {
|
|
132
|
+
// Cursor glow + magnetic buttons + card edge-glow (shared across themes).
|
|
133
|
+
cleanups.push(bindPointerFx(root))
|
|
134
|
+
|
|
135
|
+
// Ambient: a motif drifts across the screen every few seconds.
|
|
136
|
+
const spawnAmbient = (): void => {
|
|
137
|
+
const rtl = Math.random() < 0.5
|
|
138
|
+
launchFlyer(
|
|
139
|
+
rtl ? '104vw' : '-6vw',
|
|
140
|
+
`${(8 + Math.random() * 70).toFixed(0)}vh`,
|
|
141
|
+
(rtl ? 180 : 0) + (Math.random() * 16 - 8),
|
|
142
|
+
window.innerWidth * (1.1 + Math.random() * 0.3),
|
|
143
|
+
6000 + Math.random() * 4000,
|
|
144
|
+
20 + Math.random() * 16,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
const loop = (): void => {
|
|
148
|
+
spawnAmbient()
|
|
149
|
+
timers.push(window.setTimeout(loop, 5000 + Math.random() * 6000))
|
|
150
|
+
}
|
|
151
|
+
timers.push(window.setTimeout(loop, 1800))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
onCleanup(() => {
|
|
155
|
+
cleanups.forEach((fn) => fn())
|
|
156
|
+
timers.forEach((t) => clearTimeout(t))
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div id="scenery" ref={root} aria-hidden="true">
|
|
162
|
+
<div class="themed-sky" />
|
|
163
|
+
<div ref={field} class="absolute inset-0" />
|
|
164
|
+
<div
|
|
165
|
+
id="cursorGlow"
|
|
166
|
+
style={{
|
|
167
|
+
position: 'absolute',
|
|
168
|
+
width: '520px',
|
|
169
|
+
height: '520px',
|
|
170
|
+
'border-radius': '50%',
|
|
171
|
+
background: 'radial-gradient(circle, hsl(var(--primary) / 0.1), transparent 70%)',
|
|
172
|
+
transform: 'translate(-50%,-50%)',
|
|
173
|
+
opacity: '0',
|
|
174
|
+
transition: 'opacity .4s ease',
|
|
175
|
+
}}
|
|
176
|
+
/>
|
|
177
|
+
</div>
|
|
178
|
+
)
|
|
179
|
+
}
|