@konce-pt/motion 0.0.0-stage → 0.15.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,821 @@
1
+ import { BOUNCE_GROUND, LOADER_EASE, LOADER_MARKS, LOADER_QUIET_MS, LOADER_RESULT_MS, LOADER_VIEWBOX, SHAPE_CIRCLE, SHAPE_SQUIRCLE, WAVE_WIDTH, bounceIdle, bounceResult, bounceShadow, bridgeDot, bridgePillWidth, bridgeTraveler, bubblesOrbit, bubblesSpin, clamp01, easeIn, easeInOut, easeOut, flatPath, loaderColor, loaderOnColor, loaderPalette, loaderSubtle, mix, mixRadii, ringArcPath, ringIdleArc, shapePath, shapesFrame, waveIdleSpan, wavePath, waveResultPath, } from "./loader.js";
2
+ /**
3
+ * Wkłada loader do `stage` i zaczyna grać. `content` to element z treścią komponentu (ikona
4
+ * w środku pierścienia) - moduł tylko go skaluje, nigdy nie zmienia jego dzieci.
5
+ */
6
+ export function createLoader(stage, content, options) {
7
+ return new Loader(stage, content, options);
8
+ }
9
+ /**
10
+ * Podmienia napis w `box` z przenikaniem: stary odpływa w górę i rozmywa się, nowy wchodzi z dołu.
11
+ * Pudełko musi trzymać dzieci w jednej komórce siatki (np. `.kpt-update__swap`), żeby na chwilę
12
+ * leżały na sobie. Stary napis od razu dostaje `aria-hidden`. Bez ruchu (`motion: false` albo
13
+ * systemowe "ogranicz ruch") napis zmienia się od razu.
14
+ */
15
+ export function swapText(box, text, motion = true) {
16
+ const current = currentText(box);
17
+ if (current?.textContent === text)
18
+ return;
19
+ const next = document.createElement('span');
20
+ next.textContent = text;
21
+ if (!motion || reduceMotion() || typeof next.animate !== 'function' || !current) {
22
+ box.replaceChildren(next);
23
+ return;
24
+ }
25
+ current.setAttribute('aria-hidden', 'true');
26
+ current.setAttribute('data-leaving', '');
27
+ current
28
+ .animate({ opacity: [1, 0], transform: ['none', 'translateY(-0.45em)'], filter: ['blur(0px)', 'blur(3px)'] }, { duration: 200, easing: 'cubic-bezier(.4, 0, 1, 1)', fill: 'forwards' })
29
+ .finished.then(() => current.remove(), () => current.remove());
30
+ box.appendChild(next);
31
+ next.animate({ opacity: [0, 1], transform: ['translateY(0.45em)', 'none'], filter: ['blur(3px)', 'blur(0px)'] }, { duration: 300, delay: 90, easing: 'cubic-bezier(.2, 0, 0, 1)', fill: 'backwards' });
32
+ }
33
+ /** Zmienia bieżący napis `box` bez przenikania - licznik bajtów w trakcie pobierania. */
34
+ export function setText(box, text) {
35
+ const current = currentText(box);
36
+ if (current)
37
+ current.textContent = text;
38
+ else
39
+ swapText(box, text, false);
40
+ }
41
+ function currentText(box) {
42
+ const last = box.lastElementChild;
43
+ return last instanceof HTMLElement && !last.hasAttribute('data-leaving') ? last : null;
44
+ }
45
+ let reduceQuery;
46
+ /** Systemowe "ogranicz ruch" - zapytanie tworzone raz, bo pętla pyta o nie w każdej klatce. */
47
+ function reduceMotion() {
48
+ if (reduceQuery === undefined) {
49
+ reduceQuery = typeof window !== 'undefined' && window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : null;
50
+ }
51
+ return !!reduceQuery?.matches;
52
+ }
53
+ // -- SVG --
54
+ const NS = 'http://www.w3.org/2000/svg';
55
+ let gooSeq = 0;
56
+ function svg(tag, attrs, parent, cls) {
57
+ const el = document.createElementNS(NS, tag);
58
+ for (const [k, v] of Object.entries(attrs))
59
+ el.setAttribute(k, String(v));
60
+ if (cls)
61
+ el.setAttribute('class', cls);
62
+ parent?.appendChild(el);
63
+ return el;
64
+ }
65
+ /** Przesunięcie i skala w jednostkach rysunku. */
66
+ function place(el, x, y, sx = 1, sy = sx) {
67
+ el.setAttribute('transform', `translate(${x.toFixed(2)} ${y.toFixed(2)}) scale(${sx.toFixed(3)} ${sy.toFixed(3)})`);
68
+ }
69
+ /** Wartość z listy klatek w chwili `p` (0..1), klatki rozłożone równo albo wg `offsets`. */
70
+ function keyframe(p, frames, offsets) {
71
+ const at = offsets ?? frames.map((_, i) => i / (frames.length - 1));
72
+ for (let i = 1; i < frames.length; i++) {
73
+ if (p <= at[i])
74
+ return mix(frames[i - 1], frames[i], (p - at[i - 1]) / (at[i] - at[i - 1] || 1));
75
+ }
76
+ return frames[frames.length - 1];
77
+ }
78
+ class Loader {
79
+ constructor(stage, content, options) {
80
+ this.stage = stage;
81
+ this.content = content;
82
+ /** Zegar loadera w sekundach - w pętli płynie z `speed`, w wyniku w czasie rzeczywistym. */
83
+ this.t = 0;
84
+ this.timers = [];
85
+ this.tweens = [];
86
+ this.phase = 'loading';
87
+ this.notify = true;
88
+ this.live = true;
89
+ this.frame = 0;
90
+ this.last = 0;
91
+ this.visible = true;
92
+ this.io = null;
93
+ this.step = (now) => {
94
+ this.frame = 0;
95
+ // Pierwsza klatka po pauzie liczy się jak zwykła - inaczej loader przeskoczyłby o czas postoju.
96
+ const dt = this.last ? Math.min(Math.max((now - this.last) / 1000, 0), 0.05) : 1 / 60;
97
+ this.last = now;
98
+ this.advance(dt);
99
+ if (this.visible && (this.phase !== 'settled' || this.tweens.length))
100
+ this.frame = requestAnimationFrame(this.step);
101
+ else
102
+ this.last = 0;
103
+ };
104
+ this.opts = { ...options };
105
+ if (typeof IntersectionObserver !== 'undefined') {
106
+ this.io = new IntersectionObserver((entries) => {
107
+ this.visible = entries.some((e) => e.isIntersecting);
108
+ // Wynik poza ekranem nikogo nie cieszy, a `settled` nie może czekać na przewinięcie.
109
+ if (!this.visible && this.phase === 'result')
110
+ this.fastForward();
111
+ this.wake();
112
+ });
113
+ this.io.observe(stage);
114
+ }
115
+ this.mount();
116
+ if (this.opts.status !== 'loading')
117
+ this.play(this.opts.status, false);
118
+ }
119
+ update(next) {
120
+ const prev = this.opts;
121
+ const o = (this.opts = { ...prev, ...next });
122
+ const repaint = o.variant !== prev.variant ||
123
+ o.successColor !== prev.successColor ||
124
+ o.errorColor !== prev.errorColor ||
125
+ (o.colors ?? []).join('|') !== (prev.colors ?? []).join('|');
126
+ if (repaint) {
127
+ this.mount();
128
+ if (o.status !== 'loading')
129
+ this.play(o.status, false);
130
+ return;
131
+ }
132
+ if (o.status !== prev.status) {
133
+ if (o.status === 'loading')
134
+ this.mount();
135
+ else {
136
+ if (this.phase !== 'loading')
137
+ this.mount();
138
+ this.play(o.status, true);
139
+ }
140
+ }
141
+ else if (o.successText !== prev.successText || o.errorText !== prev.errorText) {
142
+ this.scene.relabel?.();
143
+ }
144
+ this.wake();
145
+ }
146
+ destroy() {
147
+ if (this.frame)
148
+ cancelAnimationFrame(this.frame);
149
+ this.frame = 0;
150
+ this.io?.disconnect();
151
+ this.stage.replaceChildren();
152
+ if (this.content)
153
+ this.content.style.transform = '';
154
+ }
155
+ // -- dla scen --
156
+ get options() {
157
+ return this.opts;
158
+ }
159
+ /** Barwy pętli już jako wartości CSS. */
160
+ palette() {
161
+ return loaderPalette(this.opts.variant, this.opts.colors);
162
+ }
163
+ /** Pierwsza barwa w postaci podanej przez użytkownika (rola albo kolor) - do tła `-subtle`. */
164
+ firstColor() {
165
+ return this.opts.colors?.find((c) => c.trim() !== '') ?? 'primary';
166
+ }
167
+ resultColor(kind) {
168
+ return loaderColor(kind === 'success' ? this.opts.successColor : this.opts.errorColor);
169
+ }
170
+ onResultColor(kind) {
171
+ return loaderOnColor(kind === 'success' ? this.opts.successColor : this.opts.errorColor);
172
+ }
173
+ resultText(kind) {
174
+ return kind === 'success' ? this.opts.successText : this.opts.errorText;
175
+ }
176
+ hasContent() {
177
+ const c = this.content;
178
+ return !!c && (c.childElementCount > 0 || !!c.textContent?.trim());
179
+ }
180
+ /** Środek pola rysunku (tam staje znak wersji z ograniczonym ruchem). */
181
+ center() {
182
+ const [x, y, w, h] = LOADER_VIEWBOX[this.opts.variant];
183
+ return [x + w / 2, y + h / 2];
184
+ }
185
+ later(ms, fn) {
186
+ this.timers.push({ at: this.t + ms / 1000, fn });
187
+ }
188
+ tween(ms, fn, delay = 0, done) {
189
+ this.tweens.push({ start: this.t + delay / 1000, dur: Math.max(1, ms) / 1000, fn, done });
190
+ }
191
+ /** Scena skończyła ruch - zostają już tylko przejścia i timery. */
192
+ stop() {
193
+ this.live = false;
194
+ }
195
+ /** Element przejściowy (kropla, iskra, poświata) - znika, gdy wynik się skończy. */
196
+ fx(el) {
197
+ el.setAttribute('data-fx', '');
198
+ return el;
199
+ }
200
+ /** Filtr cieczy dla tej instancji - własne `id`, bo loaderów na stronie bywa kilka. */
201
+ goo() {
202
+ const id = `kpt-loader-goo-${++gooSeq}`;
203
+ const f = svg('filter', { id, x: -200, y: -200, width: 400, height: 400, filterUnits: 'userSpaceOnUse', 'color-interpolation-filters': 'sRGB' }, this.defs);
204
+ svg('feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 6, result: 'blur' }, f);
205
+ svg('feColorMatrix', { in: 'blur', mode: 'matrix', values: '1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 22 -9', result: 'goo' }, f);
206
+ svg('feComposite', { in: 'SourceGraphic', in2: 'goo', operator: 'atop' }, f);
207
+ return `url(#${id})`;
208
+ }
209
+ /** Znak (ptaszek, krzyżyk, wykrzyknik) o boku `size`, rysowany kreską od `delay`. */
210
+ mark(parent, kind, size, cx, cy, color, delay = 0, instant = false) {
211
+ const g = svg('g', { transform: `translate(${cx - size / 2} ${cy - size / 2}) scale(${size / 24})` }, parent, 'kpt-loader__mark');
212
+ g.style.color = color;
213
+ LOADER_MARKS[kind].forEach((d, i) => {
214
+ const p = svg('path', { d, pathLength: 1 }, g);
215
+ p.style.strokeDasharray = '1 2';
216
+ p.style.strokeDashoffset = instant ? '0' : '1';
217
+ if (!instant)
218
+ this.tween(340, (q) => (p.style.strokeDashoffset = String(1 - LOADER_EASE.out(q))), delay + i * 140);
219
+ });
220
+ return g;
221
+ }
222
+ /** Pierścień, który rozchodzi się i gaśnie - "udało się". */
223
+ halo(parent, color, size, cx = 0, cy = 0) {
224
+ const c = this.fx(svg('circle', { cx, cy, r: size / 2 }, parent, 'kpt-loader__halo'));
225
+ c.style.color = color;
226
+ this.tween(720, (q) => {
227
+ const e = LOADER_EASE.out(q);
228
+ const s = mix(0.9, 2.1, e);
229
+ c.setAttribute('r', String((size / 2) * s));
230
+ c.style.strokeWidth = String(2 * s);
231
+ c.style.opacity = String(0.7 * (1 - e));
232
+ }, 0, () => c.remove());
233
+ }
234
+ /** Iskry rozlatujące się z `r0` do `r1` wokół środka. */
235
+ sparks(parent, n, color, r0, r1, cx = 0, cy = 0) {
236
+ for (let i = 0; i < n; i++) {
237
+ const a = (i / n) * Math.PI * 2 + 0.4;
238
+ const s = this.fx(svg('circle', { r: 3 }, parent, 'kpt-loader__spark'));
239
+ s.style.fill = color;
240
+ this.tween(620, (q) => {
241
+ const e = LOADER_EASE.out(q);
242
+ const r = mix(r0, r1, e);
243
+ s.setAttribute('cx', (cx + Math.cos(a) * r).toFixed(2));
244
+ s.setAttribute('cy', (cy + Math.sin(a) * r).toFixed(2));
245
+ s.setAttribute('r', (3 * mix(1, 0.2, e)).toFixed(2));
246
+ s.style.opacity = String(1 - e);
247
+ }, 0, () => s.remove());
248
+ }
249
+ }
250
+ /** Krótkie, gasnące potrząśnięcie w poziomie - "nie tak". Grupa nie może mieć innego transformu. */
251
+ shake(el, amp) {
252
+ const k = [0, -1, 0.8, -0.6, 0.4, -0.2, 0];
253
+ this.tween(480, (q) => el.setAttribute('transform', `translate(${(keyframe(q, k) * amp).toFixed(2)} 0)`), 0, () => el.removeAttribute('transform'));
254
+ }
255
+ setContentTransform(value) {
256
+ if (this.content)
257
+ this.content.style.transform = value;
258
+ }
259
+ // -- cykl życia --
260
+ mount() {
261
+ this.timers = [];
262
+ this.tweens = [];
263
+ this.t = 0;
264
+ this.phase = 'loading';
265
+ this.live = true;
266
+ const variant = this.opts.variant;
267
+ const [x, y, w, h] = LOADER_VIEWBOX[variant];
268
+ this.svgEl = svg('svg', { viewBox: `${x} ${y} ${w} ${h}`, 'aria-hidden': 'true', focusable: 'false' }, null, 'kpt-loader__svg');
269
+ this.svgEl.style.setProperty('--_vw', String(w));
270
+ this.svgEl.style.setProperty('--_vh', String(h));
271
+ this.defs = svg('defs', {}, this.svgEl);
272
+ this.body = svg('g', {}, this.svgEl, 'kpt-loader__body');
273
+ this.stage.replaceChildren(this.svgEl);
274
+ this.setContentTransform('');
275
+ this.scene = SCENES[variant](this);
276
+ this.scene.tick?.(0, 0);
277
+ this.wake();
278
+ }
279
+ play(kind, notify) {
280
+ this.phase = 'result';
281
+ this.notify = notify;
282
+ const ms = this.reduced() ? (this.scene.quiet?.(kind) ?? quietResult(this, kind)) : this.scene.result(kind);
283
+ this.later(ms, () => this.settle(kind));
284
+ // Wynik bez widza (od razu przy starcie, poza ekranem, w ukrytej karcie) - od razu stan końcowy.
285
+ const hidden = typeof document !== 'undefined' && document.visibilityState === 'hidden';
286
+ if (!notify || !this.visible || hidden)
287
+ this.fastForward();
288
+ this.wake();
289
+ }
290
+ settle(kind) {
291
+ this.phase = 'settled';
292
+ this.live = false;
293
+ // Przejścia, które jeszcze trwają (np. kreska znaku), kończymy w stanie docelowym.
294
+ for (const tw of this.tweens)
295
+ tw.fn(1);
296
+ for (const tw of this.tweens)
297
+ tw.done?.();
298
+ this.tweens = [];
299
+ this.timers = [];
300
+ for (const el of this.svgEl.querySelectorAll('[data-fx]'))
301
+ el.remove();
302
+ if (this.notify)
303
+ this.opts.onSettled?.(kind);
304
+ }
305
+ fastForward() {
306
+ for (let i = 0; i < 600 && this.phase === 'result'; i++)
307
+ this.advance(1 / 60);
308
+ }
309
+ reduced() {
310
+ return this.opts.reduced || reduceMotion();
311
+ }
312
+ wake() {
313
+ if (this.frame || !this.visible || typeof requestAnimationFrame === 'undefined')
314
+ return;
315
+ if (this.phase === 'settled' && !this.tweens.length)
316
+ return;
317
+ this.frame = requestAnimationFrame(this.step);
318
+ }
319
+ advance(dt) {
320
+ const scaled = this.phase === 'loading' ? dt * Math.max(0.05, this.opts.speed || 1) * (this.reduced() ? 0.35 : 1) : dt;
321
+ this.t += scaled;
322
+ const due = this.timers.filter((tm) => tm.at <= this.t + 1e-9).sort((a, b) => a.at - b.at);
323
+ if (due.length) {
324
+ this.timers = this.timers.filter((tm) => !due.includes(tm));
325
+ for (const tm of due) {
326
+ tm.fn();
327
+ if (this.phase === 'settled')
328
+ return;
329
+ }
330
+ }
331
+ for (const tw of [...this.tweens]) {
332
+ if (this.t < tw.start)
333
+ continue;
334
+ const p = clamp01((this.t - tw.start) / tw.dur);
335
+ tw.fn(p);
336
+ if (p >= 1) {
337
+ this.tweens.splice(this.tweens.indexOf(tw), 1);
338
+ tw.done?.();
339
+ }
340
+ }
341
+ if (this.live)
342
+ this.scene.tick?.(this.t, scaled);
343
+ }
344
+ }
345
+ /** Wynik z ograniczonym ruchem: rysunek gaśnie, a w środku pojawia się znak w kółku. */
346
+ function quietResult(L, kind) {
347
+ L.stop();
348
+ const body = L.body;
349
+ L.tween(180, (q) => (body.style.opacity = String(1 - q)));
350
+ const [cx, cy] = L.center();
351
+ // Obok rysunku, nie w nim - gaszony rysunek nie może zabrać znaku ze sobą.
352
+ const badge = svg('g', {}, body.parentNode);
353
+ const dot = svg('circle', { cx, cy, r: 22 }, badge);
354
+ dot.style.fill = L.resultColor(kind);
355
+ L.mark(badge, kind === 'success' ? 'check' : 'x', 24, cx, cy, L.onResultColor(kind), 0, true);
356
+ badge.style.opacity = '0';
357
+ L.tween(200, (q) => (badge.style.opacity = String(q)), 120);
358
+ return LOADER_QUIET_MS;
359
+ }
360
+ // -- warianty --
361
+ const SCENES = {
362
+ bubbles: bubblesScene,
363
+ bridge: bridgeScene,
364
+ shapes: shapesScene,
365
+ ring: ringScene,
366
+ wave: waveScene,
367
+ bounce: bounceScene,
368
+ };
369
+ /** 1 - cztery krople krążą, zlewają się i rozpryskują; wynik: jedna kropla rośnie w znak. */
370
+ function bubblesScene(L) {
371
+ const grp = svg('g', {}, L.body);
372
+ const layer = svg('g', { filter: L.goo() }, grp);
373
+ const blobs = L.palette().map((color) => {
374
+ const b = svg('circle', { r: 14 }, layer, 'kpt-loader__blob');
375
+ b.style.fill = color;
376
+ return b;
377
+ });
378
+ let angle = 0;
379
+ let radius = 6;
380
+ let res = null;
381
+ return {
382
+ tick(t, dt) {
383
+ let r;
384
+ let scale = 1;
385
+ let rt = 0;
386
+ if (!res)
387
+ r = radius = bubblesOrbit(t);
388
+ else {
389
+ rt = t - res.t0;
390
+ r = res.r0 * (1 - easeIn(clamp01(rt / 0.4)));
391
+ scale = 1 + (res.kind === 'success' ? 1.25 : 0.9) * LOADER_EASE.wobble(clamp01((rt - 0.42) / 0.7));
392
+ }
393
+ angle += bubblesSpin(r) * (res ? Math.exp(-Math.max(0, rt - 0.4) * 2.5) : 1) * dt;
394
+ blobs.forEach((b, i) => {
395
+ const th = angle + (i * Math.PI) / 2;
396
+ const rr = r * (1 + 0.1 * Math.sin(t * 3.1 + i * 1.9));
397
+ place(b, Math.cos(th) * rr, Math.sin(th) * rr, scale * (res ? 1 : 1 + 0.12 * Math.sin(t * 4.3 + i * 1.3)));
398
+ });
399
+ },
400
+ result(kind) {
401
+ const ok = kind === 'success';
402
+ res = { kind, t0: L.t, r0: radius };
403
+ L.later(200, () => blobs.forEach((b) => (b.style.fill = L.resultColor(kind))));
404
+ L.later(760, () => L.mark(grp, ok ? 'check' : 'x', 30, 0, 0, L.onResultColor(kind)));
405
+ if (ok) {
406
+ L.later(560, () => {
407
+ // Rozprysk: krople odrywają się od dużej kropli.
408
+ for (let i = 0; i < 8; i++) {
409
+ const a = (i / 8) * Math.PI * 2 + 0.3;
410
+ const dist = 62 + (i % 3) * 8;
411
+ const d = L.fx(svg('circle', { r: 7 }, layer, 'kpt-loader__blob'));
412
+ d.style.fill = L.resultColor(kind);
413
+ L.tween(720, (q) => {
414
+ const e = LOADER_EASE.splash(q);
415
+ place(d, Math.cos(a) * dist * e, Math.sin(a) * dist * e, mix(1, 0.25, e));
416
+ }, 0, () => d.remove());
417
+ }
418
+ L.halo(grp, L.resultColor(kind), 64);
419
+ });
420
+ }
421
+ else {
422
+ L.later(820, () => L.shake(grp, 10));
423
+ L.later(1000, () => {
424
+ // Kapanie: krople zwisają, rozciągają się i odrywają w dół.
425
+ [-10, 5, 14].forEach((x, j) => {
426
+ const d = L.fx(svg('circle', { r: 7 }, layer, 'kpt-loader__blob'));
427
+ d.style.fill = L.resultColor(kind);
428
+ place(d, x, 12, 0.6);
429
+ L.tween(700, (q) => {
430
+ const e = LOADER_EASE.in(q);
431
+ const at = [0, 0.3, 1];
432
+ place(d, x, keyframe(e, [12, 26, 86], at), keyframe(e, [0.6, 1, 0.5], at), keyframe(e, [0.6, 1.3, 1.4], at));
433
+ }, j * 160, () => d.remove());
434
+ });
435
+ });
436
+ }
437
+ return LOADER_RESULT_MS.bubbles[kind];
438
+ },
439
+ };
440
+ }
441
+ /** 2 - kropla przelatuje przez rząd kropek mostkiem cieczy; wynik: kapsuła z napisem. */
442
+ function bridgeScene(L) {
443
+ const grp = svg('g', {}, L.body);
444
+ const layer = svg('g', { filter: L.goo() }, grp);
445
+ const [dotColor, travelerColor] = L.palette();
446
+ const dots = [-40, 0, 40].map((home) => {
447
+ const el = svg('circle', { r: 9 }, layer, 'kpt-loader__blob');
448
+ el.style.fill = dotColor;
449
+ return { home, el };
450
+ });
451
+ const traveler = svg('circle', { r: 11 }, layer, 'kpt-loader__blob');
452
+ traveler.style.fill = travelerColor;
453
+ let x = 0;
454
+ let res = null;
455
+ let pill = null;
456
+ const hideRow = () => {
457
+ for (const el of [...dots.map((d) => d.el), traveler])
458
+ el.style.visibility = 'hidden';
459
+ L.stop();
460
+ };
461
+ /** Kapsuła i jej treść; `instant` - od razu w rozmiarze końcowym. */
462
+ const showPill = (kind, instant) => {
463
+ const rect = svg('rect', {}, layer, 'kpt-loader__blob');
464
+ rect.style.fill = L.resultColor(kind);
465
+ const content = svg('g', {}, grp);
466
+ content.style.color = L.onResultColor(kind);
467
+ const text = svg('text', { 'font-size': 15, 'dominant-baseline': 'central' }, content, 'kpt-loader__text');
468
+ text.style.fill = 'currentColor';
469
+ text.textContent = L.resultText(kind);
470
+ const mark = L.mark(content, kind === 'success' ? 'check' : 'x', 18, 0, 0, 'currentColor', instant ? 0 : 320, instant);
471
+ pill = { rect, content, mark, text, width: 0 };
472
+ layoutPill();
473
+ const size = (w, h) => {
474
+ rect.setAttribute('x', String(-w / 2));
475
+ rect.setAttribute('y', String(-h / 2));
476
+ rect.setAttribute('width', String(Math.max(0, w)));
477
+ rect.setAttribute('height', String(h));
478
+ rect.setAttribute('rx', String(h / 2));
479
+ };
480
+ const target = pill.width;
481
+ if (instant) {
482
+ size(target, 40);
483
+ return;
484
+ }
485
+ size(22, 22);
486
+ L.tween(700, (q) => {
487
+ const e = LOADER_EASE.wobble(q);
488
+ size(mix(22, target, e), mix(22, 40, e));
489
+ });
490
+ content.style.opacity = '0';
491
+ L.tween(320, (q) => {
492
+ const e = LOADER_EASE.out(q);
493
+ content.style.opacity = String(e);
494
+ content.setAttribute('transform', `translate(0 ${(6 * (1 - e)).toFixed(2)})`);
495
+ }, 200);
496
+ };
497
+ /** Szerokość z napisu: mierzymy wyrenderowany tekst, a bez układu (ukryty loader) szacujemy. */
498
+ const layoutPill = () => {
499
+ if (!pill)
500
+ return;
501
+ let measured = 0;
502
+ try {
503
+ measured = pill.text.getComputedTextLength();
504
+ }
505
+ catch {
506
+ // Firefox rzuca dla tekstu bez układu (`display: none` u przodka) - zostaje szacunek.
507
+ }
508
+ const textWidth = measured > 0 ? measured : (pill.text.textContent?.length ?? 0) * 8.4;
509
+ const width = bridgePillWidth(textWidth);
510
+ const left = -(width - 34) / 2;
511
+ pill.mark.setAttribute('transform', `translate(${left} -9) scale(0.75)`);
512
+ pill.text.setAttribute('x', String(left + 18 + 6.4));
513
+ pill.width = width;
514
+ };
515
+ return {
516
+ tick(t) {
517
+ let sx = 1;
518
+ let sy = 1;
519
+ let gather = 1;
520
+ if (!res)
521
+ ({ x, sx, sy } = bridgeTraveler(t));
522
+ else {
523
+ const p = easeInOut(clamp01((t - res.t0) / 0.38));
524
+ x = res.x0 * (1 - p);
525
+ gather = 1 - p;
526
+ }
527
+ place(traveler, x, 0, sx, sy);
528
+ for (const d of dots) {
529
+ const f = bridgeDot(d.home, x, gather);
530
+ place(d.el, f.x, 0, f.scale);
531
+ }
532
+ },
533
+ result(kind) {
534
+ res = { kind, t0: L.t, x0: x };
535
+ L.later(330, () => showPill(kind, false));
536
+ L.later(460, hideRow);
537
+ if (kind === 'error')
538
+ L.later(950, () => L.shake(grp, 9));
539
+ return LOADER_RESULT_MS.bridge[kind];
540
+ },
541
+ quiet(kind) {
542
+ res = { kind, t0: L.t, x0: x };
543
+ hideRow();
544
+ showPill(kind, true);
545
+ return LOADER_QUIET_MS;
546
+ },
547
+ relabel() {
548
+ if (!pill || !res)
549
+ return;
550
+ pill.text.textContent = L.resultText(res.kind);
551
+ layoutPill();
552
+ const w = pill.width;
553
+ pill.rect.setAttribute('x', String(-w / 2));
554
+ pill.rect.setAttribute('width', String(w));
555
+ },
556
+ };
557
+ }
558
+ /** 3 - kształt przechodzi płynnie w następny i obraca się sprężyście; wynik: koło albo kwadrat. */
559
+ function shapesScene(L) {
560
+ const bg = svg('circle', { r: 46 }, L.body, 'kpt-loader__shape-bg');
561
+ bg.style.fill = loaderSubtle(L.firstColor());
562
+ const path = svg('path', {}, L.body, 'kpt-loader__blob');
563
+ path.style.fill = L.palette()[0];
564
+ let radii = [];
565
+ let rotation = 0;
566
+ let res = null;
567
+ return {
568
+ tick(t) {
569
+ let scale;
570
+ let r;
571
+ let rot;
572
+ if (!res) {
573
+ const f = shapesFrame(t);
574
+ r = radii = f.radii;
575
+ rot = rotation = f.rotation;
576
+ scale = f.scale;
577
+ }
578
+ else {
579
+ const rt = t - res.t0;
580
+ const ok = res.kind === 'success';
581
+ r = mixRadii(res.r0, ok ? SHAPE_CIRCLE : SHAPE_SQUIRCLE, LOADER_EASE.wobble(clamp01(rt / 0.7)));
582
+ rot = mix(res.rot0, res.rot1, easeOut(clamp01(rt / 0.8)));
583
+ if (!ok && rt > 0.35)
584
+ rot += 0.22 * Math.sin((rt - 0.35) * 26) * Math.exp(-(rt - 0.35) * 3.5);
585
+ scale = 30 * (1 + (ok ? 0.32 : 0.14) * LOADER_EASE.wobble(clamp01((rt - 0.12) / 0.7)));
586
+ }
587
+ path.setAttribute('d', shapePath(r, rot, scale));
588
+ },
589
+ result(kind) {
590
+ const quarter = Math.PI / 2;
591
+ res = { kind, t0: L.t, r0: radii, rot0: rotation, rot1: Math.ceil((rotation + 1.2) / quarter) * quarter + quarter / 2 };
592
+ L.later(260, () => {
593
+ path.style.fill = L.resultColor(kind);
594
+ L.tween(480, (q) => {
595
+ const e = LOADER_EASE.out(q);
596
+ bg.style.opacity = String(1 - e);
597
+ bg.setAttribute('r', String(46 * mix(1, 1.3, e)));
598
+ });
599
+ });
600
+ L.later(600, () => {
601
+ L.mark(L.body, kind === 'success' ? 'check' : 'x', 34, 0, 0, L.onResultColor(kind));
602
+ if (kind === 'success')
603
+ L.halo(L.body, L.resultColor(kind), 80);
604
+ });
605
+ return LOADER_RESULT_MS.shapes[kind];
606
+ },
607
+ };
608
+ }
609
+ /**
610
+ * 4 - pierścień z falą. Z treścią (ikona aplikacji) ikona kurczy się w środku, a wynik ją
611
+ * wypuszcza; bez treści pierścień sam domyka się albo zwija i rysuje znak w środku.
612
+ */
613
+ function ringScene(L) {
614
+ const grp = svg('g', {}, L.body);
615
+ const ring = svg('g', {}, grp);
616
+ const track = svg('path', {}, ring, 'kpt-loader__track');
617
+ const arc = svg('path', {}, ring, 'kpt-loader__arc');
618
+ arc.style.stroke = L.palette()[0];
619
+ const withContent = L.hasContent();
620
+ const iconScale = (s, extra = '') => L.setContentTransform(`scale(${s.toFixed(3)})${extra}`);
621
+ if (withContent)
622
+ iconScale(0.7);
623
+ let det = null;
624
+ let shown = 0;
625
+ let a0 = 0;
626
+ let a1 = 0;
627
+ let res = null;
628
+ return {
629
+ tick(t, dt) {
630
+ let amp = 2.2;
631
+ if (!res) {
632
+ ({ a0, a1 } = ringIdleArc(t));
633
+ const target = L.options.progress;
634
+ if (target != null) {
635
+ // Z nieokreślonego do postępu: łuk dojeżdża do "dwunastej", a wartość goni cel wygładzona.
636
+ det ??= { t0: t, base: -90 + 360 * Math.ceil((a0 + 90) / 360) };
637
+ shown += (target - shown) * (1 - Math.exp(-dt * 8));
638
+ const kb = easeInOut(clamp01((t - det.t0) / 0.5));
639
+ a0 = mix(a0, det.base, kb);
640
+ a1 = mix(a1, det.base + Math.max(4, 360 * shown), kb);
641
+ }
642
+ else if (det) {
643
+ det = null;
644
+ shown = 0;
645
+ }
646
+ }
647
+ else {
648
+ const rt = t - res.t0;
649
+ amp = 2.2 * (1 - easeOut(clamp01(rt / 0.35)));
650
+ a0 = res.base;
651
+ a1 = res.kind === 'success' ? mix(res.from, a0 + 360, easeOut(clamp01(rt / 0.35))) : mix(res.from, a0, easeIn(clamp01(rt / 0.45)));
652
+ }
653
+ arc.setAttribute('d', ringArcPath(a0, a1, amp, t * 5));
654
+ const gap = a1 - a0 < 0.5 ? 0 : 14;
655
+ track.setAttribute('d', a1 - a0 > 340 ? '' : ringArcPath(a1 + gap, a0 + 360 - gap - (gap ? 0 : 0.1), 0, 0));
656
+ },
657
+ result(kind) {
658
+ const ok = kind === 'success';
659
+ res = { kind, t0: L.t, base: a0, from: a1 };
660
+ if (withContent) {
661
+ if (ok) {
662
+ L.later(350, () => {
663
+ L.tween(380, (q) => {
664
+ const e = LOADER_EASE.out(q);
665
+ ring.style.opacity = String(1 - e);
666
+ ring.setAttribute('transform', `scale(${mix(1, 1.14, e).toFixed(3)})`);
667
+ });
668
+ L.tween(700, (q) => iconScale(mix(0.7, 1, LOADER_EASE.wobble(q))));
669
+ });
670
+ L.later(520, () => L.sparks(grp, 6, L.resultColor(kind), 34, 54));
671
+ L.later(800, () => L.stop());
672
+ }
673
+ else {
674
+ L.later(200, () => track.style.setProperty('stroke', 'var(--kpt-color-danger-border)'));
675
+ L.later(450, () => L.tween(480, (q) => iconScale(0.7, ` rotate(${keyframe(q, [0, -9, 8, -5, 3, 0]).toFixed(2)}deg)`)));
676
+ L.later(520, () => badge(kind, false));
677
+ L.later(1000, () => L.stop());
678
+ }
679
+ }
680
+ else if (ok) {
681
+ L.later(250, () => (arc.style.stroke = L.resultColor(kind)));
682
+ L.later(420, () => {
683
+ L.mark(grp, 'check', 40, 0, 0, L.resultColor(kind));
684
+ L.halo(grp, L.resultColor(kind), 70);
685
+ });
686
+ L.later(800, () => L.stop());
687
+ }
688
+ else {
689
+ L.later(200, () => {
690
+ track.style.setProperty('stroke', 'var(--kpt-color-danger-border)');
691
+ arc.style.stroke = L.resultColor(kind);
692
+ });
693
+ L.later(480, () => L.mark(grp, 'x', 30, 0, 0, L.resultColor(kind)));
694
+ L.later(600, () => L.shake(grp, 8));
695
+ L.later(1000, () => L.stop());
696
+ }
697
+ return LOADER_RESULT_MS.ring[kind];
698
+ },
699
+ quiet(kind) {
700
+ if (!withContent)
701
+ return quietResult(L, kind);
702
+ L.stop();
703
+ ring.style.opacity = '0';
704
+ iconScale(1);
705
+ if (kind === 'error')
706
+ badge(kind, true);
707
+ return LOADER_QUIET_MS;
708
+ },
709
+ };
710
+ /** Znaczek błędu na rogu ikony - wyskakuje na sprężynie. */
711
+ function badge(kind, instant) {
712
+ const g = svg('g', {}, grp);
713
+ const dot = svg('circle', { r: 11 }, g);
714
+ dot.style.fill = L.resultColor(kind);
715
+ L.mark(g, 'alert', 16, 0, 0, L.onResultColor(kind), 160, instant);
716
+ if (instant) {
717
+ place(g, 33, -33);
718
+ return;
719
+ }
720
+ place(g, 33, -33, 0.2);
721
+ g.style.opacity = '0';
722
+ L.tween(560, (q) => place(g, 33, -33, mix(0.2, 1, LOADER_EASE.pop(q))));
723
+ L.tween(160, (q) => (g.style.opacity = String(q)));
724
+ }
725
+ }
726
+ /** 6 - pasek postępu jako fala; wynik: fala wygładza się, kurczy w kreskę i zgina w znak. */
727
+ function waveScene(L) {
728
+ const grp = svg('g', {}, L.body);
729
+ const track = svg('path', {}, grp, 'kpt-loader__track kpt-loader__line');
730
+ const act = svg('path', {}, grp, 'kpt-loader__line');
731
+ act.style.stroke = L.palette()[0];
732
+ let det = null;
733
+ let shown = 0;
734
+ let x0 = 0;
735
+ let x1 = 0;
736
+ let res = null;
737
+ return {
738
+ tick(t, dt) {
739
+ const phase = t * 7;
740
+ if (res) {
741
+ const r = waveResultPath(res.kind, t - res.t0, res.x0, res.x1, phase);
742
+ act.setAttribute('d', r.d);
743
+ if (r.mark)
744
+ act.style.strokeWidth = '5';
745
+ return;
746
+ }
747
+ ({ x0, x1 } = waveIdleSpan(t));
748
+ const target = L.options.progress;
749
+ if (target != null) {
750
+ det ??= { t0: t };
751
+ shown += (target - shown) * (1 - Math.exp(-dt * 8));
752
+ const kb = easeInOut(clamp01((t - det.t0) / 0.4));
753
+ x0 = mix(x0, 0, kb);
754
+ x1 = mix(x1, WAVE_WIDTH * shown, kb);
755
+ }
756
+ else if (det) {
757
+ det = null;
758
+ shown = 0;
759
+ }
760
+ x0 = Math.min(Math.max(x0, 0), WAVE_WIDTH);
761
+ x1 = Math.min(Math.max(x1, 0), WAVE_WIDTH);
762
+ act.setAttribute('d', wavePath(x0, x1, 3, phase));
763
+ track.setAttribute('d', flatPath(0, x0 - 8) + flatPath(x1 + 8, WAVE_WIDTH));
764
+ },
765
+ result(kind) {
766
+ const ok = kind === 'success';
767
+ res = { kind, t0: L.t, x0, x1 };
768
+ L.later(ok ? 300 : 60, () => (act.style.stroke = L.resultColor(kind)));
769
+ L.later(250, () => L.tween(250, (q) => (track.style.opacity = String(1 - q))));
770
+ if (!ok)
771
+ L.later(780, () => L.shake(grp, 8));
772
+ L.later(1450, () => L.stop());
773
+ return LOADER_RESULT_MS.wave[kind];
774
+ },
775
+ };
776
+ }
777
+ /** 8 - trzy bąbelki skaczą z cieniem; wynik: wspólny skok i lądowanie znakiem albo kałuża. */
778
+ function bounceScene(L) {
779
+ const grp = svg('g', {}, L.body);
780
+ const balls = L.palette().map((color, i) => {
781
+ const shadow = svg('ellipse', { rx: 11, ry: 3 }, grp, 'kpt-loader__shadow');
782
+ const el = svg('g', {}, grp);
783
+ // Kula stoi na początku układu: skala od dołu, więc przysiad nie odrywa jej od ziemi.
784
+ const body = svg('circle', { cy: -10, r: 10 }, el, 'kpt-loader__blob');
785
+ body.style.fill = color;
786
+ return { home: (i - 1) * 30, h: 0, from: { x: 0, h: 0 }, shadow, el, body };
787
+ });
788
+ let res = null;
789
+ const put = (b, f) => {
790
+ place(b.el, f.x, BOUNCE_GROUND - f.h, f.sx * f.k, f.sy * f.k);
791
+ const s = bounceShadow(f);
792
+ place(b.shadow, f.x, BOUNCE_GROUND, s.scale);
793
+ b.shadow.style.opacity = s.opacity.toFixed(3);
794
+ b.el.style.visibility = f.k <= 0 ? 'hidden' : '';
795
+ };
796
+ return {
797
+ tick(t) {
798
+ balls.forEach((b, i) => {
799
+ if (!res) {
800
+ const f = bounceIdle(t, i, b.home);
801
+ b.h = f.h;
802
+ put(b, f);
803
+ }
804
+ else
805
+ put(b, bounceResult(res.kind, t - res.t0, i, b.from));
806
+ });
807
+ },
808
+ result(kind) {
809
+ const ok = kind === 'success';
810
+ for (const b of balls)
811
+ b.from = { x: b.home, h: b.h };
812
+ res = { kind, t0: L.t };
813
+ L.later(ok ? 300 : 250, () => balls.forEach((b) => (b.body.style.fill = L.resultColor(kind))));
814
+ L.later(ok ? 1000 : 1150, () => L.mark(grp, ok ? 'check' : 'x', ok ? 28 : 20, 0, BOUNCE_GROUND - (ok ? 26 : 17), L.onResultColor(kind)));
815
+ if (ok)
816
+ L.later(870, () => L.sparks(grp, 6, L.resultColor(kind), 30, 52, 0, BOUNCE_GROUND - 26));
817
+ L.later(1500, () => L.stop());
818
+ return LOADER_RESULT_MS.bounce[kind];
819
+ },
820
+ };
821
+ }