@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.
Files changed (126) hide show
  1. package/README.md +36 -26
  2. package/dist/full.css +3042 -0
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +1 -1
  5. package/package.json +8 -1
  6. package/src/charts/BarChart.tsx +55 -0
  7. package/src/charts/DonutChart.tsx +127 -0
  8. package/src/charts/Sparkline.tsx +101 -0
  9. package/src/charts/index.ts +5 -0
  10. package/src/commerce/CartLine.tsx +84 -0
  11. package/src/commerce/CartSummary.tsx +70 -0
  12. package/src/commerce/FilterGroup.tsx +70 -0
  13. package/src/commerce/PriceTag.tsx +62 -0
  14. package/src/commerce/ProductCard.tsx +121 -0
  15. package/src/commerce/ProductGrid.tsx +27 -0
  16. package/src/commerce/QuantityStepper.tsx +59 -0
  17. package/src/commerce/index.ts +10 -0
  18. package/src/elements.tsx +167 -0
  19. package/src/index.ts +166 -0
  20. package/src/layout/AppShell.tsx +102 -0
  21. package/src/layout/ChristmasBackground.tsx +177 -0
  22. package/src/layout/EffectsToggle.tsx +41 -0
  23. package/src/layout/NavGroup.tsx +45 -0
  24. package/src/layout/SnowScenery.tsx +76 -0
  25. package/src/layout/SpaceBackground.tsx +459 -0
  26. package/src/layout/ThemeToggle.tsx +40 -0
  27. package/src/layout/ThemedScenery.tsx +179 -0
  28. package/src/layout/sceneEffects.ts +49 -0
  29. package/src/lib/cn.ts +17 -0
  30. package/src/lib/effects.ts +71 -0
  31. package/src/lib/media.ts +27 -0
  32. package/src/lib/motion.ts +191 -0
  33. package/src/lib/theme.ts +113 -0
  34. package/src/lib/virtual.ts +33 -0
  35. package/src/styles/space.css +510 -0
  36. package/src/styles/tokens.css +213 -0
  37. package/src/themes/index.ts +102 -0
  38. package/src/themes/palettes.ts +368 -0
  39. package/src/ui/Accordion.tsx +65 -0
  40. package/src/ui/Affix.tsx +72 -0
  41. package/src/ui/Alert.tsx +55 -0
  42. package/src/ui/AlertDialog.tsx +58 -0
  43. package/src/ui/Anchor.tsx +100 -0
  44. package/src/ui/Avatar.tsx +34 -0
  45. package/src/ui/AvatarGroup.tsx +55 -0
  46. package/src/ui/BackToTop.tsx +51 -0
  47. package/src/ui/Badge.tsx +46 -0
  48. package/src/ui/BottomNavigation.tsx +65 -0
  49. package/src/ui/Breadcrumb.tsx +64 -0
  50. package/src/ui/Button.tsx +49 -0
  51. package/src/ui/Calendar.tsx +40 -0
  52. package/src/ui/CalendarHeatmap.tsx +180 -0
  53. package/src/ui/Card.tsx +84 -0
  54. package/src/ui/Carousel.tsx +121 -0
  55. package/src/ui/Cascader.tsx +158 -0
  56. package/src/ui/Checkbox.tsx +35 -0
  57. package/src/ui/Clock.tsx +221 -0
  58. package/src/ui/Collapse.tsx +48 -0
  59. package/src/ui/ColorPicker.tsx +89 -0
  60. package/src/ui/Combobox.tsx +67 -0
  61. package/src/ui/Command.tsx +158 -0
  62. package/src/ui/Comment.tsx +91 -0
  63. package/src/ui/ContextMenu.tsx +58 -0
  64. package/src/ui/Countdown.tsx +123 -0
  65. package/src/ui/DataGrid.tsx +177 -0
  66. package/src/ui/DateField.tsx +177 -0
  67. package/src/ui/DateRangePicker.tsx +204 -0
  68. package/src/ui/DateTimeField.tsx +68 -0
  69. package/src/ui/Descriptions.tsx +56 -0
  70. package/src/ui/Drawer.tsx +72 -0
  71. package/src/ui/Dropdown.tsx +71 -0
  72. package/src/ui/Dropzone.tsx +88 -0
  73. package/src/ui/Empty.tsx +53 -0
  74. package/src/ui/FileUpload.tsx +198 -0
  75. package/src/ui/FloatingActionButton.tsx +48 -0
  76. package/src/ui/Form.tsx +108 -0
  77. package/src/ui/Highlight.tsx +53 -0
  78. package/src/ui/HoverCard.tsx +42 -0
  79. package/src/ui/Image.tsx +66 -0
  80. package/src/ui/Input.tsx +38 -0
  81. package/src/ui/Kbd.tsx +25 -0
  82. package/src/ui/List.tsx +71 -0
  83. package/src/ui/Marquee.tsx +48 -0
  84. package/src/ui/Mentions.tsx +170 -0
  85. package/src/ui/Meter.tsx +57 -0
  86. package/src/ui/Modal.tsx +106 -0
  87. package/src/ui/MultiSelect.tsx +236 -0
  88. package/src/ui/NotificationCenter.tsx +103 -0
  89. package/src/ui/NumberInput.tsx +48 -0
  90. package/src/ui/PageHeader.tsx +60 -0
  91. package/src/ui/Pagination.tsx +62 -0
  92. package/src/ui/Popover.tsx +42 -0
  93. package/src/ui/Portal.tsx +34 -0
  94. package/src/ui/Progress.tsx +55 -0
  95. package/src/ui/RadioGroup.tsx +65 -0
  96. package/src/ui/Rating.tsx +98 -0
  97. package/src/ui/Result.tsx +75 -0
  98. package/src/ui/RingProgress.tsx +75 -0
  99. package/src/ui/SegmentedControl.tsx +61 -0
  100. package/src/ui/Select.tsx +55 -0
  101. package/src/ui/Separator.tsx +39 -0
  102. package/src/ui/Skeleton.tsx +23 -0
  103. package/src/ui/Slider.tsx +57 -0
  104. package/src/ui/Sortable.tsx +174 -0
  105. package/src/ui/SpeedDial.tsx +75 -0
  106. package/src/ui/Spinner.tsx +33 -0
  107. package/src/ui/Splitter.tsx +115 -0
  108. package/src/ui/Stat.tsx +74 -0
  109. package/src/ui/Stepper.tsx +134 -0
  110. package/src/ui/Switch.tsx +44 -0
  111. package/src/ui/Table.tsx +128 -0
  112. package/src/ui/Tabs.tsx +72 -0
  113. package/src/ui/TagInput.tsx +85 -0
  114. package/src/ui/Textarea.tsx +38 -0
  115. package/src/ui/TimeField.tsx +298 -0
  116. package/src/ui/Timeline.tsx +89 -0
  117. package/src/ui/Toast.tsx +68 -0
  118. package/src/ui/Toggle.tsx +41 -0
  119. package/src/ui/ToggleGroup.tsx +59 -0
  120. package/src/ui/Tooltip.tsx +42 -0
  121. package/src/ui/Tour.tsx +197 -0
  122. package/src/ui/Transfer.tsx +112 -0
  123. package/src/ui/Tree.tsx +100 -0
  124. package/src/ui/TreeSelect.tsx +151 -0
  125. package/src/ui/VirtualList.tsx +81 -0
  126. 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
+ }