@klodd/ds 1.2.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,287 @@
1
+ /* Hero Roll Animation - delad komponent
2
+ * Krav: .hero-amount[data-animate-roll] i HTML
3
+ * Krav: font-variant-numeric: tabular-nums pa elementet
4
+ * Krav: --lh-tight definierad i CSS (anvands som rullhojd)
5
+ * Turbo: triggar via turbo:swap-event om Turbo finns, annars fresh-session only
6
+ *
7
+ * Delad mellan Jubb och Ekonom (extraherad fran Ekonom 2026-05-07).
8
+ * Filen ar oforandrad fran Ekonom-originalet - portabel utan wrapper.
9
+ */
10
+
11
+ /*--------------------------------------------------------------
12
+ Sprint J + J.4 + J.5: Hero-siffra rullnings-animation pa /avstamning.
13
+
14
+ Inspirerat av kodlas-rullning (parallella siffror som rullar med
15
+ olika sluttider). Triggers:
16
+ 1. **Fresh session** (J.5): kallstart-detektering via sessionStorage-
17
+ marker. Animation kor vid forsta page-load efter att PWA/tab
18
+ stangts helt. Sprint D3:s `body[data-first-load]` (cookie-baserad)
19
+ fungerar bara for helt nya anvandare; J.5-trigger fungerar for
20
+ return-anvandare som har `ekonom_seen`-cookie satt sedan tidigare.
21
+ 2. Manadsbyte via < / > i manads-pillen pa /avstamning
22
+
23
+ Sprint J.4 (CE-rapport: 000 kr syns lite val lange + hela processen
24
+ ar lang):
25
+ - Turbo-nav.js skip:ar VT vid month-change. Original DOM byts
26
+ omedelbart till rollers - ingen 160ms VT-fade-paus. Animation
27
+ borjar pa samma frame som DOM-swap.
28
+ - Korta animation-durations: BASE 800ms -> 500ms, STEP 200ms ->
29
+ 100ms. Total animation 500/600/700ms istallet for 800/1000/1200ms.
30
+ - Riv ut J.3:s tva-fas-arkitektur (BUILD/ANIMATE separation). Allt
31
+ sker synchron inom buildAndAnimate.
32
+ - Behaller force-reflow-pattern (`void el.offsetHeight`) sa browser
33
+ registrerar initial state innan transition triggas.
34
+
35
+ Total perceived tid (klick till slutvarde): ~720ms vid month-change
36
+ (network ~50ms + animation 700ms). Tidigare: ~1400ms.
37
+
38
+ Bredd-glitch-fix (J.3 oforandrat):
39
+ Default `<p>`-block-flow med inline-block rollers. Content-driven
40
+ width via tabular-nums - identisk packning som server-text.
41
+ --------------------------------------------------------------*/
42
+ ( function () {
43
+ 'use strict';
44
+
45
+ // Sprint J.4: kortare durations. 500/600/700ms for tre siffror.
46
+ const ROLLER_DURATION_BASE = 500; // var 800ms
47
+ const ROLLER_DURATION_STEP = 100; // var 200ms
48
+ const EXTRA_ROUNDS = 3;
49
+ // Sprint J.6: linear istallet for cubic-bezier(0.22, 1, 0.36, 1)
50
+ // (ease-out-quart). Quart-easing packar 95%+ av rorelsen i forsta
51
+ // halvan av tiden - sista 10% av tiden tacker bara 0.01% av rorelse,
52
+ // vilket gor sista rollers slutfas visuellt ororlig ("byts ut" istallet
53
+ // for "rullar"). Linear ger konstant hastighet sa alla siffror rullar
54
+ // synligt fran start till slut. Aven mer mekaniskt-korrekt - faktiska
55
+ // kodlas rullar med moment-konstant rotation.
56
+ const EASING = 'linear';
57
+
58
+ // Sprint J.5: sessionStorage-marker for fresh-session-detektering.
59
+ // sessionStorage rensas vid PWA-stangning/tab-close, persisterar
60
+ // mellan reloads/nav inom samma session.
61
+ // Delad sessionStorage-key for hero-roll. Bytt fran ekonom_hero_session
62
+ // 2026-05-08 nar komponenten porterades tillbaka till Ekonom som shared-
63
+ // system. Befintliga session-markers fran gamla nyckeln tappar persist
64
+ // engang per anvandare - ny markering satts vid nasta kallstart.
65
+ const SESSION_KEY = 'ds_hero_session';
66
+
67
+ let hasRunInitial = false;
68
+ let lastSeenUrl = window.location.href;
69
+
70
+ function getHeroEl () {
71
+ return document.querySelector( '.hero-amount[data-animate-roll]' );
72
+ }
73
+
74
+ function prefersReducedMotion () {
75
+ return window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
76
+ }
77
+
78
+ function buildRoller ( targetDigit ) {
79
+ const roller = document.createElement( 'span' );
80
+ roller.className = 'hero-digit-roller';
81
+
82
+ const track = document.createElement( 'span' );
83
+ track.className = 'hero-digit-track';
84
+
85
+ const totalSteps = targetDigit + ( EXTRA_ROUNDS * 10 );
86
+ for ( let i = 0; i <= totalSteps; i++ ) {
87
+ const span = document.createElement( 'span' );
88
+ span.textContent = String( i % 10 );
89
+ track.appendChild( span );
90
+ }
91
+
92
+ roller.appendChild( track );
93
+ return { roller: roller, track: track, steps: totalSteps };
94
+ }
95
+
96
+ function buildStaticChar ( text ) {
97
+ const span = document.createElement( 'span' );
98
+ span.className = 'hero-static-char';
99
+ span.textContent = text;
100
+ return span;
101
+ }
102
+
103
+ /*--------------------------------------------------------------
104
+ Bygg digit-rollers + starta transition synchron i samma frame.
105
+ Idempotent via data-rolled-attribut.
106
+ --------------------------------------------------------------*/
107
+ function buildAndAnimate ( el ) {
108
+ if ( ! el ) return;
109
+ if ( el.dataset.rolled === 'true' ) return;
110
+ const target = parseInt( el.dataset.animateRoll, 10 );
111
+ if ( isNaN( target ) ) return;
112
+
113
+ // No-op for 0
114
+ if ( target === 0 ) {
115
+ el.dataset.rolled = 'true';
116
+ return;
117
+ }
118
+
119
+ el.dataset.rolled = 'true';
120
+
121
+ const originalText = el.textContent;
122
+ const isNegative = target < 0;
123
+ const absTarget = Math.abs( target );
124
+ const digits = String( absTarget ).split( '' ).map( Number );
125
+ const len = digits.length;
126
+
127
+ const fragment = document.createDocumentFragment();
128
+
129
+ if ( isNegative ) {
130
+ fragment.appendChild( buildStaticChar( '−' ) );
131
+ }
132
+
133
+ const rollers = [];
134
+ digits.forEach( function ( d, i ) {
135
+ const r = buildRoller( d );
136
+ fragment.appendChild( r.roller );
137
+ rollers.push( r );
138
+ const fromRight = len - 1 - i;
139
+ if ( fromRight > 0 && fromRight % 3 === 0 ) {
140
+ fragment.appendChild( buildStaticChar( ' ' ) );
141
+ }
142
+ } );
143
+
144
+ fragment.appendChild( buildStaticChar( ' kr' ) );
145
+
146
+ // Mat font-line-height for digit-roller-hojd.
147
+ const computed = window.getComputedStyle( el );
148
+ const fontSize = parseFloat( computed.fontSize );
149
+ const lineHeightRaw = computed.lineHeight;
150
+ let lineHeight;
151
+ if ( lineHeightRaw === 'normal' ) {
152
+ lineHeight = fontSize * 1.05;
153
+ } else {
154
+ lineHeight = parseFloat( lineHeightRaw );
155
+ }
156
+ el.style.setProperty( '--hero-digit-height', lineHeight + 'px' );
157
+
158
+ // Replace content
159
+ el.innerHTML = '';
160
+ el.appendChild( fragment );
161
+
162
+ // Reduced-motion: hopppa direkt till slutposition utan transition.
163
+ if ( prefersReducedMotion() ) {
164
+ rollers.forEach( function ( r ) {
165
+ r.track.style.transform =
166
+ 'translateY(calc(-1 * var(--hero-digit-height) * ' + r.steps + '))';
167
+ } );
168
+ requestAnimationFrame( function () {
169
+ el.textContent = originalText;
170
+ } );
171
+ return;
172
+ }
173
+
174
+ // Sprint J.4: force reflow sa browser registrerar initial state
175
+ // (translateY default 0) innan transition triggas. Utan reflow
176
+ // hoppar browser direkt till slut-state utan animation.
177
+ void el.offsetHeight;
178
+
179
+ // Trigga transition synchron i samma frame.
180
+ rollers.forEach( function ( r, i ) {
181
+ const duration = ROLLER_DURATION_BASE + ( i * ROLLER_DURATION_STEP );
182
+ r.track.style.transition =
183
+ 'transform ' + duration + 'ms ' + EASING;
184
+ r.track.style.transform =
185
+ 'translateY(calc(-1 * var(--hero-digit-height) * ' + r.steps + '))';
186
+ } );
187
+
188
+ // Vid done: restora till server-text for klean DOM-state.
189
+ const totalDuration = ROLLER_DURATION_BASE + ( ( rollers.length - 1 ) * ROLLER_DURATION_STEP );
190
+ setTimeout( function () {
191
+ el.textContent = originalText;
192
+ }, totalDuration + 60 );
193
+ }
194
+
195
+ /*--------------------------------------------------------------
196
+ Trigger-detektering.
197
+ --------------------------------------------------------------*/
198
+ function isMonthChange ( fromUrl, toUrl ) {
199
+ if ( ! fromUrl ) return false;
200
+ try {
201
+ const f = new URL( fromUrl );
202
+ const t = new URL( toUrl );
203
+ if ( f.pathname !== '/avstamning' || t.pathname !== '/avstamning' ) return false;
204
+ const fromMonth = f.searchParams.get( 'month' );
205
+ const toMonth = t.searchParams.get( 'month' );
206
+ return fromMonth !== toMonth;
207
+ } catch ( _ ) {
208
+ return false;
209
+ }
210
+ }
211
+
212
+ /*--------------------------------------------------------------
213
+ Sprint J.5: trigger animation vid fresh session (kallstart fran
214
+ stangd PWA/tab). sessionStorage-marker satts forsta gang per
215
+ session - om saknad ar det en ny session.
216
+
217
+ Behaller `data-first-load`-trigger som fallback for browsers utan
218
+ sessionStorage-stod (extremt sallsynt) och for konsistens med
219
+ Sprint D3 splash-animations.
220
+ --------------------------------------------------------------*/
221
+ function tryAnimateInitial () {
222
+ if ( hasRunInitial ) return;
223
+
224
+ // Fresh-session-detektering via sessionStorage.
225
+ let isFreshSession = false;
226
+ try {
227
+ if ( ! sessionStorage.getItem( SESSION_KEY ) ) {
228
+ isFreshSession = true;
229
+ sessionStorage.setItem( SESSION_KEY, '1' );
230
+ }
231
+ } catch ( _ ) {
232
+ // sessionStorage kan kasta i restricted contexts (extremt
233
+ // sallsynt). Falla tillbaka till data-first-load.
234
+ }
235
+
236
+ const isFirstEver = document.body.dataset.firstLoad === 'true';
237
+
238
+ if ( ! isFreshSession && ! isFirstEver ) return;
239
+
240
+ const el = getHeroEl();
241
+ if ( ! el ) return;
242
+ hasRunInitial = true;
243
+ buildAndAnimate( el );
244
+ }
245
+
246
+ /*--------------------------------------------------------------
247
+ First-load via DOMContentLoaded.
248
+ --------------------------------------------------------------*/
249
+ if ( document.readyState === 'loading' ) {
250
+ document.addEventListener( 'DOMContentLoaded', tryAnimateInitial );
251
+ } else {
252
+ tryAnimateInitial();
253
+ }
254
+
255
+ /*--------------------------------------------------------------
256
+ Sprint J.4: month-change via turbo:swap (synchron inom turbo-nav.js
257
+ swap-kornplats). Eftersom turbo-nav.js skip:ar VT vid month-change,
258
+ ar swap synchron och original DOM ar synlig direkt - animation
259
+ borjar pa samma frame som DOM-byte.
260
+ --------------------------------------------------------------*/
261
+ document.addEventListener( 'turbo:swap', function ( e ) {
262
+ const detail = e.detail || {};
263
+ const newUrl = detail.url || window.location.href;
264
+
265
+ if ( ! isMonthChange( lastSeenUrl, newUrl ) ) {
266
+ lastSeenUrl = newUrl;
267
+ return;
268
+ }
269
+
270
+ const el = getHeroEl();
271
+ if ( ! el ) {
272
+ lastSeenUrl = newUrl;
273
+ return;
274
+ }
275
+
276
+ buildAndAnimate( el );
277
+ lastSeenUrl = newUrl;
278
+ } );
279
+
280
+ /*--------------------------------------------------------------
281
+ Fallback: turbo:navigated (efter VT-end) for first-load om
282
+ DOMContentLoaded inte hunnit.
283
+ --------------------------------------------------------------*/
284
+ document.addEventListener( 'turbo:navigated', function () {
285
+ tryAnimateInitial();
286
+ } );
287
+ } )();
package/js/index.js ADDED
@@ -0,0 +1,95 @@
1
+ /* @klodd/ds - JavaScript entry point.
2
+ *
3
+ * Convenience-aggregator som kor enskilda init-funktioner. Default
4
+ * fungerar utan konfiguration (LLM-vanligt). Konfig-objektet kan
5
+ * over-rida vad som ska aktiveras.
6
+ *
7
+ * Anvandning:
8
+ *
9
+ * <script src="https://unpkg.com/@klodd/ds/js/index.js" defer></script>
10
+ * <script>
11
+ * window.addEventListener('DOMContentLoaded', () => {
12
+ * KloddDS.init();
13
+ * });
14
+ * </script>
15
+ *
16
+ * Eller med konfig:
17
+ *
18
+ * KloddDS.init({
19
+ * swPath: '/service-worker.js',
20
+ * lucide: true,
21
+ * barStyles: true,
22
+ * heroRoll: true,
23
+ * pullToRefresh: false,
24
+ * turboNav: false,
25
+ * navOptimistic: true,
26
+ * sheetDrag: true,
27
+ * pwaRegister: false, // satt true + swPath for SW + install-prompts
28
+ * });
29
+ *
30
+ * Individuella moduler kan ocksa importeras separat fran js/-mappen.
31
+ * KloddDS.init() ar bara en convenience-wrapper - den inkluderar inte
32
+ * filerna sjalv, dvs varje modul-fil maste laddas via <script>-tag.
33
+ *
34
+ * Defaults ar konservativa: heavy beteenden (PtR, Turbo Drive, PWA
35
+ * register) ar OFF default. App-mantainern aktiverar opt-in.
36
+ */
37
+ 'use strict';
38
+
39
+ (function (root) {
40
+ const defaults = {
41
+ lucide: true,
42
+ barStyles: true,
43
+ heroRoll: true,
44
+ navOptimistic: true,
45
+ sheetDrag: true,
46
+ pullToRefresh: false,
47
+ turboNav: false,
48
+ pwaRegister: false,
49
+ swPath: '/static/sw.js',
50
+ };
51
+
52
+ const KloddDS = {
53
+ init(config) {
54
+ const cfg = Object.assign({}, defaults, config || {});
55
+
56
+ if (cfg.lucide && typeof root.initLucide === 'function') {
57
+ root.initLucide();
58
+ } else if (cfg.lucide && root.lucide && typeof root.lucide.createIcons === 'function') {
59
+ root.lucide.createIcons();
60
+ }
61
+
62
+ if (cfg.barStyles && typeof root.initBarStyles === 'function') {
63
+ root.initBarStyles();
64
+ }
65
+
66
+ if (cfg.heroRoll && typeof root.initHeroRoll === 'function') {
67
+ root.initHeroRoll();
68
+ }
69
+
70
+ if (cfg.navOptimistic && typeof root.initNavOptimistic === 'function') {
71
+ root.initNavOptimistic();
72
+ }
73
+
74
+ if (cfg.sheetDrag && typeof root.initSheetDrag === 'function') {
75
+ root.initSheetDrag();
76
+ }
77
+
78
+ if (cfg.pullToRefresh && typeof root.initPullToRefresh === 'function') {
79
+ root.initPullToRefresh();
80
+ }
81
+
82
+ if (cfg.turboNav && typeof root.initTurboNav === 'function') {
83
+ root.initTurboNav();
84
+ }
85
+
86
+ if (cfg.pwaRegister && typeof root.initPwaRegister === 'function') {
87
+ root.initPwaRegister({ swPath: cfg.swPath });
88
+ }
89
+ },
90
+
91
+ version: '2.0.0',
92
+ };
93
+
94
+ root.KloddDS = KloddDS;
95
+ })(typeof window !== 'undefined' ? window : globalThis);
@@ -0,0 +1,27 @@
1
+ /* Lucide-init: scannar DOM efter <i data-lucide="..."> och ersatter
2
+ * med <svg>-element. Kors vid DOMContentLoaded plus efter Turbo-nav-
3
+ * swap (om Turbo finns) sa nya element pa nyladdade pages aktiveras.
4
+ *
5
+ * Lucide CDN-script laddas separat via cdn.jsdelivr.net (allowed i
6
+ * Jubbs CSP). Detta init-script ar lokalt sa det forklaras inte av
7
+ * inline-script-restrict.
8
+ */
9
+ (function () {
10
+ 'use strict';
11
+
12
+ function createIcons() {
13
+ if (typeof window.lucide !== 'undefined' && window.lucide.createIcons) {
14
+ window.lucide.createIcons();
15
+ }
16
+ }
17
+
18
+ if (document.readyState === 'loading') {
19
+ document.addEventListener('DOMContentLoaded', createIcons);
20
+ } else {
21
+ createIcons();
22
+ }
23
+
24
+ // Re-init efter Turbo-swap sa nya ikoner pa swapped DOM aktiveras.
25
+ document.addEventListener('turbo:swap', createIcons);
26
+ document.addEventListener('turbo:navigated', createIcons);
27
+ })();
@@ -0,0 +1,74 @@
1
+ /*--------------------------------------------------------------
2
+ Sprint F1 + G4 + I: optimistic active-flytt pa bottom-nav.
3
+
4
+ Problem (CE 2026-04-27): tap pa bottom-nav-pod tar ~1s innan
5
+ podden blir visuellt aktiv. Anvandaren ser inaktiv pod tills
6
+ hela nya sidan laddats. Native-appar uppdaterar tab-bar
7
+ omedelbart vid tap, oberoende av content-render.
8
+
9
+ Losning: vid click pa nav-pod flytta active-class direkt till
10
+ tappad pod. Turbo-fetch (Sprint I) eller cross-document VT
11
+ renderar content parallellt. Failsafe rullar tillbaka active-
12
+ flytt om navigation inte landar (notification, network-stall
13
+ etc.).
14
+
15
+ Sprint G4: borttagen body.navigating-class + content-fade. Native
16
+ iOS fadar inte content under nav-byten. Active-flytt ensam ger
17
+ onskad feedback.
18
+
19
+ Sprint I: lyssna pa turbo:navigated istallet for pageshow
20
+ (pageshow firar inte vid same-document swap). Pageshow-handler
21
+ behalls for bfcache-restore.
22
+ --------------------------------------------------------------*/
23
+ ( function () {
24
+ const nav = document.querySelector( '.bottom-nav' );
25
+ if ( ! nav ) return;
26
+
27
+ let pendingNav = null;
28
+ let failsafeTimer = null;
29
+
30
+ function reset () {
31
+ pendingNav = null;
32
+ clearTimeout( failsafeTimer );
33
+ failsafeTimer = null;
34
+ }
35
+
36
+ nav.addEventListener( 'click', ( e ) => {
37
+ // Skippa modifier-clicks (cmd/ctrl/shift -> ny flik) och middle-click.
38
+ // Browser hanterar dem som "open in new tab" - vi vill INTE flytta active.
39
+ if ( e.metaKey || e.ctrlKey || e.shiftKey || e.altKey ) return;
40
+ if ( e.button !== undefined && e.button !== 0 ) return;
41
+
42
+ const pod = e.target.closest( '.nav-pod' );
43
+ if ( ! pod || ! nav.contains( pod ) ) return;
44
+ if ( pod.classList.contains( 'active' ) ) return;
45
+
46
+ const previousActive = nav.querySelector( '.nav-pod.active' );
47
+ previousActive && previousActive.classList.remove( 'active' );
48
+ pod.classList.add( 'active' );
49
+
50
+ pendingNav = { from: previousActive, to: pod };
51
+
52
+ // Failsafe: om ny sida inte landar inom 3s, rulla tillbaka. Skyddar
53
+ // mot edge-cases dar navigation avbryts (notification dragdown,
54
+ // network-stall, abort etc.).
55
+ clearTimeout( failsafeTimer );
56
+ failsafeTimer = setTimeout( () => {
57
+ if ( pendingNav ) {
58
+ pendingNav.to.classList.remove( 'active' );
59
+ pendingNav.from && pendingNav.from.classList.add( 'active' );
60
+ reset();
61
+ }
62
+ }, 3000 );
63
+ } );
64
+
65
+ // Sprint I: turbo-swap rensar pending state och korrigerar active-class
66
+ // om server-render skiljer sig fran optimistic flytt (turbo-nav.js gor
67
+ // performSwap.bottom-nav-active-justering vilket overskuggar var optimistic
68
+ // flytt om server-render landade pa annan route).
69
+ document.addEventListener( 'turbo:navigated', reset );
70
+
71
+ // pageshow firar bade vid fresh load OCH bfcache-restore. Behalls for
72
+ // fallback-fall (no-turbo, full reload, browser back utan turbo-state).
73
+ window.addEventListener( 'pageshow', reset );
74
+ } )();
@@ -0,0 +1,162 @@
1
+ /*--------------------------------------------------------------
2
+ Ekonom - Pull-to-refresh (Sprint E1).
3
+
4
+ Pointer Events-baserad pull fran top-of-page nar window.scrollY === 0.
5
+ Aktiveras pa main routes (avstamning/kategorier/bolanet/jag) via
6
+ data-ptr-enabled-attribut pa body.
7
+
8
+ Fysik:
9
+ - 1:1 tracking forsta 40px, sen kvadratisk dampning
10
+ - Threshold: 80px med damping for trigger
11
+ - Spinner-indikator visas centrerad ovanfor hero
12
+ - Refresh = location.reload()
13
+
14
+ Edge cases (no-op):
15
+ - Sheet ar oppen
16
+ - Form har :focus-within
17
+ - VT-animation pagar (impossible to detect reliably; vi kollar via
18
+ document.startViewTransition existence + nyligen-startad-flagga)
19
+ - Aktiv upload (.upload-spinner-overlay.visible)
20
+ --------------------------------------------------------------*/
21
+ (function () {
22
+ 'use strict';
23
+
24
+ const THRESHOLD_PX = 80;
25
+ const LINEAR_PX = 40;
26
+ const SETTLE_PX = 60;
27
+ const REFRESH_DELAY_MS = 200;
28
+
29
+ function shouldEnable() {
30
+ return document.body.dataset.ptrEnabled === 'true' ||
31
+ document.body.hasAttribute('data-ptr-enabled');
32
+ }
33
+
34
+ function isBlocked() {
35
+ // Sheet/dialog open
36
+ if (document.querySelector('dialog.sheet[open]')) return true;
37
+ // Form has focus (keyboard up)
38
+ if (document.activeElement && document.activeElement.matches &&
39
+ (document.activeElement.matches('input, textarea, select') ||
40
+ document.activeElement.closest('form:focus-within'))) return true;
41
+ // Upload spinner active
42
+ const upload = document.getElementById('upload-spinner-overlay');
43
+ if (upload && upload.classList.contains('visible')) return true;
44
+ return false;
45
+ }
46
+
47
+ function applyResistance(dy) {
48
+ if (dy <= LINEAR_PX) return dy;
49
+ // kvadratisk dampning efter 40px
50
+ return LINEAR_PX + Math.sqrt(dy - LINEAR_PX) * 4;
51
+ }
52
+
53
+ function ensureIndicator() {
54
+ let el = document.getElementById('ptr-indicator');
55
+ if (el) return el;
56
+ el = document.createElement('div');
57
+ el.id = 'ptr-indicator';
58
+ el.className = 'ptr-indicator';
59
+ el.setAttribute('aria-hidden', 'true');
60
+ el.innerHTML = '<div class="ptr-spinner"></div>';
61
+ document.body.appendChild(el);
62
+ return el;
63
+ }
64
+
65
+ let dragging = false;
66
+ let startY = 0;
67
+ let pullY = 0;
68
+ let pointerId = null;
69
+ let triggered = false;
70
+
71
+ function onPointerDown(e) {
72
+ if (!shouldEnable()) return;
73
+ if (isBlocked()) return;
74
+ if (window.scrollY > 0) return;
75
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
76
+ dragging = true;
77
+ triggered = false;
78
+ startY = e.clientY;
79
+ pullY = 0;
80
+ pointerId = e.pointerId;
81
+ }
82
+
83
+ function onPointerMove(e) {
84
+ if (!dragging || e.pointerId !== pointerId) return;
85
+ const dy = e.clientY - startY;
86
+ if (dy <= 0) {
87
+ pullY = 0;
88
+ updateIndicator(0);
89
+ return;
90
+ }
91
+ // Om scrollY har gatt > 0 sen drag-start (t.ex. anvandaren bytte riktning),
92
+ // avbryt drag.
93
+ if (window.scrollY > 0) {
94
+ cancelDrag();
95
+ return;
96
+ }
97
+ pullY = applyResistance(dy);
98
+ updateIndicator(pullY);
99
+ // Forhindra default sa browsern inte scrollar (om vi ar pa scrollY=0).
100
+ if (e.cancelable) e.preventDefault();
101
+ }
102
+
103
+ function onPointerUp(e) {
104
+ if (!dragging) return;
105
+ dragging = false;
106
+ const indicator = document.getElementById('ptr-indicator');
107
+ if (pullY >= THRESHOLD_PX && !triggered) {
108
+ triggered = true;
109
+ // Snap till SETTLE_PX, sedan reload.
110
+ if (indicator) {
111
+ indicator.style.transition = 'transform 220ms var(--ease-spring-snappy)';
112
+ indicator.style.transform = 'translate(-50%, ' + SETTLE_PX + 'px)';
113
+ indicator.classList.add('ptr-indicator--triggered');
114
+ }
115
+ setTimeout(function () { window.location.reload(); }, REFRESH_DELAY_MS);
116
+ } else {
117
+ // Spring-back till 0.
118
+ if (indicator) {
119
+ indicator.style.transition = 'transform 320ms var(--ease-spring-bounce), opacity 220ms';
120
+ indicator.style.transform = 'translate(-50%, -40px)';
121
+ indicator.style.opacity = '0';
122
+ const onEnd = function () {
123
+ indicator.removeEventListener('transitionend', onEnd);
124
+ indicator.style.transition = '';
125
+ indicator.style.transform = '';
126
+ indicator.style.opacity = '';
127
+ };
128
+ indicator.addEventListener('transitionend', onEnd, { once: true });
129
+ }
130
+ }
131
+ pullY = 0;
132
+ }
133
+
134
+ function cancelDrag() {
135
+ dragging = false;
136
+ const indicator = document.getElementById('ptr-indicator');
137
+ if (indicator) {
138
+ indicator.style.transition = 'transform 220ms, opacity 180ms';
139
+ indicator.style.transform = 'translate(-50%, -40px)';
140
+ indicator.style.opacity = '0';
141
+ }
142
+ pullY = 0;
143
+ }
144
+
145
+ function updateIndicator(y) {
146
+ const el = ensureIndicator();
147
+ el.style.transition = 'none';
148
+ // Translate fran -40px (dolt ovanfor viewport) till y px nedat.
149
+ const translateY = -40 + y;
150
+ el.style.transform = 'translate(-50%, ' + translateY + 'px)';
151
+ const progress = Math.min(1, y / THRESHOLD_PX);
152
+ el.style.opacity = String(progress);
153
+ // Rotation pa spinnern: hela 360deg vid threshold.
154
+ const spinner = el.firstChild;
155
+ if (spinner) spinner.style.transform = 'rotate(' + (progress * 360) + 'deg)';
156
+ }
157
+
158
+ document.addEventListener('pointerdown', onPointerDown, { passive: true });
159
+ document.addEventListener('pointermove', onPointerMove, { passive: false });
160
+ document.addEventListener('pointerup', onPointerUp, { passive: true });
161
+ document.addEventListener('pointercancel', cancelDrag, { passive: true });
162
+ })();