create-nativecore 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +6 -14
  2. package/bin/index.mjs +403 -431
  3. package/package.json +3 -2
  4. package/template/.env.example +28 -0
  5. package/template/.htmlhintrc +14 -0
  6. package/template/api/data/dashboard.json +11 -0
  7. package/template/api/data/users.json +18 -0
  8. package/template/api/mockApi.js +161 -0
  9. package/template/assets/icon.svg +13 -0
  10. package/template/assets/logo.svg +25 -0
  11. package/template/eslint.config.js +94 -0
  12. package/template/index.html +137 -0
  13. package/template/manifest.json +19 -0
  14. package/template/public/.well-known/security.txt +9 -0
  15. package/template/public/_headers +24 -0
  16. package/template/public/_redirects +14 -0
  17. package/template/public/assets/icon.svg +13 -0
  18. package/template/public/assets/logo.svg +25 -0
  19. package/template/public/manifest.json +19 -0
  20. package/template/public/robots.txt +13 -0
  21. package/template/public/sitemap.xml +27 -0
  22. package/template/scripts/build-for-bots.mjs +121 -0
  23. package/template/scripts/convert-to-ts.mjs +106 -0
  24. package/template/scripts/fix-encoding.mjs +38 -0
  25. package/template/scripts/fix-svg-paths.mjs +32 -0
  26. package/template/scripts/generate-cf-router.mjs +52 -0
  27. package/template/scripts/inject-dev-tools.mjs +41 -0
  28. package/template/scripts/inject-version.mjs +65 -0
  29. package/template/scripts/make-component.mjs +445 -0
  30. package/template/scripts/make-component.mjs.backup +432 -0
  31. package/template/scripts/make-controller.mjs +119 -0
  32. package/template/scripts/make-core-component.mjs +303 -0
  33. package/template/scripts/make-view.mjs +346 -0
  34. package/template/scripts/minify.mjs +71 -0
  35. package/template/scripts/prepare-static-assets.mjs +141 -0
  36. package/template/scripts/prompt-bot-build.mjs +223 -0
  37. package/template/scripts/remove-component.mjs +170 -0
  38. package/template/scripts/remove-core-component.mjs +156 -0
  39. package/template/scripts/remove-dev.mjs +13 -0
  40. package/template/scripts/remove-view.mjs +200 -0
  41. package/template/scripts/strip-dev-blocks.mjs +30 -0
  42. package/template/scripts/watch-compile.mjs +69 -0
  43. package/template/server.js +1066 -0
  44. package/template/src/app.ts +115 -0
  45. package/template/src/components/appRegistry.ts +8 -0
  46. package/template/src/components/core/app-footer.ts +27 -0
  47. package/template/src/components/core/app-header.ts +175 -0
  48. package/template/src/components/core/app-sidebar.ts +238 -0
  49. package/template/src/components/core/loading-spinner.ts +25 -0
  50. package/template/src/components/core/nc-a.ts +313 -0
  51. package/template/src/components/core/nc-accordion.ts +186 -0
  52. package/template/src/components/core/nc-alert.ts +153 -0
  53. package/template/src/components/core/nc-animation.ts +1150 -0
  54. package/template/src/components/core/nc-autocomplete.ts +271 -0
  55. package/template/src/components/core/nc-avatar-group.ts +113 -0
  56. package/template/src/components/core/nc-avatar.ts +148 -0
  57. package/template/src/components/core/nc-badge.ts +86 -0
  58. package/template/src/components/core/nc-bottom-nav.ts +214 -0
  59. package/template/src/components/core/nc-breadcrumb.ts +96 -0
  60. package/template/src/components/core/nc-button.ts +307 -0
  61. package/template/src/components/core/nc-card.ts +160 -0
  62. package/template/src/components/core/nc-checkbox.ts +282 -0
  63. package/template/src/components/core/nc-chip.ts +115 -0
  64. package/template/src/components/core/nc-code.ts +314 -0
  65. package/template/src/components/core/nc-collapsible.ts +154 -0
  66. package/template/src/components/core/nc-color-picker.ts +268 -0
  67. package/template/src/components/core/nc-copy-button.ts +119 -0
  68. package/template/src/components/core/nc-date-picker.ts +443 -0
  69. package/template/src/components/core/nc-div.ts +280 -0
  70. package/template/src/components/core/nc-divider.ts +81 -0
  71. package/template/src/components/core/nc-drawer.ts +230 -0
  72. package/template/src/components/core/nc-dropdown.ts +178 -0
  73. package/template/src/components/core/nc-empty-state.ts +134 -0
  74. package/template/src/components/core/nc-file-upload.ts +354 -0
  75. package/template/src/components/core/nc-form.ts +312 -0
  76. package/template/src/components/core/nc-image.ts +184 -0
  77. package/template/src/components/core/nc-input.ts +383 -0
  78. package/template/src/components/core/nc-kbd.ts +48 -0
  79. package/template/src/components/core/nc-menu-item.ts +193 -0
  80. package/template/src/components/core/nc-menu.ts +376 -0
  81. package/template/src/components/core/nc-modal.ts +238 -0
  82. package/template/src/components/core/nc-nav-item.ts +151 -0
  83. package/template/src/components/core/nc-number-input.ts +350 -0
  84. package/template/src/components/core/nc-otp-input.ts +235 -0
  85. package/template/src/components/core/nc-pagination.ts +178 -0
  86. package/template/src/components/core/nc-popover.ts +260 -0
  87. package/template/src/components/core/nc-progress-circular.ts +119 -0
  88. package/template/src/components/core/nc-progress.ts +134 -0
  89. package/template/src/components/core/nc-radio.ts +235 -0
  90. package/template/src/components/core/nc-rating.ts +266 -0
  91. package/template/src/components/core/nc-rich-text.ts +283 -0
  92. package/template/src/components/core/nc-scroll-top.ts +116 -0
  93. package/template/src/components/core/nc-select.ts +452 -0
  94. package/template/src/components/core/nc-skeleton.ts +107 -0
  95. package/template/src/components/core/nc-slider.ts +285 -0
  96. package/template/src/components/core/nc-snackbar.ts +230 -0
  97. package/template/src/components/core/nc-splash.ts +343 -0
  98. package/template/src/components/core/nc-stepper.ts +247 -0
  99. package/template/src/components/core/nc-switch.ts +281 -0
  100. package/template/src/components/core/nc-tab-item.ts +138 -0
  101. package/template/src/components/core/nc-table.ts +279 -0
  102. package/template/src/components/core/nc-tabs.ts +554 -0
  103. package/template/src/components/core/nc-tag-input.ts +279 -0
  104. package/template/src/components/core/nc-textarea.ts +216 -0
  105. package/template/src/components/core/nc-time-picker.ts +438 -0
  106. package/template/src/components/core/nc-timeline.ts +186 -0
  107. package/template/src/components/core/nc-tooltip.ts +143 -0
  108. package/template/src/components/frameworkRegistry.ts +68 -0
  109. package/template/src/components/preloadRegistry.ts +28 -0
  110. package/template/src/components/registry.ts +8 -0
  111. package/template/src/components/ui/dashboard-signal-lab.ts +284 -0
  112. package/template/src/constants/apiEndpoints.ts +27 -0
  113. package/template/src/constants/errorMessages.ts +23 -0
  114. package/template/src/constants/index.ts +8 -0
  115. package/template/src/constants/routePaths.ts +15 -0
  116. package/template/src/constants/storageKeys.ts +18 -0
  117. package/template/src/controllers/dashboard.controller.ts +200 -0
  118. package/template/src/controllers/home.controller.ts +21 -0
  119. package/template/src/controllers/index.ts +11 -0
  120. package/template/src/controllers/login.controller.ts +131 -0
  121. package/template/src/core/component.ts +354 -0
  122. package/template/src/core/errorHandler.ts +85 -0
  123. package/template/src/core/gpu-animation.ts +604 -0
  124. package/template/src/core/http.ts +173 -0
  125. package/template/src/core/lazyComponents.ts +90 -0
  126. package/template/src/core/router.ts +653 -0
  127. package/template/src/core/signals.ts +146 -0
  128. package/template/src/core/state.ts +248 -0
  129. package/template/src/dev/component-editor.ts +1363 -0
  130. package/template/src/dev/component-overlay.ts +278 -0
  131. package/template/src/dev/context-menu.ts +223 -0
  132. package/template/src/dev/denc-tools.ts +250 -0
  133. package/template/src/dev/hmr.ts +189 -0
  134. package/template/src/dev/nfbs.code-workspace +27 -0
  135. package/template/src/dev/outline-panel.ts +1247 -0
  136. package/template/src/middleware/auth.middleware.ts +23 -0
  137. package/template/src/routes/routes.ts +38 -0
  138. package/template/src/services/api.service.ts +394 -0
  139. package/template/src/services/auth.service.ts +176 -0
  140. package/template/src/services/index.ts +8 -0
  141. package/template/src/services/logger.service.ts +74 -0
  142. package/template/src/services/storage.service.ts +88 -0
  143. package/template/src/stores/appStore.ts +57 -0
  144. package/template/src/stores/uiStore.ts +36 -0
  145. package/template/src/styles/core-variables.css +219 -0
  146. package/template/src/styles/core.css +710 -0
  147. package/template/src/styles/main.css +3164 -0
  148. package/template/src/styles/variables.css +152 -0
  149. package/template/src/types/global.d.ts +47 -0
  150. package/template/src/utils/cacheBuster.ts +20 -0
  151. package/template/src/utils/dom.ts +149 -0
  152. package/template/src/utils/events.ts +203 -0
  153. package/template/src/utils/form.ts +176 -0
  154. package/template/src/utils/formatters.ts +169 -0
  155. package/template/src/utils/helpers.ts +195 -0
  156. package/template/src/utils/markdown.ts +307 -0
  157. package/template/src/utils/sidebar.ts +96 -0
  158. package/template/src/utils/smoothScroll.ts +85 -0
  159. package/template/src/utils/templates.ts +23 -0
  160. package/template/src/utils/validation.ts +73 -0
  161. package/template/src/views/protected/dashboard.html +293 -0
  162. package/template/src/views/public/home.html +150 -0
  163. package/template/src/views/public/login.html +102 -0
  164. package/template/tests/unit/component.test.ts +87 -0
  165. package/template/tests/unit/computed.test.ts +79 -0
  166. package/template/tests/unit/form.test.ts +68 -0
  167. package/template/tests/unit/formatters.test.ts +49 -0
  168. package/template/tests/unit/lazy-components.test.ts +59 -0
  169. package/template/tests/unit/markdown.test.ts +62 -0
  170. package/template/tests/unit/router.test.ts +112 -0
  171. package/template/tests/unit/signals.test.ts +54 -0
  172. package/template/tests/unit/validation.test.ts +50 -0
  173. package/template/tsconfig.build.json +21 -0
  174. package/template/tsconfig.json +51 -0
  175. package/template/vitest.config.ts +36 -0
@@ -0,0 +1,1150 @@
1
+ /**
2
+ * NcAnimation Component
3
+ *
4
+ * A declarative animation wrapper that intelligently selects the most
5
+ * GPU-efficient execution path for each animation type:
6
+ *
7
+ * - Simple CSS keyframes → runs entirely on the compositor thread (no JS)
8
+ * - Enter / exit → Web Animations API via gpu-animation.ts
9
+ * - Scroll-reveal → IntersectionObserver + Web Animations API
10
+ * - Continuous / looping → CSS animation (compositor-only)
11
+ * - Particle effects → WebGL (vertex shader) with canvas2d fallback
12
+ *
13
+ * Attributes:
14
+ * name — Animation preset name (see list below). Required.
15
+ * trigger — When to run: 'mount'|'visible'|'hover'|'click'|'manual' (default: 'mount')
16
+ * duration — Ms integer (default: varies per preset)
17
+ * delay — Ms delay before animation starts (default: 0)
18
+ * easing — CSS easing / cubic-bezier string (default: preset-specific)
19
+ * iterations — Number or 'infinite' (default: 1)
20
+ * distance — Slide/offset distance in px (default: 40)
21
+ * threshold — IntersectionObserver threshold 0-1 (default: 0.15, trigger=visible only)
22
+ * fill — Web Animations fill mode: 'forwards'|'backwards'|'both'|'none' (default: 'forwards')
23
+ * reverse — boolean — play animation backwards
24
+ * no-gpu-hint — boolean — skip will-change + contain hints (use for very tiny elements)
25
+ *
26
+ * Particle-specific attributes (all optional):
27
+ * origin-x — Horizontal start position as 0-1 fraction of screen width (default: 0.5 = center)
28
+ * origin-y — Vertical start position as 0-1 fraction of screen height (default: 0.5)
29
+ * Special shortcuts: 'top'=0, 'bottom'=1, 'left'=0, 'right'=1, 'center'=0.5
30
+ * target-x — End-point X fraction (used by 'converge'/'ripple' presets)
31
+ * target-y — End-point Y fraction
32
+ * count — Override particle count (integer)
33
+ * spread — Override spread/speed scale 0-2 (default: 1)
34
+ *
35
+ * Animation preset names:
36
+ * Enter/exit: fade-in | fade-out | slide-up | slide-down | slide-left | slide-right
37
+ * scale-in | scale-out | zoom-in | zoom-out | flip-x | flip-y
38
+ * Attention: pulse | shake | bounce | rubber-band | swing | jello | tada | heartbeat
39
+ * Continuous: spin | ping | float | glow
40
+ * Particles: confetti | sparkles | bubbles | snow | firework |
41
+ * electricity | fire | explosion | ripple
42
+ *
43
+ * Events:
44
+ * start — CustomEvent — animation begins
45
+ * finish — CustomEvent — animation ends (not fired for infinite)
46
+ * cancel — CustomEvent — animation was cancelled
47
+ *
48
+ * Methods (call on the element):
49
+ * el.play() — play / replay
50
+ * el.pause() — pause (Web Animations API animations only)
51
+ * el.cancel() — cancel and reset
52
+ *
53
+ * Slots:
54
+ * default — the content to animate
55
+ *
56
+ * Usage:
57
+ * <nc-animation name="fade-in" trigger="visible">
58
+ * <nc-card>...</nc-card>
59
+ * </nc-animation>
60
+ *
61
+ * <nc-animation name="slide-up" trigger="visible" delay="150">
62
+ * <p>Staggered paragraph</p>
63
+ * </nc-animation>
64
+ *
65
+ * <nc-animation name="pulse" trigger="hover" iterations="infinite">
66
+ * <nc-button>Hover me</nc-button>
67
+ * </nc-animation>
68
+ *
69
+ * <nc-animation name="confetti" trigger="click">
70
+ * <nc-button variant="success">Celebrate</nc-button>
71
+ * </nc-animation>
72
+ */
73
+
74
+ import { Component, defineComponent } from '@core/component.js';
75
+ import {
76
+ animate,
77
+ prepareForAnimation,
78
+ cleanupAnimation,
79
+ fadeIn,
80
+ fadeOut,
81
+ slideIn,
82
+ scaleIn,
83
+ createAnimationLoop,
84
+ createWebGLParticleSystem,
85
+ type AnimationOptions,
86
+ type ParticleConfig,
87
+ } from '@core/gpu-animation.js';
88
+
89
+ // ── Types ─────────────────────────────────────────────────────────────────────
90
+
91
+ type AnimationTrigger = 'mount' | 'visible' | 'hover' | 'click' | 'manual';
92
+
93
+ /**
94
+ * Defines whether an animation runs via:
95
+ * 'css' — a CSS class applied to the slotted element (compositor-only, zero JS)
96
+ * 'waapi' — Web Animations API via gpu-animation helpers (JS, GPU transform/opacity)
97
+ * 'particle'— WebGL/canvas particle system overlay
98
+ */
99
+ type AnimationPath = 'css' | 'waapi' | 'particle';
100
+
101
+ interface PresetDef {
102
+ path: AnimationPath;
103
+ /** Default duration ms */
104
+ duration: number;
105
+ /** For 'waapi' path: function that runs the animation */
106
+ run?: (el: HTMLElement, opts: AnimationOptions & { distance?: number }) => Promise<void>;
107
+ /** For 'css' path: keyframes injected once into shadow DOM <style> */
108
+ keyframes?: string;
109
+ /** For 'particle' path: config */
110
+ particle?: ParticleConfig;
111
+ easing?: string;
112
+ }
113
+
114
+ // ── Preset registry ───────────────────────────────────────────────────────────
115
+
116
+ const PRESETS: Record<string, PresetDef> = {
117
+ // ── Enter / exit (WAAPI — GPU transform + opacity) ──────────────────────
118
+ 'fade-in': {
119
+ path: 'waapi', duration: 400,
120
+ run: (el, opts) => fadeIn(el, opts.duration),
121
+ },
122
+ 'fade-out': {
123
+ path: 'waapi', duration: 400,
124
+ run: (el, opts) => fadeOut(el, opts.duration),
125
+ },
126
+ 'slide-up': {
127
+ path: 'waapi', duration: 450,
128
+ run: (el, opts) => slideIn(el, 'up', opts.distance, opts.duration),
129
+ },
130
+ 'slide-down': {
131
+ path: 'waapi', duration: 450,
132
+ run: (el, opts) => slideIn(el, 'down', opts.distance, opts.duration),
133
+ },
134
+ 'slide-left': {
135
+ path: 'waapi', duration: 450,
136
+ run: (el, opts) => slideIn(el, 'left', opts.distance, opts.duration),
137
+ },
138
+ 'slide-right': {
139
+ path: 'waapi', duration: 450,
140
+ run: (el, opts) => slideIn(el, 'right', opts.distance, opts.duration),
141
+ },
142
+ 'scale-in': {
143
+ path: 'waapi', duration: 350,
144
+ run: (el, opts) => scaleIn(el, opts.duration),
145
+ },
146
+ 'scale-out': {
147
+ path: 'waapi', duration: 350,
148
+ run: (el, opts) => animate(el, [
149
+ { transform: 'scale3d(1,1,1)', opacity: '1' },
150
+ { transform: 'scale3d(0.8,0.8,1)', opacity: '0' },
151
+ ], opts),
152
+ },
153
+ 'zoom-in': {
154
+ path: 'waapi', duration: 400,
155
+ run: (el, opts) => animate(el, [
156
+ { transform: 'scale3d(0.5,0.5,1)', opacity: '0' },
157
+ { transform: 'scale3d(1,1,1)', opacity: '1' },
158
+ ], { ...opts, easing: 'cubic-bezier(0.34,1.56,0.64,1)' }),
159
+ },
160
+ 'zoom-out': {
161
+ path: 'waapi', duration: 400,
162
+ run: (el, opts) => animate(el, [
163
+ { transform: 'scale3d(1,1,1)', opacity: '1' },
164
+ { transform: 'scale3d(0.5,0.5,1)', opacity: '0' },
165
+ ], opts),
166
+ },
167
+ 'flip-x': {
168
+ path: 'waapi', duration: 500,
169
+ run: (el, opts) => {
170
+ prepareForAnimation(el, ['transform', 'opacity']);
171
+ return animate(el, [
172
+ { transform: 'perspective(400px) rotateX(90deg)', opacity: '0' },
173
+ { transform: 'perspective(400px) rotateX(-10deg)', opacity: '1', offset: 0.6 },
174
+ { transform: 'perspective(400px) rotateX(0deg)', opacity: '1' },
175
+ ], opts);
176
+ },
177
+ },
178
+ 'flip-y': {
179
+ path: 'waapi', duration: 500,
180
+ run: (el, opts) => {
181
+ prepareForAnimation(el, ['transform', 'opacity']);
182
+ return animate(el, [
183
+ { transform: 'perspective(400px) rotateY(90deg)', opacity: '0' },
184
+ { transform: 'perspective(400px) rotateY(-10deg)', opacity: '1', offset: 0.6 },
185
+ { transform: 'perspective(400px) rotateY(0deg)', opacity: '1' },
186
+ ], opts);
187
+ },
188
+ },
189
+
190
+ // ── Attention seekers (WAAPI — brief GPU motion) ─────────────────────────
191
+ 'pulse': {
192
+ path: 'waapi', duration: 600,
193
+ run: (el, opts) => {
194
+ prepareForAnimation(el, ['transform']);
195
+ return animate(el, [
196
+ { transform: 'scale3d(1,1,1)' },
197
+ { transform: 'scale3d(1.05,1.05,1)', offset: 0.5 },
198
+ { transform: 'scale3d(1,1,1)' },
199
+ ], opts);
200
+ },
201
+ },
202
+ 'shake': {
203
+ path: 'waapi', duration: 500,
204
+ run: (el, opts) => {
205
+ prepareForAnimation(el, ['transform']);
206
+ return animate(el, [
207
+ { transform: 'translate3d(0,0,0)' },
208
+ { transform: 'translate3d(-8px,0,0)', offset: 0.1 },
209
+ { transform: 'translate3d(8px,0,0)', offset: 0.3 },
210
+ { transform: 'translate3d(-8px,0,0)', offset: 0.5 },
211
+ { transform: 'translate3d(8px,0,0)', offset: 0.7 },
212
+ { transform: 'translate3d(-4px,0,0)', offset: 0.9 },
213
+ { transform: 'translate3d(0,0,0)' },
214
+ ], { ...opts, easing: 'ease-in-out' });
215
+ },
216
+ },
217
+ 'bounce': {
218
+ path: 'waapi', duration: 800,
219
+ run: (el, opts) => {
220
+ prepareForAnimation(el, ['transform']);
221
+ return animate(el, [
222
+ { transform: 'translate3d(0,0,0)', animationTimingFunction: 'cubic-bezier(0.8,0,1,1)' },
223
+ { transform: 'translate3d(0,-30px,0)', offset: 0.4, animationTimingFunction: 'cubic-bezier(0,0,0.2,1)' },
224
+ { transform: 'translate3d(0,0,0)', offset: 0.6, animationTimingFunction: 'cubic-bezier(0.8,0,1,1)' },
225
+ { transform: 'translate3d(0,-15px,0)', offset: 0.8, animationTimingFunction: 'cubic-bezier(0,0,0.2,1)' },
226
+ { transform: 'translate3d(0,0,0)' },
227
+ ], { ...opts, easing: 'linear' });
228
+ },
229
+ },
230
+ 'rubber-band': {
231
+ path: 'waapi', duration: 700,
232
+ run: (el, opts) => {
233
+ prepareForAnimation(el, ['transform']);
234
+ return animate(el, [
235
+ { transform: 'scale3d(1,1,1)' },
236
+ { transform: 'scale3d(1.25,0.75,1)', offset: 0.3 },
237
+ { transform: 'scale3d(0.75,1.25,1)', offset: 0.5 },
238
+ { transform: 'scale3d(1.15,0.85,1)', offset: 0.65 },
239
+ { transform: 'scale3d(0.95,1.05,1)', offset: 0.75 },
240
+ { transform: 'scale3d(1.05,0.95,1)', offset: 0.9 },
241
+ { transform: 'scale3d(1,1,1)' },
242
+ ], { ...opts, easing: 'ease-in-out' });
243
+ },
244
+ },
245
+ 'swing': {
246
+ path: 'waapi', duration: 700,
247
+ run: (el, opts) => {
248
+ prepareForAnimation(el, ['transform']);
249
+ return animate(el, [
250
+ { transform: 'rotate3d(0,0,1,0deg)' },
251
+ { transform: 'rotate3d(0,0,1,15deg)', offset: 0.2 },
252
+ { transform: 'rotate3d(0,0,1,-10deg)', offset: 0.4 },
253
+ { transform: 'rotate3d(0,0,1,5deg)', offset: 0.6 },
254
+ { transform: 'rotate3d(0,0,1,-5deg)', offset: 0.8 },
255
+ { transform: 'rotate3d(0,0,1,0deg)' },
256
+ ], { ...opts, easing: 'ease-in-out' });
257
+ },
258
+ },
259
+ 'jello': {
260
+ path: 'waapi', duration: 900,
261
+ run: (el, opts) => {
262
+ prepareForAnimation(el, ['transform']);
263
+ return animate(el, [
264
+ { transform: 'skewX(0) skewY(0)' },
265
+ { transform: 'skewX(-12.5deg) skewY(-12.5deg)', offset: 0.11 },
266
+ { transform: 'skewX(6.25deg) skewY(6.25deg)', offset: 0.22 },
267
+ { transform: 'skewX(-3.125deg) skewY(-3.125deg)', offset: 0.33 },
268
+ { transform: 'skewX(1.5625deg) skewY(1.5625deg)', offset: 0.44 },
269
+ { transform: 'skewX(-0.78125deg) skewY(-0.78125deg)', offset: 0.55 },
270
+ { transform: 'skewX(0.390625deg) skewY(0.390625deg)', offset: 0.66 },
271
+ { transform: 'skewX(-0.1953125deg) skewY(-0.1953125deg)', offset: 0.77 },
272
+ { transform: 'skewX(0) skewY(0)' },
273
+ ], { ...opts, easing: 'ease-in-out' });
274
+ },
275
+ },
276
+ 'tada': {
277
+ path: 'waapi', duration: 800,
278
+ run: (el, opts) => {
279
+ prepareForAnimation(el, ['transform']);
280
+ return animate(el, [
281
+ { transform: 'scale3d(1,1,1) rotate3d(0,0,1,0deg)' },
282
+ { transform: 'scale3d(0.9,0.9,0.9) rotate3d(0,0,1,-3deg)', offset: 0.1 },
283
+ { transform: 'scale3d(1.1,1.1,1.1) rotate3d(0,0,1,3deg)', offset: 0.3 },
284
+ { transform: 'scale3d(1.1,1.1,1.1) rotate3d(0,0,1,-3deg)', offset: 0.5 },
285
+ { transform: 'scale3d(1.1,1.1,1.1) rotate3d(0,0,1,3deg)', offset: 0.7 },
286
+ { transform: 'scale3d(1.1,1.1,1.1) rotate3d(0,0,1,-3deg)', offset: 0.8 },
287
+ { transform: 'scale3d(1,1,1) rotate3d(0,0,1,0deg)' },
288
+ ], { ...opts, easing: 'ease-in-out' });
289
+ },
290
+ },
291
+ 'heartbeat': {
292
+ path: 'waapi', duration: 600,
293
+ run: (el, opts) => {
294
+ prepareForAnimation(el, ['transform']);
295
+ return animate(el, [
296
+ { transform: 'scale3d(1,1,1)' },
297
+ { transform: 'scale3d(1.15,1.15,1)', offset: 0.14 },
298
+ { transform: 'scale3d(1,1,1)', offset: 0.28 },
299
+ { transform: 'scale3d(1.15,1.15,1)', offset: 0.42 },
300
+ { transform: 'scale3d(1,1,1)' },
301
+ ], { ...opts, easing: 'ease-in-out' });
302
+ },
303
+ },
304
+
305
+ // ── Continuous / looping (CSS — compositor-only, zero rAF cost) ──────────
306
+ 'spin': {
307
+ path: 'css', duration: 1000,
308
+ keyframes: `from { transform: rotate(0deg); } to { transform: rotate(360deg); }`,
309
+ },
310
+ 'ping': {
311
+ path: 'css', duration: 1000,
312
+ keyframes: `
313
+ 0% { transform: scale3d(1,1,1); opacity: 1; }
314
+ 75%, 100% { transform: scale3d(2,2,1); opacity: 0; }
315
+ `,
316
+ },
317
+ 'float': {
318
+ path: 'css', duration: 3000,
319
+ easing: 'ease-in-out',
320
+ keyframes: `
321
+ 0%, 100% { transform: translate3d(0,0,0); }
322
+ 50% { transform: translate3d(0,-12px,0); }
323
+ `,
324
+ },
325
+ 'glow': {
326
+ path: 'css', duration: 2000,
327
+ easing: 'ease-in-out',
328
+ keyframes: `
329
+ 0%, 100% { filter: brightness(1) drop-shadow(0 0 0px currentColor); }
330
+ 50% { filter: brightness(1.2) drop-shadow(0 0 8px currentColor); }
331
+ `,
332
+ },
333
+
334
+ // ── Particle system (WebGL → canvas2d fallback) ───────────────────────────
335
+ 'confetti': {
336
+ path: 'particle', duration: 3000,
337
+ particle: {
338
+ count: 120,
339
+ colors: ['#f43f5e','#f59e0b','#10b981','#3b82f6','#8b5cf6','#ec4899'],
340
+ size: { min: 4, max: 10 },
341
+ speed: { min: 60, max: 160 },
342
+ type: 'shower',
343
+ },
344
+ },
345
+ 'sparkles': {
346
+ path: 'particle', duration: 2000,
347
+ particle: {
348
+ count: 60,
349
+ colors: ['#fcd34d','#fbbf24','#fde68a','#fff'],
350
+ size: { min: 2, max: 6 },
351
+ speed: { min: 40, max: 100 },
352
+ type: 'burst',
353
+ },
354
+ },
355
+ 'bubbles': {
356
+ path: 'particle', duration: 4000,
357
+ particle: {
358
+ count: 50,
359
+ colors: ['#93c5fd','#bfdbfe','#dbeafe'],
360
+ size: { min: 6, max: 18 },
361
+ speed: { min: 20, max: 60 },
362
+ type: 'float',
363
+ },
364
+ },
365
+ 'snow': {
366
+ path: 'particle', duration: 5000,
367
+ particle: {
368
+ count: 80,
369
+ colors: ['#e0f2fe','#bae6fd','#fff'],
370
+ size: { min: 3, max: 8 },
371
+ speed: { min: 20, max: 50 },
372
+ type: 'shower',
373
+ },
374
+ },
375
+ 'firework': {
376
+ path: 'particle', duration: 3000,
377
+ particle: {
378
+ count: 120,
379
+ colors: ['#f43f5e','#f59e0b','#fcd34d','#34d399','#60a5fa','#c084fc','#fff'],
380
+ size: { min: 2, max: 5 },
381
+ speed: { min: 280, max: 460 },
382
+ type: 'firework',
383
+ },
384
+ },
385
+ // ── New presets ───────────────────────────────────────────────────────────
386
+ 'electricity': {
387
+ path: 'particle', duration: 2000,
388
+ particle: {
389
+ count: 5, // number of simultaneous bolts
390
+ colors: ['#a5f3fc','#e0f2fe','#7dd3fc','#ffffff'],
391
+ size: { min: 1, max: 2 },
392
+ speed: { min: 0, max: 0 },
393
+ type: 'electricity' as ParticleConfig['type'],
394
+ },
395
+ },
396
+ 'fire': {
397
+ path: 'particle', duration: 3000,
398
+ particle: {
399
+ count: 120,
400
+ colors: ['#ef4444','#f97316','#fbbf24','#fde68a'],
401
+ size: { min: 8, max: 22 },
402
+ speed: { min: 80, max: 180 },
403
+ type: 'fire' as ParticleConfig['type'],
404
+ },
405
+ },
406
+ 'explosion': {
407
+ path: 'particle', duration: 2000,
408
+ particle: {
409
+ count: 110,
410
+ colors: ['#ef4444','#f97316','#fbbf24','#fde68a','#fff'],
411
+ size: { min: 4, max: 16 },
412
+ speed: { min: 90, max: 360 },
413
+ type: 'explosion',
414
+ },
415
+ },
416
+ 'ripple': {
417
+ path: 'particle', duration: 2500,
418
+ particle: {
419
+ count: 5, // number of concentric rings
420
+ colors: ['#3b82f6','#60a5fa','#93c5fd','#bfdbfe'],
421
+ size: { min: 2, max: 4 },
422
+ speed: { min: 0, max: 0 },
423
+ type: 'ripple' as ParticleConfig['type'],
424
+ },
425
+ },
426
+ };
427
+
428
+ // ── Component ─────────────────────────────────────────────────────────────────
429
+
430
+ // Global set — keyframes injected into document.head, deduplicated across all instances
431
+ const _injectedKeyframes = new Set<string>();
432
+
433
+ export class NcAnimation extends Component {
434
+ static useShadowDOM = true;
435
+
436
+ static get observedAttributes() {
437
+ return ['name', 'trigger', 'duration', 'delay', 'easing', 'iterations',
438
+ 'distance', 'threshold', 'fill', 'reverse', 'no-gpu-hint',
439
+ 'origin-x', 'origin-y', 'target-x', 'target-y', 'count', 'spread'];
440
+ }
441
+
442
+ // active Web Animation handle — lets us pause / cancel
443
+ private _waAnimation: Animation | null = null;
444
+ // particle loop + webgl handles
445
+ private _particleLoop: ReturnType<typeof createAnimationLoop> | null = null;
446
+ private _particleWebGL: ReturnType<typeof createWebGLParticleSystem> | null = null;
447
+ private _canvas: HTMLCanvasElement | null = null;
448
+ // IntersectionObserver for trigger=visible
449
+ private _io: IntersectionObserver | null = null;
450
+ // tear-down refs
451
+ private _hoverOff: (() => void) | null = null;
452
+ private _clickOff: (() => void) | null = null;
453
+ // css animation class injected into shadow
454
+ private _cssAnimName = '';
455
+ // track whether IntersectionObserver already fired
456
+ private _visibleFired = false;
457
+
458
+ template() {
459
+ return `
460
+ <style>
461
+ :host {
462
+ display: contents;
463
+ }
464
+ .wrap {
465
+ display: contents;
466
+ }
467
+ .canvas-layer {
468
+ position: absolute;
469
+ inset: 0;
470
+ pointer-events: none;
471
+ z-index: 10;
472
+ }
473
+ /* Will be extended dynamically per CSS preset */
474
+ </style>
475
+ <div class="wrap"><slot></slot></div>
476
+ `;
477
+ }
478
+
479
+ onMount() {
480
+ this._setup();
481
+ }
482
+
483
+ onUnmount() {
484
+ this._teardown();
485
+ }
486
+
487
+ // ── Public methods ────────────────────────────────────────────────────────
488
+
489
+ play() { this._run(); }
490
+
491
+ pause() {
492
+ if (this._waAnimation && this._waAnimation.playState === 'running') {
493
+ this._waAnimation.pause();
494
+ }
495
+ }
496
+
497
+ cancel() {
498
+ this._waAnimation?.cancel();
499
+ this._waAnimation = null;
500
+ this._stopParticles();
501
+ const target = this._target();
502
+ if (target) {
503
+ cleanupAnimation(target);
504
+ target.style.cssText = target.style.cssText.replace(/animation[^;]*;?/g, '');
505
+ }
506
+ this.dispatchEvent(new CustomEvent('cancel', { bubbles: true, composed: true }));
507
+ }
508
+
509
+ // ── Setup ─────────────────────────────────────────────────────────────────
510
+
511
+ private _setup() {
512
+ const trigger = this._attr('trigger', 'mount') as AnimationTrigger;
513
+
514
+ switch (trigger) {
515
+ case 'mount':
516
+ this._scheduleRun();
517
+ break;
518
+ case 'visible':
519
+ this._setupVisibleTrigger();
520
+ break;
521
+ case 'hover':
522
+ this._setupHoverTrigger();
523
+ break;
524
+ case 'click':
525
+ this._setupClickTrigger();
526
+ break;
527
+ // 'manual' — nothing; caller calls .play()
528
+ }
529
+ }
530
+
531
+ private _teardown() {
532
+ this._io?.disconnect();
533
+ this._io = null;
534
+ this._hoverOff?.();
535
+ this._hoverOff = null;
536
+ this._clickOff?.();
537
+ this._clickOff = null;
538
+ this._waAnimation?.cancel();
539
+ this._waAnimation = null;
540
+ this._stopParticles();
541
+ const target = this._target();
542
+ if (target) cleanupAnimation(target);
543
+ }
544
+
545
+ private _scheduleRun() {
546
+ const delay = this._numAttr('delay', 0);
547
+ if (delay > 0) {
548
+ setTimeout(() => this._run(), delay);
549
+ } else {
550
+ // defer one tick so the slot is painted
551
+ requestAnimationFrame(() => this._run());
552
+ }
553
+ }
554
+
555
+ private _setupVisibleTrigger() {
556
+ const threshold = parseFloat(this.getAttribute('threshold') || '0.15');
557
+ this._io = new IntersectionObserver((entries) => {
558
+ for (const entry of entries) {
559
+ if (entry.isIntersecting && !this._visibleFired) {
560
+ this._visibleFired = true;
561
+ this._scheduleRun();
562
+ this._io?.disconnect();
563
+ }
564
+ }
565
+ }, { threshold });
566
+ this._io.observe(this);
567
+ }
568
+
569
+ private _setupHoverTrigger() {
570
+ const onEnter = () => { this._visibleFired = false; this._run(); };
571
+ this.addEventListener('mouseenter', onEnter);
572
+ this._hoverOff = () => this.removeEventListener('mouseenter', onEnter);
573
+ }
574
+
575
+ private _setupClickTrigger() {
576
+ const onClick = () => { this._visibleFired = false; this._run(); };
577
+ this.addEventListener('click', onClick);
578
+ this._clickOff = () => this.removeEventListener('click', onClick);
579
+ }
580
+
581
+ // ── Core run dispatcher ───────────────────────────────────────────────────
582
+
583
+ private _run() {
584
+ const name = this._attr('name', 'fade-in');
585
+ const preset = PRESETS[name];
586
+ if (!preset) {
587
+ console.warn(`[nc-animation] Unknown preset: "${name}"`);
588
+ return;
589
+ }
590
+
591
+ this.dispatchEvent(new CustomEvent('start', { bubbles: true, composed: true }));
592
+
593
+ switch (preset.path) {
594
+ case 'waapi': this._runWAAPI(preset); break;
595
+ case 'css': this._runCSS(preset, name); break;
596
+ case 'particle': this._runParticles(preset); break;
597
+ }
598
+ }
599
+
600
+ // ── WAAPI path ────────────────────────────────────────────────────────────
601
+
602
+ private _runWAAPI(preset: PresetDef) {
603
+ const target = this._target();
604
+ if (!target || !preset.run) return;
605
+
606
+ const noHint = this.hasAttribute('no-gpu-hint');
607
+ if (!noHint) prepareForAnimation(target, ['transform', 'opacity']);
608
+
609
+ const opts: AnimationOptions & { distance?: number } = {
610
+ duration: this._numAttr('duration', preset.duration),
611
+ delay: this._numAttr('delay', 0),
612
+ easing: this.getAttribute('easing') ?? preset.easing ?? 'cubic-bezier(0.4,0,0.2,1)',
613
+ fill: (this.getAttribute('fill') as FillMode | null) ?? 'forwards',
614
+ iterations: this._iterAttr(),
615
+ distance: this._numAttr('distance', 40),
616
+ };
617
+
618
+ // Capture the animation handle so pause/cancel work
619
+ // We monkey-patch by running and grabbing the animation from the element
620
+ const before = target.getAnimations().length;
621
+ preset.run(target, opts).then(() => {
622
+ if (!noHint && opts.iterations === 1) cleanupAnimation(target);
623
+ this.dispatchEvent(new CustomEvent('finish', { bubbles: true, composed: true }));
624
+ });
625
+
626
+ // Grab the newest animation handle
627
+ requestAnimationFrame(() => {
628
+ const anims = target.getAnimations();
629
+ if (anims.length > before) {
630
+ this._waAnimation = anims[anims.length - 1];
631
+ }
632
+ });
633
+ }
634
+
635
+ // ── CSS compositor path ───────────────────────────────────────────────────
636
+
637
+ private _runCSS(preset: PresetDef, name: string) {
638
+ const target = this._target();
639
+ if (!target || !preset.keyframes) return;
640
+
641
+ const animName = `nc-anim-${name}`;
642
+
643
+ // Inject keyframes into document.head (NOT shadow root) — slotted targets
644
+ // are light DOM elements; shadow-scoped @keyframes are invisible to them.
645
+ if (!_injectedKeyframes.has(animName)) {
646
+ const style = document.createElement('style');
647
+ style.id = `kf-${animName}`;
648
+ style.textContent = `@keyframes ${animName} { ${preset.keyframes} }`;
649
+ document.head.appendChild(style);
650
+ _injectedKeyframes.add(animName);
651
+ }
652
+
653
+ const duration = this._numAttr('duration', preset.duration);
654
+ const delay = this._numAttr('delay', 0);
655
+ const easing = this.getAttribute('easing') ?? preset.easing ?? 'ease-in-out';
656
+ const iterations = this.getAttribute('iterations') === 'infinite' ? 'infinite' : this._iterAttr();
657
+ const fill = this.getAttribute('fill') ?? (iterations === 'infinite' ? 'none' : 'forwards');
658
+ const direction = this.hasAttribute('reverse') ? 'reverse' : 'normal';
659
+
660
+ target.style.willChange = 'transform, opacity';
661
+ target.style.animation = `${animName} ${duration}ms ${easing} ${delay}ms ${iterations} ${fill} ${direction}`;
662
+
663
+ if (iterations !== 'infinite') {
664
+ target.addEventListener('animationend', () => {
665
+ cleanupAnimation(target);
666
+ this.dispatchEvent(new CustomEvent('finish', { bubbles: true, composed: true }));
667
+ }, { once: true });
668
+ }
669
+ }
670
+
671
+ // ── Particle path ─────────────────────────────────────────────────────────
672
+
673
+ /** Resolve 'top'|'bottom'|'left'|'right'|'center'|number-string → 0-1 float */
674
+ private _resolvePos(raw: string | null, fallback: number): number {
675
+ if (!raw) return fallback;
676
+ const aliases: Record<string, number> = { top: 0, bottom: 1, left: 0, right: 1, center: 0.5 };
677
+ if (raw in aliases) return aliases[raw];
678
+ const n = parseFloat(raw);
679
+ return isNaN(n) ? fallback : Math.max(0, Math.min(1, n));
680
+ }
681
+
682
+ private _runParticles(preset: PresetDef) {
683
+ if (!preset.particle) return;
684
+ this._stopParticles();
685
+
686
+ // Full-viewport canvas — particles live in screen space, not element space
687
+ const canvas = document.createElement('canvas');
688
+ canvas.width = window.innerWidth;
689
+ canvas.height = window.innerHeight;
690
+ canvas.style.cssText =
691
+ 'position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:9999;';
692
+ document.body.appendChild(canvas);
693
+ this._canvas = canvas;
694
+
695
+ // Resolve origin from attributes (default center of screen)
696
+ const ox = this._resolvePos(this.getAttribute('origin-x'), 0.5);
697
+ const oy = this._resolvePos(this.getAttribute('origin-y'), 0.5);
698
+ const tx = this._resolvePos(this.getAttribute('target-x'), 0.5);
699
+ const ty = this._resolvePos(this.getAttribute('target-y'), 0.5);
700
+
701
+ // Allow attribute overrides on count and spread
702
+ const countOverride = this.getAttribute('count');
703
+ const spreadScale = parseFloat(this.getAttribute('spread') ?? '1');
704
+ const cfg: ParticleConfig = {
705
+ ...preset.particle,
706
+ count: countOverride ? parseInt(countOverride, 10) : preset.particle.count,
707
+ speed: preset.particle.speed
708
+ ? { min: preset.particle.speed.min * spreadScale, max: preset.particle.speed.max * spreadScale }
709
+ : { min: 40, max: 120 },
710
+ };
711
+
712
+ // Always use canvas2d — the WebGL system from gpu-animation doesn't
713
+ // support per-preset behaviors (electricity, fire, ripple etc.)
714
+ this._runCanvas2D(canvas, cfg, ox, oy, tx, ty, preset);
715
+
716
+ const duration = this._numAttr('duration', preset.duration);
717
+ setTimeout(() => {
718
+ this._stopParticles();
719
+ this.dispatchEvent(new CustomEvent('finish', { bubbles: true, composed: true }));
720
+ }, duration);
721
+ }
722
+
723
+ /**
724
+ * Full canvas2d particle engine.
725
+ * origin / target are 0-1 fractions of canvas dimensions.
726
+ * Each preset type gets its own spawn + update behaviour.
727
+ */
728
+ private _runCanvas2D(
729
+ canvas: HTMLCanvasElement,
730
+ config: ParticleConfig,
731
+ ox: number, oy: number,
732
+ tx: number, ty: number,
733
+ preset: PresetDef,
734
+ ) {
735
+ const ctx = canvas.getContext('2d');
736
+ if (!ctx) return;
737
+
738
+ const W = canvas.width;
739
+ const H = canvas.height;
740
+ const originX = ox * W;
741
+ const originY = oy * H;
742
+ const targetX = tx * W;
743
+ const targetY = ty * H;
744
+
745
+ const { count, colors = ['#667eea'], size = { min: 3, max: 8 }, speed = { min: 40, max: 120 }, type = 'burst' } = config;
746
+ const rnd = (min: number, max: number) => min + Math.random() * (max - min);
747
+ const pick = (arr: string[]) => arr[Math.floor(Math.random() * arr.length)];
748
+ const hex2rgb = (hex: string): [number,number,number] => {
749
+ const r = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
750
+ return r ? [parseInt(r[1],16), parseInt(r[2],16), parseInt(r[3],16)] : [255,255,255];
751
+ };
752
+
753
+ // ── Particle data arrays (avoid GC pressure) ─────────────────────────
754
+ interface P {
755
+ x: number; y: number; vx: number; vy: number;
756
+ ax: number; ay: number; // acceleration
757
+ size: number; color: string;
758
+ alpha: number;
759
+ life: number; maxLife: number; // 0-1
760
+ rot?: number; rotV?: number; // rotation for rect particles
761
+ trail?: Array<[number,number]>; // for electricity
762
+ wave?: number; // phase offset for ripple
763
+ shape?: 'circle'|'rect'|'line'|'arc';
764
+ }
765
+ const particles: P[] = [];
766
+
767
+ // ── Spawn logic per type ─────────────────────────────────────────────
768
+ const spawnParticle = (i: number): P => {
769
+ const t = count > 1 ? i / (count - 1) : 0.5;
770
+ switch (type) {
771
+ case 'shower': {
772
+ // rain from origin-x spread across top edge
773
+ const spread = W * 0.6;
774
+ return {
775
+ x: originX + (Math.random() - 0.5) * spread,
776
+ y: originY * H < H * 0.3 ? -10 : originY * H,
777
+ vx: rnd(-30, 30),
778
+ vy: speed.min + Math.random() * (speed.max - speed.min),
779
+ ax: 0, ay: 60,
780
+ size: rnd(size.min, size.max),
781
+ color: pick(colors), alpha: 1, life: 1, maxLife: 1,
782
+ rot: Math.random() * Math.PI * 2,
783
+ rotV: rnd(-3, 3),
784
+ shape: 'rect',
785
+ };
786
+ }
787
+ case 'burst': {
788
+ const angle = t * Math.PI * 2;
789
+ const spd = rnd(speed.min, speed.max);
790
+ return {
791
+ x: originX, y: originY,
792
+ vx: Math.cos(angle) * spd, vy: Math.sin(angle) * spd,
793
+ ax: 0, ay: 20,
794
+ size: rnd(size.min, size.max),
795
+ color: pick(colors), alpha: 1, life: 1, maxLife: 1,
796
+ rot: 0, rotV: rnd(-5, 5), shape: 'circle',
797
+ };
798
+ }
799
+ case 'explode': {
800
+ const angle = Math.random() * Math.PI * 2;
801
+ const spd = rnd(speed.min, speed.max);
802
+ return {
803
+ x: originX, y: originY,
804
+ vx: Math.cos(angle) * spd, vy: Math.sin(angle) * spd,
805
+ ax: 0, ay: 120,
806
+ size: rnd(size.min, size.max),
807
+ color: pick(colors), alpha: 1, life: 1, maxLife: 1,
808
+ rot: Math.random() * Math.PI * 2,
809
+ rotV: rnd(-8, 8), shape: 'rect',
810
+ };
811
+ }
812
+ case 'float': {
813
+ return {
814
+ x: originX + (Math.random() - 0.5) * W * 0.8,
815
+ y: H + rnd(0, H * 0.2),
816
+ vx: rnd(-20, 20),
817
+ vy: -(speed.min + Math.random() * (speed.max - speed.min)),
818
+ ax: Math.sin(i) * 5, ay: 0,
819
+ size: rnd(size.min, size.max),
820
+ color: pick(colors), alpha: 0.7, life: 1, maxLife: 1,
821
+ shape: 'circle',
822
+ };
823
+ }
824
+ case 'spiral': {
825
+ const a = t * Math.PI * 8;
826
+ const r = t * Math.min(W, H) * 0.4;
827
+ const spd = rnd(speed.min, speed.max);
828
+ return {
829
+ x: originX + Math.cos(a) * r,
830
+ y: originY + Math.sin(a) * r,
831
+ vx: Math.cos(a + Math.PI/2) * spd,
832
+ vy: Math.sin(a + Math.PI/2) * spd,
833
+ ax: 0, ay: 0,
834
+ size: rnd(size.min, size.max),
835
+ color: pick(colors), alpha: 1, life: 1, maxLife: 1,
836
+ shape: 'circle',
837
+ };
838
+ }
839
+ case 'converge': {
840
+ const angle = Math.random() * Math.PI * 2;
841
+ const dist = rnd(Math.min(W,H)*0.3, Math.min(W,H)*0.6);
842
+ const sx = targetX + Math.cos(angle)*dist;
843
+ const sy = targetY + Math.sin(angle)*dist;
844
+ const spd = rnd(speed.min, speed.max);
845
+ const dx = targetX - sx, dy = targetY - sy;
846
+ const len = Math.hypot(dx, dy) || 1;
847
+ return {
848
+ x: sx, y: sy,
849
+ vx: (dx/len)*spd, vy: (dy/len)*spd,
850
+ ax: 0, ay: 0,
851
+ size: rnd(size.min, size.max),
852
+ color: pick(colors), alpha: 1, life: 1, maxLife: 1,
853
+ shape: 'circle',
854
+ };
855
+ }
856
+ case 'firework': {
857
+ // Particles burst from random cluster positions across upper screen
858
+ const clusterSize = 20;
859
+ const clusterIdx = Math.floor(i / clusterSize);
860
+ const totalClusters = Math.max(1, Math.ceil(count / clusterSize));
861
+ // Spread bursts evenly across horizontal, upper 55% of screen
862
+ const burstX = W * (0.1 + (clusterIdx / Math.max(totalClusters - 1, 1)) * 0.8);
863
+ const burstY = H * (0.1 + Math.random() * 0.35);
864
+ const angle = ((i % clusterSize) / clusterSize) * Math.PI * 2 + (Math.random() - 0.5) * 0.4;
865
+ const spd = rnd(speed.min, speed.max);
866
+ return {
867
+ x: burstX, y: burstY,
868
+ vx: Math.cos(angle) * spd, vy: Math.sin(angle) * spd,
869
+ ax: 0, ay: 80,
870
+ size: rnd(size.min, size.max),
871
+ color: pick(colors), alpha: 1, life: 1, maxLife: 1,
872
+ shape: 'circle',
873
+ };
874
+ }
875
+ case 'explosion': {
876
+ // First 3 are expanding shockwave rings, rest are debris
877
+ if (i < 4) {
878
+ return {
879
+ x: originX, y: originY,
880
+ vx: 0, vy: 0, ax: 0, ay: 0,
881
+ size: 3 + i * 2,
882
+ color: pick(['#fbbf24','#fde68a','#fff']),
883
+ alpha: 0.85, life: 1, maxLife: 1,
884
+ wave: i * 0.12,
885
+ shape: 'arc',
886
+ };
887
+ }
888
+ const angle = Math.random() * Math.PI * 2;
889
+ const spd = rnd(speed.min, speed.max);
890
+ return {
891
+ x: originX + (Math.random()-0.5)*20,
892
+ y: originY + (Math.random()-0.5)*20,
893
+ vx: Math.cos(angle) * spd, vy: Math.sin(angle) * spd - 40,
894
+ ax: 0, ay: 200,
895
+ size: rnd(size.min, size.max),
896
+ color: pick(colors), alpha: 1, life: 1, maxLife: 1,
897
+ shape: 'circle',
898
+ };
899
+ }
900
+ case 'fire': {
901
+ return {
902
+ x: originX + (Math.random() - 0.5) * size.max * 6,
903
+ y: originY,
904
+ vx: rnd(-20, 20),
905
+ vy: -(speed.min + Math.random() * (speed.max - speed.min)),
906
+ ax: rnd(-8, 8), ay: -10,
907
+ size: rnd(size.min, size.max),
908
+ color: colors[Math.floor(Math.random() * colors.length)],
909
+ alpha: 0.9, life: 1, maxLife: 1,
910
+ shape: 'circle',
911
+ };
912
+ }
913
+ case 'ripple': {
914
+ return {
915
+ x: originX, y: originY,
916
+ vx: 0, vy: 0, ax: 0, ay: 0,
917
+ size: 0,
918
+ color: pick(colors), alpha: 1, life: 1, maxLife: 1,
919
+ wave: t * 0.5, // stagger rings by index
920
+ shape: 'arc',
921
+ };
922
+ }
923
+ default: {
924
+ const angle = Math.random() * Math.PI * 2;
925
+ const spd = rnd(speed.min, speed.max);
926
+ return {
927
+ x: originX, y: originY,
928
+ vx: Math.cos(angle)*spd, vy: Math.sin(angle)*spd,
929
+ ax: 0, ay: 30,
930
+ size: rnd(size.min, size.max),
931
+ color: pick(colors), alpha: 1, life: 1, maxLife: 1,
932
+ shape: 'circle',
933
+ };
934
+ }
935
+ }
936
+ };
937
+
938
+ // Stagger spawning for shower/fire so screen doesn't flash all at once
939
+ const isStaggered = type === 'shower' || type === 'fire' || type === 'float' || type === 'ripple';
940
+ if (isStaggered) {
941
+ // Spawn first 20% immediately, rest over first 40% of duration
942
+ const duration = this._numAttr('duration', preset.duration);
943
+ for (let i = 0; i < count; i++) {
944
+ const delay = (i / count) * duration * 0.5;
945
+ setTimeout(() => {
946
+ if (!this._canvas) return;
947
+ particles.push(spawnParticle(i));
948
+ }, delay);
949
+ }
950
+ } else {
951
+ for (let i = 0; i < count; i++) particles.push(spawnParticle(i));
952
+ }
953
+
954
+ // ── Per-particle draw ─────────────────────────────────────────────────
955
+ const drawParticle = (p: P) => {
956
+ ctx.globalAlpha = Math.max(0, p.life) * (p.alpha ?? 1);
957
+ ctx.fillStyle = p.color;
958
+ ctx.strokeStyle = p.color;
959
+
960
+ if (p.shape === 'rect' && p.rot !== undefined) {
961
+ ctx.save();
962
+ ctx.translate(p.x, p.y);
963
+ ctx.rotate(p.rot);
964
+ ctx.fillRect(-p.size / 2, -p.size * 1.5, p.size, p.size * 3);
965
+ ctx.restore();
966
+
967
+ } else if (p.shape === 'line' && p.trail && p.trail.length > 1) {
968
+ // Electricity bolt — draw jagged line segments from trail
969
+ ctx.lineWidth = p.size;
970
+ ctx.shadowColor = p.color;
971
+ ctx.shadowBlur = 12;
972
+ ctx.beginPath();
973
+ ctx.moveTo(p.trail[0][0], p.trail[0][1]);
974
+ for (let k = 1; k < p.trail.length; k++) {
975
+ ctx.lineTo(p.trail[k][0], p.trail[k][1]);
976
+ }
977
+ ctx.stroke();
978
+ ctx.shadowBlur = 0;
979
+
980
+ } else if (p.shape === 'arc') {
981
+ // Ripple ring OR explosion shockwave
982
+ const maxR = type === 'explosion'
983
+ ? Math.max(W, H) * 0.35
984
+ : Math.max(W, H) * 0.45;
985
+ const radius = (1 - p.life) * maxR + (p.size ?? 0);
986
+ if (radius > 0) {
987
+ ctx.lineWidth = type === 'explosion' ? Math.max(1, p.size * p.life * 2) : (p.size > 0 ? p.size : 2);
988
+ ctx.beginPath();
989
+ ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
990
+ ctx.stroke();
991
+ }
992
+
993
+ } else {
994
+ // Circle — fire, firework, explosion debris each get glow
995
+ if (type === 'fire') {
996
+ const [r,g,b] = hex2rgb(p.color);
997
+ const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
998
+ grad.addColorStop(0, `rgba(${r},${g},${b},${p.life})`);
999
+ grad.addColorStop(0.6, `rgba(${r},${g},${b},${p.life*0.5})`);
1000
+ grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
1001
+ ctx.fillStyle = grad;
1002
+ } else if (type === 'firework') {
1003
+ // Glowing spark — shrinks as it fades
1004
+ ctx.shadowColor = p.color;
1005
+ ctx.shadowBlur = p.size * 4;
1006
+ const drawR = Math.max(0.5, p.size * p.life);
1007
+ ctx.beginPath();
1008
+ ctx.arc(p.x, p.y, drawR, 0, Math.PI * 2);
1009
+ ctx.fill();
1010
+ ctx.shadowBlur = 0;
1011
+ ctx.globalAlpha = 1;
1012
+ return; // skip the generic arc below
1013
+ } else if (type === 'explosion') {
1014
+ const [r,g,b] = hex2rgb(p.color);
1015
+ const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
1016
+ grad.addColorStop(0, `rgba(255,255,255,${p.life})`);
1017
+ grad.addColorStop(0.35, `rgba(${r},${g},${b},${p.life})`);
1018
+ grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
1019
+ ctx.fillStyle = grad;
1020
+ ctx.shadowColor = p.color;
1021
+ ctx.shadowBlur = p.size * 2;
1022
+ }
1023
+ ctx.beginPath();
1024
+ ctx.arc(p.x, p.y, Math.max(0, p.size), 0, Math.PI * 2);
1025
+ ctx.fill();
1026
+ }
1027
+ ctx.globalAlpha = 1;
1028
+ ctx.shadowBlur = 0;
1029
+ };
1030
+
1031
+ const maxElecBolts = 5;
1032
+ let elecTimer = 0;
1033
+ const elecInterval = 0.06; // seconds between new bolt sets
1034
+
1035
+ const loop = createAnimationLoop((dt) => {
1036
+ ctx.clearRect(0, 0, W, H);
1037
+
1038
+ // ── Electricity: regenerate jagged bolt paths each interval ────
1039
+ if (type === 'electricity') {
1040
+ elecTimer += dt;
1041
+ if (elecTimer >= elecInterval) {
1042
+ elecTimer = 0;
1043
+ // Re-generate all bolt trails from origin -> target
1044
+ const activeBolts = Math.min(maxElecBolts, particles.length);
1045
+ for (let b = 0; b < activeBolts; b++) {
1046
+ const p = particles[b];
1047
+ if (!p) continue;
1048
+ const steps = 12 + Math.floor(Math.random() * 8);
1049
+ const trail: Array<[number,number]> = [[originX, originY]];
1050
+ for (let s = 1; s < steps; s++) {
1051
+ const t2 = s / steps;
1052
+ const mx = originX + (targetX - originX) * t2;
1053
+ const my = originY + (targetY - originY) * t2;
1054
+ const jitter = (1 - Math.abs(t2 - 0.5) * 2) * 80;
1055
+ trail.push([
1056
+ mx + (Math.random()-0.5) * jitter,
1057
+ my + (Math.random()-0.5) * jitter,
1058
+ ]);
1059
+ }
1060
+ trail.push([targetX, targetY]);
1061
+ p.trail = trail;
1062
+ p.life = 1;
1063
+ p.alpha = 0.6 + Math.random() * 0.4;
1064
+ }
1065
+ } else {
1066
+ // Fade between regenerations
1067
+ for (const p of particles) p.life = Math.max(0, p.life - dt * 6);
1068
+ }
1069
+ for (const p of particles) drawParticle(p);
1070
+ return true; // electricity loops until duration expires
1071
+ }
1072
+
1073
+ // ── All other types ───────────────────────────────────────────
1074
+ let alive = 0;
1075
+ const fadeRate = type === 'ripple' ? 0.3 : type === 'fire' ? 0.7 :
1076
+ type === 'float' ? 0.15 :
1077
+ type === 'firework' ? 0.42 :
1078
+ type === 'explosion' ? 0.65 : 0.35;
1079
+
1080
+ for (const p of particles) {
1081
+ p.vx += p.ax * dt;
1082
+ p.vy += p.ay * dt;
1083
+ p.x += p.vx * dt;
1084
+ p.y += p.vy * dt;
1085
+ if (p.rot !== undefined && p.rotV !== undefined) p.rot += p.rotV * dt;
1086
+ // Ripple: shrink size for growing ring effect is handled in draw
1087
+ if (type !== 'ripple') {
1088
+ p.life = Math.max(0, p.life - dt * fadeRate);
1089
+ } else {
1090
+ // stagger rings with wave offset
1091
+ p.life = Math.max(0, p.life - dt * (0.35 + (p.wave ?? 0) * 0.1));
1092
+ }
1093
+ if (p.life <= 0.01) continue;
1094
+ alive++;
1095
+ drawParticle(p);
1096
+ }
1097
+ return alive > 0 || particles.length < count;
1098
+ });
1099
+
1100
+ this._particleLoop = loop;
1101
+ loop.start();
1102
+ }
1103
+
1104
+ private _stopParticles() {
1105
+ this._particleWebGL?.stop();
1106
+ this._particleWebGL = null;
1107
+ this._particleLoop?.stop();
1108
+ this._particleLoop = null;
1109
+ if (this._canvas) {
1110
+ this._canvas.remove();
1111
+ this._canvas = null;
1112
+ }
1113
+ }
1114
+
1115
+ // ── Helpers ───────────────────────────────────────────────────────────────
1116
+
1117
+ /** First painted element assigned to the default slot. */
1118
+ private _target(): HTMLElement | null {
1119
+ const slot = this.shadowRoot?.querySelector<HTMLSlotElement>('slot');
1120
+ if (!slot) return null;
1121
+ const nodes = slot.assignedElements({ flatten: true });
1122
+ return (nodes[0] as HTMLElement) ?? null;
1123
+ }
1124
+
1125
+ private _attr(name: string, fallback: string): string {
1126
+ return this.getAttribute(name) ?? fallback;
1127
+ }
1128
+
1129
+ private _numAttr(name: string, fallback: number): number {
1130
+ const v = parseInt(this.getAttribute(name) ?? '', 10);
1131
+ return isNaN(v) ? fallback : v;
1132
+ }
1133
+
1134
+ private _iterAttr(): number {
1135
+ const raw = this.getAttribute('iterations');
1136
+ if (!raw || raw === 'infinite') return 1;
1137
+ const n = parseInt(raw, 10);
1138
+ return isNaN(n) ? 1 : n;
1139
+ }
1140
+
1141
+ attributeChangedCallback(name: string, oldValue: string, newValue: string) {
1142
+ if (oldValue === newValue || !this._mounted) return;
1143
+ // Re-setup on any attribute change (restarts trigger logic)
1144
+ this._teardown();
1145
+ this._visibleFired = false;
1146
+ this._setup();
1147
+ }
1148
+ }
1149
+
1150
+ defineComponent('nc-animation', NcAnimation);