dotnotify 1.0.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.
@@ -0,0 +1,793 @@
1
+ /*!
2
+ * DotNotify v1.0.0
3
+ * iOS-glass particle notification library
4
+ * MIT License — https://github.com/yourusername/dotnotify
5
+ */
6
+
7
+ (function (global, factory) {
8
+ typeof exports === 'object' && typeof module !== 'undefined'
9
+ ? module.exports = factory()
10
+ : typeof define === 'function' && define.amd
11
+ ? define(factory)
12
+ : (global.DotNotify = factory());
13
+ }(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this, function () {
14
+
15
+ /* ─────────────────────────────────────────
16
+ DEFAULTS
17
+ ───────────────────────────────────────── */
18
+ const DEFAULTS = {
19
+ position: 'top-right', // 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
20
+ theme: 'ios-glass', // 'ios-glass' | 'dark' | 'light' | 'minimal'
21
+ sound: false,
22
+ maxStack: 4,
23
+ duration: 4200,
24
+ zIndex: 99999,
25
+ };
26
+
27
+ const TYPE_CONFIG = {
28
+ error: {
29
+ app: 'Security', color: '#ff6b6b', rgb: '255,107,107',
30
+ tagColor: 'rgba(255,120,120,.75)',
31
+ accentBg: 'rgba(255,80,80,.18)', accentBorder: 'rgba(255,80,80,.4)', accentText: '#ffaaaa',
32
+ dots: ['#ff5252','#ff1744','#ff6b6b','#ff8a80','#ffffff','#ff4444'],
33
+ icon: `<svg viewBox="0 0 18 18" fill="none"><circle cx="9" cy="9" r="8" stroke="rgba(255,107,107,.8)" stroke-width="1.2"/><line x1="5.5" y1="5.5" x2="12.5" y2="12.5" stroke="rgba(255,107,107,.9)" stroke-width="1.6" stroke-linecap="round"/><line x1="12.5" y1="5.5" x2="5.5" y2="12.5" stroke="rgba(255,107,107,.9)" stroke-width="1.6" stroke-linecap="round"/></svg>`
34
+ },
35
+ success: {
36
+ app: 'Auth', color: '#4ade80', rgb: '74,222,128',
37
+ tagColor: 'rgba(74,222,128,.75)',
38
+ accentBg: 'rgba(74,222,128,.18)', accentBorder: 'rgba(74,222,128,.4)', accentText: '#86efac',
39
+ dots: ['#4ade80','#22c55e','#86efac','#bbf7d0','#ffffff','#16a34a'],
40
+ icon: `<svg viewBox="0 0 18 18" fill="none"><circle cx="9" cy="9" r="8" stroke="rgba(74,222,128,.8)" stroke-width="1.2"/><polyline points="5,9 7.5,12 13,6" stroke="rgba(74,222,128,.95)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`
41
+ },
42
+ warning: {
43
+ app: 'Session', color: '#fbbf24', rgb: '251,191,36',
44
+ tagColor: 'rgba(251,191,36,.75)',
45
+ accentBg: 'rgba(251,191,36,.18)', accentBorder: 'rgba(251,191,36,.4)', accentText: '#fde68a',
46
+ dots: ['#fbbf24','#f59e0b','#fcd34d','#fde68a','#ffffff','#d97706'],
47
+ icon: `<svg viewBox="0 0 18 18" fill="none"><path d="M9 2L16.5 15H1.5L9 2Z" stroke="rgba(251,191,36,.8)" stroke-width="1.2" stroke-linejoin="round"/><line x1="9" y1="7" x2="9" y2="11" stroke="rgba(251,191,36,.95)" stroke-width="1.6" stroke-linecap="round"/><circle cx="9" cy="13.2" r="0.9" fill="rgba(251,191,36,.95)"/></svg>`
48
+ },
49
+ info: {
50
+ app: 'System', color: '#818cf8', rgb: '129,140,248',
51
+ tagColor: 'rgba(129,140,248,.75)',
52
+ accentBg: 'rgba(129,140,248,.18)', accentBorder: 'rgba(129,140,248,.4)', accentText: '#c7d2fe',
53
+ dots: ['#818cf8','#6366f1','#a5b4fc','#c7d2fe','#ffffff','#4f46e5'],
54
+ icon: `<svg viewBox="0 0 18 18" fill="none"><circle cx="9" cy="9" r="8" stroke="rgba(129,140,248,.8)" stroke-width="1.2"/><circle cx="9" cy="5.8" r="1" fill="rgba(129,140,248,.95)"/><line x1="9" y1="8.5" x2="9" y2="13.5" stroke="rgba(129,140,248,.95)" stroke-width="1.7" stroke-linecap="round"/></svg>`
55
+ },
56
+ };
57
+
58
+ /* ─────────────────────────────────────────
59
+ INJECT STYLES
60
+ ───────────────────────────────────────── */
61
+ function injectStyles() {
62
+ if (document.getElementById('dotnotify-styles')) return;
63
+ const style = document.createElement('style');
64
+ style.id = 'dotnotify-styles';
65
+ style.textContent = `
66
+ .dn-stage {
67
+ position: fixed;
68
+ inset: 0;
69
+ pointer-events: none;
70
+ z-index: var(--dn-z, 99999);
71
+ }
72
+ .dn-canvas {
73
+ position: fixed;
74
+ inset: 0;
75
+ width: 100%;
76
+ height: 100%;
77
+ pointer-events: none;
78
+ z-index: var(--dn-z, 99999);
79
+ }
80
+
81
+ /* ── CARD ── */
82
+ .dn-card {
83
+ position: fixed;
84
+ width: 320px;
85
+ pointer-events: auto;
86
+ opacity: 0;
87
+ transform: scale(.72) translateY(-10px);
88
+ transform-origin: top right;
89
+ border-radius: 20px;
90
+ overflow: hidden;
91
+ transition: top .38s cubic-bezier(.34,1.2,.64,1);
92
+ will-change: transform, top, opacity;
93
+ user-select: none;
94
+ cursor: default;
95
+ }
96
+ .dn-card.pos-left { transform-origin: top left; }
97
+
98
+ /* Themes */
99
+ .dn-card.theme-ios-glass {
100
+ background: rgba(28,28,32,.55);
101
+ backdrop-filter: blur(40px) saturate(220%) brightness(110%);
102
+ -webkit-backdrop-filter: blur(40px) saturate(220%) brightness(110%);
103
+ border: 0.5px solid rgba(255,255,255,.22);
104
+ box-shadow:
105
+ 0 0 0 0.5px rgba(255,255,255,.08) inset,
106
+ 0 1px 0 rgba(255,255,255,.15) inset,
107
+ 0 4px 8px rgba(0,0,0,.12),
108
+ 0 12px 32px rgba(0,0,0,.4),
109
+ 0 40px 64px rgba(0,0,0,.3);
110
+ }
111
+ .dn-card.theme-ios-glass::before {
112
+ content: '';
113
+ position: absolute; top: 0; left: 0; right: 0; height: 50%;
114
+ background: linear-gradient(180deg, rgba(255,255,255,.09) 0%, transparent 100%);
115
+ border-radius: 20px 20px 0 0;
116
+ pointer-events: none; z-index: 0;
117
+ }
118
+ .dn-card.theme-dark {
119
+ background: rgba(10,10,12,.82);
120
+ backdrop-filter: blur(32px) saturate(180%);
121
+ -webkit-backdrop-filter: blur(32px) saturate(180%);
122
+ border: 0.5px solid rgba(255,255,255,.14);
123
+ box-shadow: 0 0 0 0.5px rgba(255,255,255,.06) inset, 0 12px 40px rgba(0,0,0,.6);
124
+ }
125
+ .dn-card.theme-light {
126
+ background: rgba(255,255,255,.78);
127
+ backdrop-filter: blur(32px) saturate(180%);
128
+ -webkit-backdrop-filter: blur(32px) saturate(180%);
129
+ border: 0.5px solid rgba(0,0,0,.1);
130
+ box-shadow: 0 0 0 0.5px rgba(255,255,255,.9) inset, 0 8px 32px rgba(0,0,0,.14);
131
+ }
132
+ .dn-card.theme-light .dn-title { color: rgba(0,0,0,.88) !important; }
133
+ .dn-card.theme-light .dn-msg { color: rgba(0,0,0,.5) !important; }
134
+ .dn-card.theme-light .dn-app { color: rgba(0,0,0,.38) !important; }
135
+ .dn-card.theme-light .dn-time { color: rgba(0,0,0,.3) !important; }
136
+ .dn-card.theme-light .dn-close { background: rgba(0,0,0,.06); border-color: rgba(0,0,0,.12); color: rgba(0,0,0,.35); }
137
+ .dn-card.theme-light .dn-sep { background: linear-gradient(90deg,transparent,rgba(0,0,0,.08) 20%,rgba(0,0,0,.06) 80%,transparent); }
138
+ .dn-card.theme-light .dn-tag { background: rgba(0,0,0,.05); border-color: rgba(0,0,0,.1); }
139
+ .dn-card.theme-light .dn-dp { background: rgba(0,0,0,.18); }
140
+ .dn-card.theme-minimal {
141
+ background: rgba(16,16,18,.9);
142
+ backdrop-filter: blur(20px) saturate(160%);
143
+ -webkit-backdrop-filter: blur(20px) saturate(160%);
144
+ border: 0.5px solid rgba(255,255,255,.1);
145
+ box-shadow: 0 8px 24px rgba(0,0,0,.5);
146
+ }
147
+
148
+ /* animations */
149
+ .dn-card.dn-show { animation: dn-in .44s cubic-bezier(.28,1.35,.5,1) forwards; }
150
+ .dn-card.dn-hide { animation: dn-out .44s cubic-bezier(.4,0,1,1) forwards; }
151
+ .dn-card.dn-swipe-left { animation: dn-swipe-l .32s ease forwards; }
152
+ .dn-card.dn-swipe-right { animation: dn-swipe-r .32s ease forwards; }
153
+
154
+ @keyframes dn-in { 0%{opacity:0;transform:scale(.72) translateY(-10px)} 60%{opacity:1} 100%{opacity:1;transform:scale(1) translateY(0)} }
155
+ @keyframes dn-out { 0%{opacity:1;transform:scale(1)} 100%{opacity:0;transform:scale(.88) translateY(-8px)} }
156
+ @keyframes dn-swipe-l { 0%{opacity:1;transform:translateX(0)} 100%{opacity:0;transform:translateX(-130%)} }
157
+ @keyframes dn-swipe-r { 0%{opacity:1;transform:translateX(0)} 100%{opacity:0;transform:translateX(130%)} }
158
+
159
+ /* ── BODY ── */
160
+ .dn-body { position: relative; z-index: 2; padding: 14px 16px 10px; }
161
+ .dn-row { display: flex; align-items: flex-start; gap: 12px; }
162
+
163
+ /* icon pill */
164
+ .dn-pill {
165
+ flex-shrink: 0;
166
+ width: 40px; height: 40px; border-radius: 12px;
167
+ display: flex; align-items: center; justify-content: center;
168
+ position: relative; overflow: hidden;
169
+ background: rgba(255,255,255,.1);
170
+ border: 0.5px solid rgba(255,255,255,.2);
171
+ box-shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 2px 8px rgba(0,0,0,.25);
172
+ }
173
+ .dn-pill::before {
174
+ content: '';
175
+ position: absolute; top: 0; left: 0; right: 0; height: 55%;
176
+ background: linear-gradient(180deg, rgba(255,255,255,.18) 0%, transparent 100%);
177
+ border-radius: 12px 12px 0 0;
178
+ }
179
+ .dn-pill svg, .dn-pill img, .dn-pill .dn-emoji {
180
+ position: relative; z-index: 1;
181
+ }
182
+ .dn-pill img {
183
+ width: 40px; height: 40px;
184
+ border-radius: 11px;
185
+ object-fit: cover;
186
+ }
187
+ .dn-pill .dn-emoji { font-size: 20px; line-height: 1; }
188
+
189
+ /* ring mode */
190
+ .dn-ring-wrap { width: 44px; height: 44px; position: relative; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
191
+ .dn-ring-svg { position: absolute; inset: 0; transform: rotate(-90deg); }
192
+ .dn-ring-bg { fill: none; stroke: rgba(255,255,255,.1); stroke-width: 2.5; }
193
+ .dn-ring-fill { fill: none; stroke-width: 2.5; stroke-linecap: round; }
194
+ .dn-ring-inner {
195
+ width: 32px; height: 32px; border-radius: 10px;
196
+ display: flex; align-items: center; justify-content: center;
197
+ background: rgba(255,255,255,.1);
198
+ border: 0.5px solid rgba(255,255,255,.15);
199
+ }
200
+ .dn-ring-inner img { width: 32px; height: 32px; border-radius: 9px; object-fit: cover; }
201
+ .dn-ring-inner .dn-emoji { font-size: 16px; }
202
+
203
+ /* image thumbnail */
204
+ .dn-thumb {
205
+ flex-shrink: 0; margin-top: 2px;
206
+ width: 48px; height: 48px; border-radius: 10px;
207
+ object-fit: cover;
208
+ border: 0.5px solid rgba(255,255,255,.15);
209
+ }
210
+
211
+ /* typography */
212
+ .dn-txt { flex: 1; min-width: 0; }
213
+ .dn-app { font-size: 10px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: rgba(255,255,255,.4); margin-bottom: 3px; line-height: 1; font-family: inherit; }
214
+ .dn-title { font-size: 14px; font-weight: 600; letter-spacing: -.022em; line-height: 1.25; color: rgba(255,255,255,.95); font-family: inherit; }
215
+ .dn-msg { font-size: 12.5px; font-weight: 300; line-height: 1.5; margin-top: 3px; color: rgba(255,255,255,.55); font-family: inherit; }
216
+
217
+ /* close */
218
+ .dn-close {
219
+ flex-shrink: 0; margin-top: -2px;
220
+ width: 22px; height: 22px; border-radius: 50%;
221
+ background: rgba(255,255,255,.1); border: 0.5px solid rgba(255,255,255,.15);
222
+ color: rgba(255,255,255,.4); font-size: 10px; cursor: pointer;
223
+ display: flex; align-items: center; justify-content: center;
224
+ transition: background .15s, color .15s;
225
+ font-family: inherit;
226
+ }
227
+ .dn-close:hover { background: rgba(255,255,255,.2); color: rgba(255,255,255,.8); }
228
+
229
+ /* actions */
230
+ .dn-actions { display: flex; gap: 7px; padding: 0 16px 10px; position: relative; z-index: 2; }
231
+ .dn-action {
232
+ flex: 1; padding: 7px 10px; border-radius: 10px;
233
+ border: 0.5px solid rgba(255,255,255,.14);
234
+ background: rgba(255,255,255,.07); color: rgba(255,255,255,.8);
235
+ font-family: inherit; font-size: 11.5px; font-weight: 500; cursor: pointer;
236
+ transition: background .15s, border-color .15s; text-align: center;
237
+ }
238
+ .dn-action:hover { background: rgba(255,255,255,.14); border-color: rgba(255,255,255,.28); }
239
+ .dn-action.dn-primary {
240
+ background: var(--dn-accent-bg);
241
+ border-color: var(--dn-accent-border);
242
+ color: var(--dn-accent-text);
243
+ }
244
+ .dn-action.dn-primary:hover { filter: brightness(1.15); }
245
+
246
+ /* footer */
247
+ .dn-sep { height: 0.5px; background: linear-gradient(90deg, transparent, rgba(255,255,255,.12) 20%, rgba(255,255,255,.08) 80%, transparent); }
248
+ .dn-foot { display: flex; align-items: center; justify-content: space-between; padding: 8px 16px 10px; position: relative; z-index: 2; }
249
+ .dn-time { font-size: 10px; color: rgba(255,255,255,.25); letter-spacing: .02em; font-family: inherit; }
250
+ .dn-tag { font-size: 9.5px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; padding: 3px 10px; border-radius: 20px; background: rgba(255,255,255,.08); border: 0.5px solid rgba(255,255,255,.12); font-family: inherit; }
251
+
252
+ /* dot progress */
253
+ .dn-dotbar { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 0 16px 12px; position: relative; z-index: 2; }
254
+ .dn-dp {
255
+ width: 5px; height: 5px; border-radius: 50%;
256
+ background: rgba(255,255,255,.22); flex-shrink: 0; cursor: default;
257
+ transition:
258
+ width .35s cubic-bezier(.34,1.56,.64,1),
259
+ height .35s cubic-bezier(.34,1.56,.64,1),
260
+ background .3s ease, box-shadow .3s ease, opacity .3s ease;
261
+ }
262
+ .dn-dp.dn-active {
263
+ background: var(--dn-color);
264
+ box-shadow: 0 0 8px var(--dn-color), 0 0 18px color-mix(in srgb, var(--dn-color) 40%, transparent);
265
+ }
266
+ .dn-dp.dn-done { width: 3px; height: 3px; background: rgba(255,255,255,.1); box-shadow: none; opacity: .5; }
267
+ .dn-dp.dn-hovered { width: 12px !important; height: 12px !important; background: var(--dn-color) !important; box-shadow: 0 0 12px var(--dn-color), 0 0 28px color-mix(in srgb, var(--dn-color) 50%, transparent) !important; opacity: 1 !important; }
268
+ .dn-dotbar.dn-paused .dn-dp:not(.dn-done):not(.dn-hovered) { width: 8px !important; height: 8px !important; background: var(--dn-color) !important; box-shadow: 0 0 7px var(--dn-color) !important; opacity: .75 !important; }
269
+ .dn-dotbar.dn-paused .dn-dp.dn-done:not(.dn-hovered) { width: 5px !important; height: 5px !important; background: rgba(255,255,255,.18) !important; opacity: .35 !important; }
270
+ `;
271
+ document.head.appendChild(style);
272
+ }
273
+
274
+ /* ─────────────────────────────────────────
275
+ CANVAS PARTICLE ENGINE
276
+ ───────────────────────────────────────── */
277
+ function getOrCreateCanvas(zIndex) {
278
+ let cv = document.getElementById('dn-canvas');
279
+ if (!cv) {
280
+ cv = document.createElement('canvas');
281
+ cv.id = 'dn-canvas';
282
+ cv.className = 'dn-canvas';
283
+ cv.style.zIndex = zIndex;
284
+ cv.style.pointerEvents = 'none';
285
+ document.body.appendChild(cv);
286
+ }
287
+ cv.width = window.innerWidth;
288
+ cv.height = window.innerHeight;
289
+ return cv;
290
+ }
291
+
292
+ function eio(t) { return t < .5 ? 4*t*t*t : 1 - Math.pow(-2*t+2, 3)/2; }
293
+
294
+ function drawDot(ctx, d, elapsed) {
295
+ const t = Math.max(0, Math.min(1, (elapsed - d.delay) / (1 - d.delay + .001)));
296
+ const et = eio(t);
297
+ d.wb += d.ws;
298
+ d.x = d.sx + (d.tx - d.sx)*et + Math.sin(d.wb)*d.wa*(1-et);
299
+ d.y = d.sy + (d.ty - d.sy)*et + Math.cos(d.wb*1.3)*d.wa*(1-et);
300
+ d.op = t < .07 ? t/.07 : t > .82 ? (1-t)/.18 : 1;
301
+ d.trail.push({x: d.x, y: d.y});
302
+ if (d.trail.length > 10) d.trail.shift();
303
+ for (let j = 1; j < d.trail.length; j++) {
304
+ const tf = (j/d.trail.length)*.32*d.op*(1-et*.8);
305
+ if (tf < .015) continue;
306
+ ctx.beginPath(); ctx.moveTo(d.trail[j-1].x, d.trail[j-1].y); ctx.lineTo(d.trail[j].x, d.trail[j].y);
307
+ ctx.strokeStyle = d.col + Math.round(tf*255).toString(16).padStart(2,'0');
308
+ ctx.lineWidth = d.r*.6; ctx.lineCap = 'round'; ctx.stroke();
309
+ }
310
+ const r = d.r*(1+.2*Math.sin(elapsed*10*800+d.wb))*(1-et*.2);
311
+ const g1 = ctx.createRadialGradient(d.x,d.y,0,d.x,d.y,r*4.5);
312
+ g1.addColorStop(0, d.col + Math.round(d.op*.4*255).toString(16).padStart(2,'0'));
313
+ g1.addColorStop(1, d.col+'00');
314
+ ctx.beginPath(); ctx.arc(d.x,d.y,r*4.5,0,Math.PI*2); ctx.fillStyle=g1; ctx.fill();
315
+ ctx.beginPath(); ctx.arc(d.x,d.y,r,0,Math.PI*2);
316
+ ctx.fillStyle = d.col + Math.round(d.op*255).toString(16).padStart(2,'0'); ctx.fill();
317
+ if (d.op > .4 && r > 2) {
318
+ ctx.beginPath(); ctx.arc(d.x-r*.3,d.y-r*.3,r*.3,0,Math.PI*2);
319
+ ctx.fillStyle = 'rgba(255,255,255,'+d.op*.6+')'; ctx.fill();
320
+ }
321
+ return t < 1;
322
+ }
323
+
324
+ function animateParticles(ctx, dots, dur, onDone) {
325
+ const t0 = performance.now();
326
+ let raf;
327
+ function fr(now) {
328
+ const el = (now - t0) / dur;
329
+ ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
330
+ let anyRunning = false;
331
+ for (const d of dots) { if (drawDot(ctx, d, el)) anyRunning = true; }
332
+ if (anyRunning) { raf = requestAnimationFrame(fr); }
333
+ else { ctx.clearRect(0,0,ctx.canvas.width,ctx.canvas.height); onDone && onDone(); }
334
+ }
335
+ raf = requestAnimationFrame(fr);
336
+ return () => { cancelAnimationFrame(raf); ctx.clearRect(0,0,ctx.canvas.width,ctx.canvas.height); };
337
+ }
338
+
339
+ function makeEntryDots(cfg, targetX, targetY, targetW, targetH) {
340
+ const cx = window.innerWidth/2, cy = window.innerHeight/2;
341
+ return Array.from({length: 65}, () => {
342
+ const ang = Math.random()*Math.PI*2, rad = 90+Math.random()*260;
343
+ const d = {
344
+ sx: cx + Math.cos(ang)*rad + (Math.random()-.5)*100,
345
+ sy: cy + Math.sin(ang)*rad + (Math.random()-.5)*100,
346
+ tx: targetX + Math.random()*targetW,
347
+ ty: targetY + Math.random()*targetH,
348
+ r: 1.2 + Math.random()*4,
349
+ col: cfg.dots[Math.floor(Math.random()*cfg.dots.length)],
350
+ delay: Math.pow(Math.random(), .5)*.5,
351
+ wb: Math.random()*Math.PI*2, ws: .03+Math.random()*.06, wa: .5+Math.random()*2,
352
+ trail: [], op: 0, x: 0, y: 0
353
+ };
354
+ d.x = d.sx; d.y = d.sy; return d;
355
+ });
356
+ }
357
+
358
+ function makeExitDots(cfg, snapX, snapY, cardW, cardH) {
359
+ return Array.from({length: 52}, () => {
360
+ const sx = snapX + Math.random()*cardW, sy = snapY + Math.random()*cardH;
361
+ const ang = Math.random()*Math.PI*2, rad = 110+Math.random()*270;
362
+ return {
363
+ sx, sy, x: sx, y: sy,
364
+ tx: sx + Math.cos(ang)*rad + (Math.random()-.5)*60,
365
+ ty: sy + Math.sin(ang)*rad + (Math.random()-.5)*60,
366
+ r: 1 + Math.random()*3.5,
367
+ col: cfg.dots[Math.floor(Math.random()*cfg.dots.length)],
368
+ delay: Math.random()*.28,
369
+ wb: Math.random()*Math.PI*2, ws: .04+Math.random()*.07, wa: .3+Math.random()*1.2,
370
+ trail: [], op: 0
371
+ };
372
+ });
373
+ }
374
+
375
+ function animateExit(ctx, dots, onDone) {
376
+ const t0 = performance.now(), dur = 640;
377
+ let raf;
378
+ function fr(now) {
379
+ const el = (now-t0)/dur;
380
+ ctx.clearRect(0,0,ctx.canvas.width,ctx.canvas.height);
381
+ let running = false;
382
+ for (const d of dots) {
383
+ const t = Math.max(0,Math.min(1,(el-d.delay)/(1-d.delay+.001)));
384
+ const et = 1-Math.pow(1-t,3);
385
+ d.wb += d.ws;
386
+ d.x = d.sx+(d.tx-d.sx)*et;
387
+ d.y = d.sy+(d.ty-d.sy)*et;
388
+ d.op = t<.08?t/.08:Math.pow(1-t,1.5);
389
+ d.trail.push({x:d.x,y:d.y}); if(d.trail.length>7)d.trail.shift();
390
+ for(let j=1;j<d.trail.length;j++){
391
+ const tf=(j/d.trail.length)*.25*d.op; if(tf<.01)continue;
392
+ ctx.beginPath();ctx.moveTo(d.trail[j-1].x,d.trail[j-1].y);ctx.lineTo(d.trail[j].x,d.trail[j].y);
393
+ ctx.strokeStyle=d.col+Math.round(tf*255).toString(16).padStart(2,'0');ctx.lineWidth=d.r*.5;ctx.lineCap='round';ctx.stroke();
394
+ }
395
+ const r=d.r*(1-et*.4);
396
+ const g=ctx.createRadialGradient(d.x,d.y,0,d.x,d.y,r*3.5);
397
+ g.addColorStop(0,d.col+Math.round(d.op*.42*255).toString(16).padStart(2,'0'));g.addColorStop(1,d.col+'00');
398
+ ctx.beginPath();ctx.arc(d.x,d.y,r*3.5,0,Math.PI*2);ctx.fillStyle=g;ctx.fill();
399
+ ctx.beginPath();ctx.arc(d.x,d.y,r,0,Math.PI*2);ctx.fillStyle=d.col+Math.round(d.op*255).toString(16).padStart(2,'0');ctx.fill();
400
+ if(t<1)running=true;
401
+ }
402
+ if(running){raf=requestAnimationFrame(fr);}else{ctx.clearRect(0,0,ctx.canvas.width,ctx.canvas.height);onDone&&onDone();}
403
+ }
404
+ raf=requestAnimationFrame(fr);
405
+ return ()=>{cancelAnimationFrame(raf);ctx.clearRect(0,0,ctx.canvas.width,ctx.canvas.height);};
406
+ }
407
+
408
+ /* ─────────────────────────────────────────
409
+ SOUND ENGINE
410
+ ───────────────────────────────────────── */
411
+ function playSound(type) {
412
+ try {
413
+ const ac = new (window.AudioContext || window.webkitAudioContext)();
414
+ const osc = ac.createOscillator(), gain = ac.createGain();
415
+ osc.connect(gain); gain.connect(ac.destination);
416
+ const freqs = { error:[440,330], success:[523,659], warning:[466,440], info:[587,659] };
417
+ const f = freqs[type] || [440,523];
418
+ osc.frequency.setValueAtTime(f[0], ac.currentTime);
419
+ osc.frequency.exponentialRampToValueAtTime(f[1], ac.currentTime+0.12);
420
+ osc.type = 'sine';
421
+ gain.gain.setValueAtTime(0, ac.currentTime);
422
+ gain.gain.linearRampToValueAtTime(0.055, ac.currentTime+0.01);
423
+ gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime+0.22);
424
+ osc.start(ac.currentTime); osc.stop(ac.currentTime+0.25);
425
+ } catch(e) {}
426
+ }
427
+
428
+ /* ─────────────────────────────────────────
429
+ SWIPE HANDLER
430
+ ───────────────────────────────────────── */
431
+ function attachSwipe(el, onSwipe) {
432
+ let startX=0, dragging=false, current=0;
433
+ const down = e => { const t=e.touches?e.touches[0]:e; startX=t.clientX; dragging=true; current=0; };
434
+ const move = e => {
435
+ if(!dragging)return;
436
+ const t=e.touches?e.touches[0]:e;
437
+ const dx=t.clientX-startX;
438
+ current=dx;
439
+ el.style.transform='translateX('+dx+'px)';
440
+ el.style.opacity=String(1-Math.abs(dx)/220);
441
+ };
442
+ const up = () => {
443
+ if(!dragging)return; dragging=false;
444
+ if(Math.abs(current)>80){ onSwipe(current>0?'right':'left'); }
445
+ else { el.style.transform=''; el.style.opacity=''; }
446
+ };
447
+ el.addEventListener('mousedown',down);
448
+ el.addEventListener('mousemove',move);
449
+ el.addEventListener('mouseup',up);
450
+ el.addEventListener('mouseleave',()=>{if(dragging){dragging=false;el.style.transform='';el.style.opacity='';}});
451
+ el.addEventListener('touchstart',down,{passive:true});
452
+ el.addEventListener('touchmove',move,{passive:true});
453
+ el.addEventListener('touchend',up);
454
+ }
455
+
456
+ /* ─────────────────────────────────────────
457
+ POSITION CALCULATOR
458
+ ───────────────────────────────────────── */
459
+ const CARD_W = 320;
460
+ const CARD_H_EST = 155;
461
+ const PAD = 20;
462
+ const GAP = 10;
463
+
464
+ function getCoords(position, slotIndex) {
465
+ const W = window.innerWidth, H = window.innerHeight;
466
+ const x = position.includes('right') ? W - CARD_W - PAD : PAD;
467
+ const y = position.includes('top')
468
+ ? PAD + (CARD_H_EST + GAP) * slotIndex
469
+ : H - CARD_H_EST - PAD - (CARD_H_EST + GAP) * slotIndex;
470
+ return { x, y };
471
+ }
472
+
473
+ /* ─────────────────────────────────────────
474
+ DOT PROGRESS BAR
475
+ ───────────────────────────────────────── */
476
+ function buildDotBar(cardEl, cfg, duration, onFinish) {
477
+ const bar = cardEl.querySelector('.dn-dotbar');
478
+ if (!bar) return { destroy: () => {} };
479
+ const N = 14;
480
+ let idx = 0, paused = false, elapsed = 0, tickStart = Date.now(), timer = null;
481
+ const interval = duration / N;
482
+
483
+ for (let i = 0; i < N; i++) {
484
+ const d = document.createElement('div');
485
+ d.className = 'dn-dp';
486
+ d.style.setProperty('--dn-color', cfg.color);
487
+ d.addEventListener('mouseenter', () => d.classList.add('dn-hovered'));
488
+ d.addEventListener('mouseleave', () => d.classList.remove('dn-hovered'));
489
+ bar.appendChild(d);
490
+ }
491
+ const dots = () => bar.querySelectorAll('.dn-dp');
492
+ dots()[0].classList.add('dn-active');
493
+ tickStart = Date.now();
494
+
495
+ cardEl.addEventListener('mouseenter', () => {
496
+ if (paused) return; paused = true;
497
+ elapsed += Date.now() - tickStart;
498
+ bar.classList.add('dn-paused');
499
+ clearTimeout(timer);
500
+ });
501
+ cardEl.addEventListener('mouseleave', () => {
502
+ if (!paused) return; paused = false;
503
+ bar.classList.remove('dn-paused');
504
+ tickStart = Date.now();
505
+ sched();
506
+ });
507
+
508
+ function sched() { timer = setTimeout(tick, Math.max(0, interval - elapsed)); }
509
+ function tick() {
510
+ if (paused) return;
511
+ const d = dots();
512
+ d[idx].classList.remove('dn-active'); d[idx].classList.add('dn-done');
513
+ idx++; elapsed = 0; tickStart = Date.now();
514
+ if (idx >= N) { onFinish(); return; }
515
+ d[idx].classList.add('dn-active');
516
+ sched();
517
+ }
518
+ sched();
519
+ return { destroy: () => clearTimeout(timer) };
520
+ }
521
+
522
+ /* ─────────────────────────────────────────
523
+ RING PROGRESS
524
+ ───────────────────────────────────────── */
525
+ function buildRingTimer(cardEl, cfg, duration, onFinish) {
526
+ const fill = cardEl.querySelector('.dn-ring-fill');
527
+ if (!fill) return { destroy: () => {} };
528
+ const R = 19, circ = 2*Math.PI*R;
529
+ fill.style.strokeDasharray = circ;
530
+ fill.style.strokeDashoffset = 0;
531
+ let _elapsed = 0, _startT = Date.now(), _paused = false, raf;
532
+
533
+ cardEl.addEventListener('mouseenter', () => { if (!_paused) { _paused=true; _elapsed+=Date.now()-_startT; } });
534
+ cardEl.addEventListener('mouseleave', () => { if (_paused) { _paused=false; _startT=Date.now(); } });
535
+
536
+ function tick() {
537
+ const total = _paused ? _elapsed : _elapsed + (Date.now()-_startT);
538
+ const p = Math.min(1, total / duration);
539
+ fill.style.strokeDashoffset = circ * p;
540
+ if (p >= 1) { onFinish(); return; }
541
+ raf = requestAnimationFrame(tick);
542
+ }
543
+ raf = requestAnimationFrame(tick);
544
+ return { destroy: () => cancelAnimationFrame(raf) };
545
+ }
546
+
547
+ /* ─────────────────────────────────────────
548
+ BUILD ICON HTML
549
+ ───────────────────────────────────────── */
550
+ function buildIconHtml(opts, cfg, useRing) {
551
+ // Determine icon content
552
+ let iconInner;
553
+ if (opts.image) {
554
+ // image: URL string or HTMLImageElement or File
555
+ const src = typeof opts.image === 'string' ? opts.image
556
+ : opts.image instanceof HTMLImageElement ? opts.image.src
557
+ : null;
558
+ if (src) {
559
+ iconInner = `<img src="${src}" alt="notification image" style="width:${useRing?32:40}px;height:${useRing?32:40}px;border-radius:${useRing?9:11}px;object-fit:cover">`;
560
+ } else if (opts.image instanceof File) {
561
+ // Will be set async — placeholder first
562
+ iconInner = `<span class="dn-emoji" data-file="1">📷</span>`;
563
+ } else {
564
+ iconInner = `<span class="dn-emoji">${opts.icon || '🔔'}</span>`;
565
+ }
566
+ } else if (opts.icon) {
567
+ // custom emoji or SVG string
568
+ iconInner = opts.icon.startsWith('<')
569
+ ? opts.icon
570
+ : `<span class="dn-emoji">${opts.icon}</span>`;
571
+ } else {
572
+ iconInner = cfg.icon;
573
+ }
574
+
575
+ if (useRing) {
576
+ const R = 19, circ = 2*Math.PI*R;
577
+ return `<div class="dn-ring-wrap">
578
+ <svg class="dn-ring-svg" viewBox="0 0 44 44">
579
+ <circle class="dn-ring-bg" cx="22" cy="22" r="${R}"/>
580
+ <circle class="dn-ring-fill" cx="22" cy="22" r="${R}" stroke="${cfg.color}" style="stroke-dasharray:${circ};stroke-dashoffset:0"/>
581
+ </svg>
582
+ <div class="dn-ring-inner">${iconInner}</div>
583
+ </div>`;
584
+ }
585
+
586
+ return `<div class="dn-pill" style="box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 0 18px rgba(${cfg.rgb},.22),0 2px 8px rgba(0,0,0,.25);border-color:rgba(${cfg.rgb},.35)">${iconInner}</div>`;
587
+ }
588
+
589
+ /* ─────────────────────────────────────────
590
+ CREATE CARD ELEMENT
591
+ ───────────────────────────────────────── */
592
+ function createCard(opts, cfg, position, theme, duration) {
593
+ const useRing = !!opts.progressStyle && opts.progressStyle === 'ring';
594
+ const el = document.createElement('div');
595
+ el.className = `dn-card theme-${theme}`;
596
+ if (position.includes('left')) el.classList.add('pos-left');
597
+ el.style.cssText = [
598
+ `--dn-color:${cfg.color}`,
599
+ `--dn-accent-bg:${cfg.accentBg}`,
600
+ `--dn-accent-border:${cfg.accentBorder}`,
601
+ `--dn-accent-text:${cfg.accentText}`,
602
+ ].join(';');
603
+
604
+ // thumbnail aside (separate from icon)
605
+ const thumbHtml = opts.thumbnail
606
+ ? `<img class="dn-thumb" src="${typeof opts.thumbnail==='string'?opts.thumbnail:''}" alt="thumb">`
607
+ : '';
608
+
609
+ // actions
610
+ const actHtml = (opts.actions || []).map((a, i) =>
611
+ `<button class="dn-action${i===0&&a.primary?' dn-primary':''}" data-act="${a.label}">${a.label}</button>`
612
+ ).join('');
613
+
614
+ el.innerHTML = `
615
+ <div class="dn-body">
616
+ <div class="dn-row">
617
+ ${buildIconHtml(opts, cfg, useRing)}
618
+ <div class="dn-txt">
619
+ <div class="dn-app" style="color:rgba(${cfg.rgb},.65)">${opts.app||cfg.app}</div>
620
+ <div class="dn-title">${opts.title||'Notification'}</div>
621
+ <div class="dn-msg">${opts.message||''}</div>
622
+ </div>
623
+ ${thumbHtml}
624
+ <button class="dn-close">✕</button>
625
+ </div>
626
+ </div>
627
+ ${actHtml ? `<div class="dn-actions">${actHtml}</div>` : ''}
628
+ <div class="dn-sep"></div>
629
+ <div class="dn-foot">
630
+ <span class="dn-time">just now</span>
631
+ <span class="dn-tag" style="color:${cfg.tagColor};border-color:rgba(${cfg.rgb},.18)">${opts.type||'info'}</span>
632
+ </div>
633
+ ${useRing ? '' : '<div class="dn-dotbar"></div>'}
634
+ `;
635
+
636
+ // Handle File image async
637
+ if (opts.image instanceof File) {
638
+ const reader = new FileReader();
639
+ reader.onload = e => {
640
+ const placeholder = el.querySelector('[data-file]');
641
+ if (placeholder) {
642
+ const img = document.createElement('img');
643
+ img.src = e.target.result;
644
+ img.style.cssText = 'width:40px;height:40px;border-radius:11px;object-fit:cover;position:relative;z-index:1';
645
+ placeholder.replaceWith(img);
646
+ }
647
+ };
648
+ reader.readAsDataURL(opts.image);
649
+ }
650
+
651
+ return el;
652
+ }
653
+
654
+ /* ─────────────────────────────────────────
655
+ DOTNOTIFY FACTORY
656
+ ───────────────────────────────────────── */
657
+ function DotNotify(userConfig) {
658
+ const cfg = Object.assign({}, DEFAULTS, userConfig);
659
+ injectStyles();
660
+
661
+ const cv = getOrCreateCanvas(cfg.zIndex - 1);
662
+ const ctx = cv.getContext('2d');
663
+ window.addEventListener('resize', () => { cv.width=window.innerWidth; cv.height=window.innerHeight; });
664
+
665
+ // Stage element for cards
666
+ let stage = document.getElementById('dn-stage');
667
+ if (!stage) {
668
+ stage = document.createElement('div');
669
+ stage.id = 'dn-stage';
670
+ stage.className = 'dn-stage';
671
+ stage.style.zIndex = cfg.zIndex;
672
+ document.body.appendChild(stage);
673
+ }
674
+
675
+ let stack = []; // active cards
676
+ const queue = []; // waiting
677
+ let cancelEntry = null;
678
+
679
+ function restack() {
680
+ stack.forEach((item, i) => {
681
+ const { x, y } = getCoords(cfg.position, i);
682
+ item.el.style.left = x + 'px';
683
+ item.el.style.top = y + 'px';
684
+ item.x = x; item.y = y;
685
+ });
686
+ }
687
+
688
+ function removeCard(el) {
689
+ const i = stack.findIndex(s => s.el === el);
690
+ if (i > -1) stack.splice(i, 1);
691
+ restack();
692
+ if (queue.length > 0 && stack.length < cfg.maxStack) {
693
+ const next = queue.shift();
694
+ setTimeout(() => spawnCard(next.opts, next.resolve), 120);
695
+ }
696
+ }
697
+
698
+ function spawnCard(opts, resolve) {
699
+ const typeCfg = TYPE_CONFIG[opts.type] || TYPE_CONFIG.info;
700
+ const theme = opts.theme || cfg.theme;
701
+ const duration = opts.duration || cfg.duration;
702
+ const slot = stack.length;
703
+ const { x, y } = getCoords(cfg.position, slot);
704
+ const useRing = opts.progressStyle === 'ring';
705
+
706
+ const el = createCard(opts, typeCfg, cfg.position, theme, duration);
707
+ el.style.left = x + 'px';
708
+ el.style.top = y + 'px';
709
+ el.style.zIndex = cfg.zIndex;
710
+ stage.appendChild(el);
711
+ requestAnimationFrame(() => el.classList.add('dn-show'));
712
+
713
+ if (cfg.sound || opts.sound) playSound(opts.type || 'info');
714
+
715
+ let progCtrl = null;
716
+
717
+ const killCard = (reason, action) => {
718
+ if (progCtrl) progCtrl.destroy();
719
+ el.classList.remove('dn-show');
720
+ if (reason === 'swipe-left') el.classList.add('dn-swipe-left');
721
+ else if (reason === 'swipe-right') el.classList.add('dn-swipe-right');
722
+ else el.classList.add('dn-hide');
723
+
724
+ const snapX = parseInt(el.style.left), snapY = parseInt(el.style.top);
725
+ const delay = reason.startsWith('swipe') ? 320 : 260;
726
+ setTimeout(() => {
727
+ el.remove();
728
+ removeCard(el);
729
+ // exit dots
730
+ const exitDots = makeExitDots(typeCfg, snapX, snapY, CARD_W, CARD_H_EST);
731
+ animateExit(ctx, exitDots);
732
+ resolve && resolve({ dismissed: reason, action });
733
+ }, delay);
734
+ };
735
+
736
+ // start progress after card appears
737
+ setTimeout(() => {
738
+ progCtrl = useRing
739
+ ? buildRingTimer(el, typeCfg, duration, () => killCard('timeout'))
740
+ : buildDotBar(el, typeCfg, duration, () => killCard('timeout'));
741
+ }, 450);
742
+
743
+ el.querySelector('.dn-close').addEventListener('click', () => killCard('close'));
744
+ (opts.actions || []).forEach(a => {
745
+ const btn = el.querySelector(`[data-act="${a.label}"]`);
746
+ if (btn) btn.addEventListener('click', () => {
747
+ if (a.onClick) a.onClick();
748
+ killCard('action', a.label);
749
+ });
750
+ });
751
+
752
+ attachSwipe(el, dir => killCard('swipe-' + dir));
753
+ stack.push({ el, x, y, kill: () => killCard('external') });
754
+ }
755
+
756
+ /* ── PUBLIC fire() ── returns Promise ── */
757
+ function fire(opts) {
758
+ return new Promise(resolve => {
759
+ if (stack.length >= cfg.maxStack) {
760
+ queue.push({ opts, resolve });
761
+ return;
762
+ }
763
+
764
+ if (stack.length === 0) {
765
+ // entry animation for first card
766
+ const { x, y } = getCoords(cfg.position, 0);
767
+ const typeCfg = TYPE_CONFIG[opts.type] || TYPE_CONFIG.info;
768
+ const entryDots = makeEntryDots(typeCfg, x, y, CARD_W, CARD_H_EST);
769
+ if (cancelEntry) cancelEntry();
770
+ cancelEntry = animateParticles(ctx, entryDots, 780, () => {
771
+ spawnCard(opts, resolve);
772
+ });
773
+ } else {
774
+ spawnCard(opts, resolve);
775
+ }
776
+ });
777
+ }
778
+
779
+ /* ── dismiss all ── */
780
+ function dismissAll() {
781
+ [...stack].forEach(s => s.kill());
782
+ }
783
+
784
+ /* ── update global config ── */
785
+ function configure(newCfg) {
786
+ Object.assign(cfg, newCfg);
787
+ }
788
+
789
+ return { fire, dismissAll, configure };
790
+ }
791
+
792
+ return DotNotify;
793
+ }));