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