PageSourceSearch

https://pupwife.com/scripts/dependencies/cursors/catPawArmCursor.js

js pupwife.com collected 2026-09-30 15:05:36 UTC 55,064 bytes, 1,435 lines download raw bytes

1/**
2 * Stretchy paw cursor — cat or dog paw on a configurable arm from the viewport edge.
3 */
4(function (global) {
5    'use strict';
6
7    var BASE_ARM_WIDTH = 30;
8    var BASE_PAW_SCALE = 1;
9    var BASE_OUTLINE = 1.4;
10    var PRINT_BASE_MS = 3000;
11    var SPRITE_TARGET_HEIGHT = 36;
12    var SPRITE_ARM_COLORS = {
13        fill: '#ffffff',
14        edge: '#bdbdbd',
15        fleck: '#e8e8e8'
16    };
17    var PAW_PRESETS = {
18        'classic-white': {
19            id: 'classic-white',
20            label: 'Classic white',
21            species: 'cat',
22            spriteBase: '/cursors/Paws/Paws/catpaw_base.png',
23            spriteBeans: '/cursors/Paws/Paws/catpaw_beans.png',
24            meta: { toesY: 0.05, wristY: 0.99, wristWidth: 0.715 },
25            recolorFur: true,
26            defaultFur: '#ffffff',
27            armEdge: '#bdbdbd',
28            armFleck: '#e8e8e8'
29        }
30    };
31
32    var PAW_SPRITE_PATHS = {};
33    var PAW_SPRITE_META = {};
34    Object.keys(PAW_PRESETS).forEach(function (id) {
35        var preset = PAW_PRESETS[id];
36        PAW_SPRITE_PATHS[id] = preset.spriteBase || preset.sprite;
37        PAW_SPRITE_META[id] = preset.meta;
38    });
39
40    var PRIDE_PRESETS = {
41        rainbow: ['#e40303', '#ff8c00', '#ffed00', '#008026', '#24408e', '#732982'],
42        trans: ['#5BCEFA', '#F5A9B8', '#FFFFFF', '#F5A9B8', '#5BCEFA'],
43        bi: ['#D60270', '#9B4F96', '#0038A8'],
44        pan: ['#FF218C', '#FFD800', '#21B1FF'],
45        lesbian: ['#D52D00', '#FF9A56', '#FFFFFF', '#D362A4', '#A40062'],
46        nb: ['#FCF434', '#FFFFFF', '#9C59D1', '#2C2C2C'],
47        ace: ['#000000', '#A3A3A3', '#FFFFFF', '#810081'],
48        progress: ['#FFFFFF', '#FFAEC9', '#74D7EE', '#613915', '#000000']
49    };
50
51    var ANCHOR_PRESETS = {
52        'bottom-center': { originX: 50, originY: 108 },
53        'bottom-left': { originX: 8, originY: 108 },
54        'bottom-right': { originX: 92, originY: 108 },
55        'top-center': { originX: 50, originY: -8 },
56        'top-left': { originX: 8, originY: -8 },
57        'top-right': { originX: 92, originY: -8 },
58        'middle-left': { originX: 0, originY: 50 },
59        'middle-right': { originX: 100, originY: 50 },
60        custom: null
61    };
62
63    function defaultColorPart(hex) {
64        return {
65            colorMode: 'solid',
66            solidColor: hex,
67            rgbSpeed: 1,
68            pridePreset: 'rainbow'
69        };
70    }
71
72    function defaultConfig() {
73        return {
74            anchorPreset: 'bottom-center',
75            originX: 50,
76            originY: 108,
77            armThickness: 100,
78            armTaper: 35,
79            armBend: 0,
80            armMargin: 100,
81            followLag: 42,
82            clickSquish: true,
83            showBeans: true,
84            pawPreset: 'classic-white',
85            pawSpecies: 'cat',
86            pawSize: 100,
87            padSize: 100,
88            printsEnabled: true,
89            printSize: 100,
90            printFade: 100,
91            printMax: 16,
92            outlineShow: true,
93            outlineThickness: 100,
94            arm: defaultColorPart('#ffffff'),
95            pawFur: defaultColorPart('#ffffff'),
96            pawPads: defaultColorPart('#f2a7b8'),
97            outline: defaultColorPart('#b0b0b0'),
98            prints: defaultColorPart('#e89ab0')
99        };
100    }
101
102    function clampInt(v, min, max, fb) {
103        var n = parseInt(v, 10);
104        if (Number.isNaN(n)) return fb;
105        return Math.max(min, Math.min(max, n));
106    }
107
108    function clampNum(v, min, max, fb) {
109        var n = parseFloat(v);
110        if (Number.isNaN(n)) return fb;
111        return Math.max(min, Math.min(max, n));
112    }
113
114    function mergeColorPart(raw, fallback) {
115        if (!raw || typeof raw !== 'object') return Object.assign({}, fallback);
116        var mode = raw.colorMode;
117        if (mode !== 'solid' && mode !== 'rgb' && mode !== 'pride') mode = fallback.colorMode;
118        return {
119            colorMode: mode,
120            solidColor: typeof raw.solidColor === 'string' ? raw.solidColor : fallback.solidColor,
121            rgbSpeed: clampNum(raw.rgbSpeed, 0.2, 3, fallback.rgbSpeed),
122            pridePreset: PRIDE_PRESETS[raw.pridePreset] ? raw.pridePreset : fallback.pridePreset
123        };
124    }
125
126    function resolvePawPresetId(raw, species) {
127        if (species === 'dog') return '';
128        if (raw && raw.pawPreset && PAW_PRESETS[raw.pawPreset]) return raw.pawPreset;
129        return 'classic-white';
130    }
131
132    function mergeConfig(raw) {
133        var d = defaultConfig();
134        if (!raw || typeof raw !== 'object') return d;
135        var species = raw.pawSpecies === 'dog' ? 'dog' : 'cat';
136        var pawPreset = resolvePawPresetId(raw, species);
137        var presetDef = getPawPreset(pawPreset);
138        if (presetDef) species = presetDef.species;
139        var preset = ANCHOR_PRESETS[raw.anchorPreset] ? raw.anchorPreset : d.anchorPreset;
140        if (preset === 'center') preset = 'bottom-center';
141        if (preset === 'custom') preset = 'custom';
142        var pawSize = clampInt(raw.pawSize, 25, 400, d.pawSize);
143        return {
144            anchorPreset: preset,
145            originX: clampNum(raw.originX, -20, 120, d.originX),
146            originY: clampNum(raw.originY, -20, 120, d.originY),
147            armThickness: pawSize,
148            armTaper: clampInt(raw.armTaper, 0, 100, d.armTaper),
149            armBend: clampInt(raw.armBend, 0, 200, d.armBend),
150            armMargin: 100,
151            followLag: clampInt(raw.followLag, 5, 100, d.followLag),
152            clickSquish: typeof raw.clickSquish === 'boolean' ? raw.clickSquish : d.clickSquish,
153            showBeans: typeof raw.showBeans === 'boolean' ? raw.showBeans : d.showBeans,
154            pawPreset: pawPreset,
155            pawSpecies: species,
156            pawSize: pawSize,
157            padSize: clampInt(raw.padSize, 50, 200, d.padSize),
158            printsEnabled: typeof raw.printsEnabled === 'boolean' ? raw.printsEnabled : d.printsEnabled,
159            printSize: clampInt(raw.printSize, 25, 200, d.printSize),
160            printFade: clampInt(raw.printFade, 25, 200, d.printFade),
161            printMax: clampInt(raw.printMax, 4, 32, d.printMax),
162            outlineShow: typeof raw.outlineShow === 'boolean' ? raw.outlineShow : d.outlineShow,
163            outlineThickness: clampInt(raw.outlineThickness, 50, 200, d.outlineThickness),
164            arm: mergeColorPart(raw.arm, d.arm),
165            pawFur: mergeColorPart(raw.pawFur, d.pawFur),
166            pawPads: mergeColorPart(raw.pawPads, d.pawPads),
167            outline: mergeColorPart(raw.outline, d.outline),
168            prints: mergeColorPart(raw.prints, d.prints)
169        };
170    }
171
172    var overlay = null;
173    var canvas = null;
174    var printLayer = null;
175    var ctx = null;
176    var animId = null;
177    var isActive = false;
178    var config = null;
179    var target = { x: 0, y: 0 };
180    var display = { x: 0, y: 0 };
181    var squish = 1;
182    var squishTarget = 1;
183    var prints = [];
184    var pawSprites = {};
185    var pawSpriteBeans = {};
186    var pawSpriteReady = {};
187    var pawSpriteLoading = {};
188    var pawSpriteMeasured = {};
189    var tintedSpriteCache = {};
190    var measureCanvas = null;
191    var measureCtx = null;
192    var tintCanvas = null;
193    var tintCtx = null;
194
195    function getPawPreset(presetId) {
196        return PAW_PRESETS[presetId] || null;
197    }
198
199    function getActivePresetId() {
200        if (!config || !config.pawPreset || !PAW_PRESETS[config.pawPreset]) return '';
201        return config.pawPreset;
202    }
203
204    function getActivePreset() {
205        var id = getActivePresetId();
206        if (!id) return null;
207        return getPawPreset(id);
208    }
209
210    function getPresetOptions() {
211        return Object.keys(PAW_PRESETS).map(function (id) {
212            return { value: id, label: PAW_PRESETS[id].label };
213        });
214    }
215
216    function clampByte(v) {
217        return Math.max(0, Math.min(255, Math.round(v)));
218    }
219
220    function parseHexColor(hex) {
221        if (!hex || typeof hex !== 'string') return { r: 255, g: 255, b: 255 };
222        var h = hex.replace('#', '');
223        if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
224        if (h.length !== 6) return { r: 255, g: 255, b: 255 };
225        return {
226            r: parseInt(h.slice(0, 2), 16),
227            g: parseInt(h.slice(2, 4), 16),
228            b: parseInt(h.slice(4, 6), 16)
229        };
230    }
231
232    function getPresetBasePath(preset) {
233        if (!preset) return null;
234        return preset.spriteBase || preset.sprite || null;
235    }
236
237    function getPresetBeansPath(preset) {
238        if (!preset) return null;
239        return preset.spriteBeans || null;
240    }
241
242    function tintFurPixel(r, g, b, a, tr, tg, tb) {
243        var lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
244        if (lum < 0.01) return [r, g, b, a];
245        return [clampByte(tr * lum), clampByte(tg * lum), clampByte(tb * lum), a];
246    }
247
248    function shadeHexColor(hex, factor) {
249        var c = parseHexColor(hex);
250        return (
251            '#' +
252            clampByte(c.r * factor).toString(16).padStart(2, '0') +
253            clampByte(c.g * factor).toString(16).padStart(2, '0') +
254            clampByte(c.b * factor).toString(16).padStart(2, '0')
255        );
256    }
257
258    function getSpriteArmColors(now) {
259        var fill = resolveColor(config.pawFur, 0.1, now);
260        return {
261            fill: fill,
262            edge: shadeHexColor(fill, 0.72),
263            fleck: shadeHexColor(fill, 1.08)
264        };
265    }
266
267    function getTintCacheKey(presetId, furPart, now) {
268        if (!furPart) return presetId + ':default';
269        if (furPart.colorMode === 'solid') return presetId + ':solid:' + furPart.solidColor;
270        if (furPart.colorMode === 'rgb') return presetId + ':rgb:' + Math.floor(now / 80);
271        return presetId + ':pride:' + furPart.pridePreset + ':' + Math.floor(now / 120);
272    }
273
274    function clearTintedSpriteCache() {
275        tintedSpriteCache = {};
276    }
277
278    function getTintCtx(width, height) {
279        if (!tintCanvas) {
280            tintCanvas = document.createElement('canvas');
281            tintCtx = tintCanvas.getContext('2d', { willReadFrequently: true });
282        }
283        if (tintCanvas.width !== width) tintCanvas.width = width;
284        if (tintCanvas.height !== height) tintCanvas.height = height;
285        return tintCtx;
286    }
287
288    function buildTintedSprite(presetId, furColor) {
289        var img = pawSprites[presetId];
290        if (!img || !img.naturalWidth) return img;
291        var width = img.naturalWidth;
292        var height = img.naturalHeight;
293        var ctx2d = getTintCtx(width, height);
294        ctx2d.clearRect(0, 0, width, height);
295        ctx2d.drawImage(img, 0, 0);
296        var data = ctx2d.getImageData(0, 0, width, height);
297        var px = data.data;
298        var tr = furColor.r;
299        var tg = furColor.g;
300        var tb = furColor.b;
301        var i;
302        var r;
303        var g;
304        var b;
305        var a;
306        var out;
307        for (i = 0; i < px.length; i += 4) {
308            r = px[i];
309            g = px[i + 1];
310            b = px[i + 2];
311            a = px[i + 3];
312            if (a < 16) continue;
313            out = tintFurPixel(r, g, b, a, tr, tg, tb);
314            px[i] = out[0];
315            px[i + 1] = out[1];
316            px[i + 2] = out[2];
317        }
318        ctx2d.putImageData(data, 0, 0);
319        return tintCanvas;
320    }
321
322    function getTintedSprite(now) {
323        var preset = getActivePreset();
324        var presetId = getActivePresetId();
325        if (!preset || !preset.recolorFur) return pawSprites[presetId] || null;
326        var furHex = resolveColor(config.pawFur, 0.1, now);
327        var cacheKey = getTintCacheKey(presetId, config.pawFur, now);
328        if (tintedSpriteCache[cacheKey]) return tintedSpriteCache[cacheKey];
329        var tinted = buildTintedSprite(presetId, parseHexColor(furHex));
330        tintedSpriteCache[cacheKey] = tinted;
331        return tinted;
332    }
333
334    function snapPixel(v) {
335        return Math.round(v);
336    }
337
338    function getMeasureCtx(width, height) {
339        if (!measureCanvas) {
340            measureCanvas = document.createElement('canvas');
341            measureCtx = measureCanvas.getContext('2d', { willReadFrequently: true });
342        }
343        if (measureCanvas.width !== width) measureCanvas.width = width;
344        if (measureCanvas.height !== height) measureCanvas.height = height;
345        return measureCtx;
346    }
347
348    function measureRowOpaqueWidth(rowData, width, alphaMin) {
349        var minX = width;
350        var maxX = -1;
351        var x;
352        var threshold = alphaMin || 16;
353        for (x = 0; x < width; x++) {
354            if (rowData[x * 4 + 3] >= threshold) {
355                if (x < minX) minX = x;
356                if (x > maxX) maxX = x;
357            }
358        }
359        return maxX >= minX ? maxX - minX + 1 : 0;
360    }
361
362    function measureSpriteArmWidth(img, meta) {
363        var width = img.naturalWidth;
364        var height = img.naturalHeight;
365        if (!width || !height) return meta.wristWidth;
366        var ctx2d = getMeasureCtx(width, height);
367        ctx2d.clearRect(0, 0, width, height);
368        ctx2d.drawImage(img, 0, 0);
369        var yStart = Math.max(0, Math.floor(height * meta.wristY) - 2);
370        var widths = [];
371        var y;
372        for (y = yStart; y < height; y++) {
373            var rowData = ctx2d.getImageData(0, y, width, 1).data;
374            var rowW = measureRowOpaqueWidth(rowData, width, 200);
375            if (rowW) widths.push(rowW);
376        }
377        if (!widths.length) return meta.wristWidth;
378        widths.sort(function (a, b) { return a - b; });
379        var medianW = widths[Math.floor(widths.length * 0.5)];
380        return medianW / width;
381    }
382
383    function measureSpriteMeta(img, presetId) {
384        var base = PAW_PRESETS[presetId] ? PAW_PRESETS[presetId].meta : { toesY: 0.05, wristY: 0.99, wristWidth: 0.715 };
385        var measured = measureSpriteArmWidth(img, base);
386        var wristWidth = Math.abs(measured - base.wristWidth) < 0.04
387            ? measured
388            : base.wristWidth;
389        pawSpriteMeasured[presetId] = {
390            toesY: base.toesY,
391            wristY: base.wristY,
392            wristWidth: wristWidth
393        };
394        emitDevLog('Paw sprite wrist width measured:', presetId, Math.round(wristWidth * img.naturalWi
394dth) + 'px', '(' + (wristWidth * 100).toFixed(1) + '% of sprite)');
395    }
396
397    function getPawSpritePath(presetOrSpecies) {
398        if (PAW_PRESETS[presetOrSpecies]) return getPresetBasePath(PAW_PRESETS[presetOrSpecies]);
399        return PAW_SPRITE_PATHS[presetOrSpecies] || null;
400    }
401
402    function getPawSpriteBeansPath(presetOrSpecies) {
403        if (PAW_PRESETS[presetOrSpecies]) return getPresetBeansPath(PAW_PRESETS[presetOrSpecies]);
404        return null;
405    }
406
407    function isPresetLayersReady(presetId) {
408        if (!pawSpriteReady[presetId]) return false;
409        var beansPath = getPawSpriteBeansPath(presetId);
410        if (!beansPath) return true;
411        var beans = pawSpriteBeans[presetId];
412        return !!(beans && beans.naturalWidth && beans.naturalHeight);
413    }
414
415    function getPawSpriteMeta(presetOrSpecies) {
416        var measured = pawSpriteMeasured[presetOrSpecies];
417        if (measured) return measured;
418        if (PAW_PRESETS[presetOrSpecies]) return PAW_PRESETS[presetOrSpecies].meta;
419        return PAW_SPRITE_META[presetOrSpecies] || { toesY: 0.05, wristY: 0.99, wristWidth: 0.715 };
420    }
421
422    function hasSpriteArt(presetOrSpecies) {
423        return !!getPawSpritePath(presetOrSpecies || (config && config.pawPreset) || 'classic-white');
424    }
425
426    function hasPresetArt(presetId) {
427        return !!getPawPreset(presetId || (config && config.pawPreset));
428    }
429
430    function shouldUsePawSprite() {
431        if (!config || config.pawSpecies === 'dog') return false;
432        var presetId = getActivePresetId();
433        if (!presetId) return false;
434        var path = getPawSpritePath(presetId);
435        if (!path) return false;
436        return isPresetLayersReady(presetId);
437    }
438
439    function preloadPawSprite(presetId) {
440        var preset = PAW_PRESETS[presetId];
441        if (!preset) return;
442        var basePath = getPresetBasePath(preset);
443        var beansPath = getPresetBeansPath(preset);
444        if (!basePath) return;
445        if (pawSprites[presetId] && (!beansPath || pawSpriteBeans[presetId])) return;
446        if (pawSpriteLoading[presetId]) return;
447
448        pawSpriteLoading[presetId] = true;
449        var pending = beansPath ? 2 : 1;
450        var finished = 0;
451        var baseOk = false;
452        var beansOk = false;
453
454        function onLayerDone() {
455            finished++;
456            if (finished < pending) return;
457            pawSpriteLoading[presetId] = false;
458            pawSpriteReady[presetId] = baseOk && (!beansPath || beansOk);
459            if (pawSpriteReady[presetId]) {
460                try {
461                    measureSpriteMeta(pawSprites[presetId], presetId);
462                } catch (e) {
463                    emitDevLog('Paw sprite measure failed:', presetId, e);
464                }
465            } else {
466                pawSpriteReady[presetId] = false;
467            }
468            clearTintedSpriteCache();
469        }
470
471        function loadLayer(path, assign) {
472            var img = new Image();
473            img.decoding = 'async';
474            img.onload = function () {
475                if (img.naturalWidth && img.naturalHeight) {
476                    assign(img);
477                    return onLayerDone();
478                }
479                onLayerDone();
480            };
481            img.onerror = onLayerDone;
482            img.src = path;
483            return img;
484        }
485
486        loadLayer(basePath, function (img) {
487            pawSprites[presetId] = img;
488            baseOk = true;
489        });
490        if (beansPath) {
491            loadLayer(beansPath, function (img) {
492                pawSpriteBeans[presetId] = img;
493                beansOk = true;
494            });
495        }
496    }
497
498    function preloadPawSprites() {
499        Object.keys(PAW_PRESETS).forEach(function (id) {
500            preloadPawSprite(id);
501        });
502    }
503
504    function getSpriteScale() {
505        var img = pawSprites[getActivePresetId()];
506        if (!img || !img.naturalHeight) return 0;
507        var targetH = SPRITE_TARGET_HEIGHT * (config.pawSize / 100);
508        return targetH / img.naturalHeight;
509    }
510
511    function getSpriteDimensions() {
512        var img = pawSprites[getActivePresetId()];
513        var scale = getSpriteScale();
514        if (!img || !scale) return null;
515        return {
516            width: img.naturalWidth * scale,
517            height: img.naturalHeight * scale
518        };
519    }
520
521    function emitDevLog() {
522        if (global.devLog) global.devLog.apply(global, arguments);
523    }
524
525    function prefersReducedMotion() {
526        try {
527            return !!(global.matchMedia && global.matchMedia('(prefers-reduced-motion: reduce)').matches);
528        } catch (e) {
529            return false;
530        }
531    }
532
533    function isCoarsePointer() {
534        try {
535            return !!(global.matchMedia && global.matchMedia('(pointer: coarse)').matches);
536        } catch (e) {
537            return false;
538        }
539    }
540
541    function isPerformanceMode() {
542        if (global.PERFORMANCE_MODE_ENABLED) return true;
543        if (global.SettingsMenu && typeof global.SettingsMenu.getSetting === 'function') {
544            return !!global.SettingsMenu.getSetting('performanceMode');
545        }
546        return false;
547    }
548
549    function readConfig() {
550        if (global.SettingsMenu && typeof global.SettingsMenu.getSetting === 'function') {
551            return mergeConfig(global.SettingsMenu.getSetting('catPawArm'));
552        }
553        return defaultConfig();
554    }
555
556    function resolveColor(part, t, time) {
557        if (!part) return '#ffffff';
558        if (part.colorMode === 'solid') return part.solidColor;
559        if (part.colorMode === 'rgb') {
560            var hue = (time * part.rgbSpeed * 0.08 + t * 280) % 360;
561            return 'hsl(' + hue + ', 78%, 58%)';
562        }
563        var stripes = PRIDE_PRESETS[part.pridePreset] || PRIDE_PRESETS.rainbow;
564        var idx = Math.floor(t * stripes.length) % stripes.length;
565        if (idx < 0) idx = 0;
566        return stripes[idx];
567    }
568
569    function lerp(a, b, t) {
570        return a + (b - a) * t;
571    }
572
573    function getAnchor() {
574        var margin = (config.armMargin / 100) * 24;
575        var ox = (config.originX / 100) * global.innerWidth;
576        var oy = (config.originY / 100) * global.innerHeight;
577
578        if (config.originX < 0) ox = -margin;
579        else if (config.originX > 100) ox = global.innerWidth + margin;
580        else if (config.originX <= 5) ox -= margin * 0.35;
581        else if (config.originX >= 95) ox += margin * 0.35;
582
583        if (config.originY < 0) oy = -margin;
584        else if (config.originY > 100) oy = global.innerHeight + margin;
585        else if (config.originY <= 5) oy -= margin * 0.35;
586        else if (config.originY >= 95) oy += margin * 0.35;
587
588        return { x: ox, y: oy };
589    }
590
591    function getPawMetrics() {
592        var scale = BASE_PAW_SCALE * (config.pawSize / 100);
593        var padScale = config.padSize / 100;
594        if (shouldUsePawSprite()) {
595            var dims = getSpriteDimensions();
596            var meta = getPawSpriteMeta(getActivePresetId());
597            if (dims) {
598                var wristReach = dims.height * (meta.wristY - meta.toesY);
599                return {
600                    scale: scale,
601                    padScale: padScale,
602                    spriteScale: getSpriteScale(),
603                    spriteMeta: meta,
604                    useSprite: true,
605                    hotspotOffset: 0,
606                    cuffReach: wristReach,
607                    cuffWidth: dims.width * meta.wristWidth,
608                    forearmOverlap: Math.max(4, wristReach * 0.12)
609                };
610            }
611        }
612        return {
613            scale: scale,
614            padScale: padScale,
615            useSprite: false,
616            hotspotOffset: 11 * scale,
617            cuffReach: 23 * scale,
618            cuffWidth: 20 * scale,
619            forearmOverlap: 12 * scale
620        };
621    }
622
623    function getWristTangent(ax, ay, endX, endY, sideX, sideY) {
624        var cp = getControlPoint(ax, ay, endX, endY, sideX, sideY);
625        return Math.atan2(endY - cp.y, endX - cp.x);
626    }
627
628    function getPawLayout(anchor, hotspot, metrics) {
629        var chordAim = Math.atan2(hotspot.y - anchor.y, hotspot.x - anchor.x);
630        var attachX = hotspot.x - Math.cos(chordAim) * metrics.cuffReach;
631        var attachY = hotspot.y - Math.sin(chordAim) * metrics.cuffReach;
632        var armEndX = attachX + Math.cos(chordAim) * metrics.forearmOverlap;
633        var armEndY = attachY + Math.sin(chordAim) * metrics.forearmOverlap;
634        var wristAim = getWristTangent(anchor.x, anchor.y, armEndX, armEndY, hotspot.x, hotspot.y);
635
636        attachX = hotspot.x - Math.cos(wristAim) * metrics.cuffReach;
637        attachY = hotspot.y - Math.sin(wristAim) * metrics.cuffReach;
638        armEndX = attachX + Math.cos(wristAim) * metrics.forearmOverlap;
639        armEndY = attachY + Math.sin(wristAim) * metrics.forearmOverlap;
640        wristAim = getWristTangent(anchor.x, anchor.y, armEndX, armEndY, hotspot.x, hotspot.y);
641
642        return {
643            aim: wristAim,
644            attachX: attachX,
645            attachY: attachY,
646            armEndX: armEndX,
647            armEndY: armEndY,
648            sideX: hotspot.x,
649            sideY: hotspot.y
650        };
651    }
652
653    function getPawAngle(layout) {
654        return layout.aim + Math.PI * 0.5;
655    }
656
657    function getWristSquish() {
658        if (!config.clickSquish || squish === 1) return 1;
659        return squish;
660    }
661
662    function getArmSquishFactor(t, wristSquish) {
663        if (wristSquish === 1) return 1;
664        var along = Math.pow(Math.max(0, Math.min(1, t)), 1.65);
665        return 1 + (wristSquish - 1) * along;
666    }
667
668    function getAnchorInwardNormal(ax, ay) {
669        var ix = global.innerWidth * 0.5 - ax;
670        var iy = global.innerHeight * 0.5 - ay;
671        var len = Math.hypot(ix, iy) || 1;
672        return { x: ix / len, y: iy / len };
673    }
674
675    function getArmBendFactor(ax, ay, sideX, sideY) {
676        var dx = sideX - ax;
677        var dy = sideY - ay;
678        var len = Math.hypot(dx, dy) || 1;
679        var ux = dx / len;
680        var uy = dy / len;
681        var inward = getAnchorInwardNormal(ax, ay);
682        var cross = ux * inward.y - uy * inward.x;
683        var lateral = Math.abs(cross);
684        if (lateral < 0.0001) return 0;
685        var bendSign = cross > 0 ? 1 : -1;
686        return lateral * bendSign;
687    }
688
689    function getControlPoint(ax, ay, px, py, sideX, sideY) {
690        var mx = (ax + px) * 0.5;
691        var my = (ay + py) * 0.5;
692        var dx = px - ax;
693        var dy = py - ay;
694        var len = Math.hypot(dx, dy) || 1;
695        var nx = -dy / len;
696        var ny = dx / len;
697        var refX = sideX !== undefined ? sideX : px;
698        var refY = sideY !== undefined ? sideY : py;
699        var bendFactor = getArmBendFactor(ax, ay, refX, refY);
700        var bend = (config.armBend / 100) * Math.min(180, len * 0.35) * bendFactor;
701        return { x: mx + nx * bend, y: my + ny * bend };
702    }
703
704    function quadPoint(t, x0, y0, cx, cy, x1, y1) {
705        var u = 1 - t;
706        return {
707            x: u * u * x0 + 2 * u * t * cx + t * t * x1,
708            y: u * u * y0 + 2 * u * t * cy + t * t * y1
709        };
710    }
711
712    function sampleArmPath(ax, ay, wx, wy, steps, sideX, sideY) {
713        var cp = getControlPoint(ax, ay, wx, wy, sideX, sideY);
714        var pts = [];
715        var i;
716        for (i = 0; i <= steps; i++) {
717            pts.push(quadPoint(i / steps, ax, ay, cp.x, cp.y, wx, wy));
718        }
719        return pts;
720    }
721
722    function armEdgeNoise(x, y, seed) {
723        var n = (x * 374761393 + y * 668265263 + seed * 982451653) | 0;
724        n = (n ^ (n >> 13)) * 1274126177;
725        return ((n ^ (n >> 16)) >>> 0) / 4294967295;
726    }
727
728    function drawFuzzyPixelArm(left, right, armColors) {
729        var i;
730        var pt;
731        var prev;
732        var next;
733        var tx;
734        var ty;
735        var len;
736        var nx;
737        var ny;
738        var noise;
739        var dist;
740        var fx;
741        var fy;
742        var snappedLeft = left.map(function (p) {
743            return { x: snapPixel(p.x), y: snapPixel(p.y) };
744        });
745        var snappedRight = right.map(function (p) {
746            return { x: snapPixel(p.x), y: snapPixel(p.y) };
747        });
748
749        var colors = armColors || SPRITE_ARM_COLORS;
750        ctx.imageSmoothingEnabled = false;
751        ctx.fillStyle = colors.fill;
752        ctx.beginPath();
753        ctx.moveTo(snappedLeft[0].x, snappedLeft[0].y);
754        for (i = 1; i < snappedLeft.length; i++) ctx.lineTo(snappedLeft[i].x, snappedLeft[i].y);
755        for (i = snappedRight.length - 1; i >= 0; i--) ctx.lineTo(snappedRight[i].x, snappedRight[i].y);
756        ctx.closePath();
757        ctx.fill();
758
759        function fuzzEdge(chain, outward, seedBase) {
760            for (i = 0; i < chain.length; i++) {
761                pt = chain[i];
762                if (i === 0) {
763                    if (chain.length > 1) {
764                        tx = chain[1].x - chain[0].x;
765                        ty = chain[1].y - chain[0].y;
766                    } else {
767                        tx = 0;
768                        ty = outward;
769                    }
770                } else if (i === chain.length - 1) {
771                    tx = chain[i].x - chain[i - 1].x;
772                    ty = chain[i].y - chain[i - 1].y;
773                } else {
774                    tx = chain[i + 1].x - chain[i - 1].x;
775                    ty = chain[i + 1].y - chain[i - 1].y;
776                }
777                len = Math.hypot(tx, ty) || 1;
778                nx = (-ty / len) * outward;
779                ny = (tx / len) * outward;
780
781                noise = armEdgeNoise(pt.x, pt.y, seedBase + i);
782                ctx.fillStyle = noise < 0.42 ? colors.edge : colors.fill;
783                ctx.fillRect(pt.x, pt.y, 1, 1);
784
785                if (noise > 0.18) {
786                    dist = noise > 0.78 ? 2 : 1;
787                    fx = snapPixel(pt.x + nx * dist);
788                    fy = snapPixel(pt.y + ny * dist);
789                    ctx.fillStyle = noise > 0.62 ? colors.fill : colors.edge;
790                    ctx.fillRect(fx, fy, 1, 1);
791                }
792
793                if (noise > 0.88 && i % 2 === 0) {
794                    ctx.fillStyle = colors.fleck;
795                    ctx.fillRect(snapPixel(pt.x + nx * 2), snapPixel(pt.y + ny * 2), 1, 1);
796                }
797
798                if (i > 0 && i < chain.length - 1 && noise > 0.52 && noise < 0.72) {
799                    ctx.fillStyle = colors.edge;
800                    ctx.fillRect(snapPixel(pt.x + nx), snapPixel(pt.y + ny), 1, 1);
801                }
802            }
803        }
804
805        fuzzEdge(snappedLeft, -1, 11);
806        fuzzEdge(snappedRight, 1, 29);
807    }
808
809    function drawPixelArmPolygon(left, right, fillColor, outlineColor) {
810        var i;
811        var snappedLeft = left.map(function (pt) {
812            return { x: snapPixel(pt.x), y: snapPixel(pt.y) };
813        });
814        var snappedRight = right.map(function (pt) {
815            return { x: snapPixel(pt.x), y: snapPixel(pt.y) };
816        });
817
818        ctx.fillStyle = fillColor;
819        ctx.beginPath();
820        ctx.moveTo(snappedLeft[0].x, snappedLeft[0].y);
821        for (i = 1; i < snappedLeft.length; i++) ctx.lineTo(snappedLeft[i].x, snappedLeft[i].y);
822        for (i = snappedRight.length - 1; i >= 0; i--) ctx.lineTo(snappedRight[i].x, snappedRight[i].y);
823        ctx.closePath();
824        ctx.fill();
825
826        if (!config.outlineShow) return;
827
828        ctx.strokeStyle = outlineColor;
829        ctx.lineWidth = Math.max(1, snapPixel(BASE_OUTLINE * (config.outlineThickness / 100)));
830        ctx.lineJoin = 'miter';
831        ctx.miterLimit = 1;
832        ctx.beginPath();
833        ctx.moveTo(snappedLeft[0].x, snappedLeft[0].y);
834        for (i = 1; i < snappedLeft.length; i++) ctx.lineTo(snappedLeft[i].x, snappedLeft[i].y);
835        ctx.stroke();
836        ctx.beginPath();
837        ctx.moveTo(snappedRight[0].x, snappedRight[0].y);
838        for (i = 1; i < snappedRight.length; i++) ctx.lineTo(snappedRight[i].x, snappedRight[i].y);
839        ctx.stroke();
840    }
841
842    function drawTaperedArm(ax, ay, endX, endY, cuffWidth, now, usePixelStyle, wristSquish, sideX, sideY) {
843        var sizeScale = config.pawSize / 100;
844        var taper = config.armTaper / 100;
845        var wristScale = wristSquish || 1;
846        var startW;
847        var endWBase;
848        if (usePixelStyle) {
849            endWBase = Math.max(cuffWidth, 6);
850            startW = Math.max(endWBase * 1.35, cuffWidth * 1.25, 8) * (1 - taper * 0.38);
851        } else {
852            startW = BASE_ARM_WIDTH * sizeScale;
853            endWBase = Math.max(cuffWidth, startW * (1 - taper * 0.45));
854        }
855        var steps = usePixelStyle ? 20 : 24;
856        var path = sampleArmPath(ax, ay, endX, endY, steps, sideX, sideY);
857        var left = [];
858        var right = [];
859        var i;
860        var pt;
861        var t;
862        var tx;
863        var ty;
864        var len;
865        var nx;
866        var ny;
867        var w;
868
869        for (i = 0; i < path.length; i++) {
870            t = i / (path.length - 1);
871            pt = path[i];
872            if (i === 0) {
873                tx = path[1].x - path[0].x;
874                ty = path[1].y - path[0].y;
875            } else if (i === path.length - 1) {
876                tx = path[i].x - path[i - 1].x;
877                ty = path[i].y - path[i - 1].y;
878            } else {
879                tx = path[i + 1].x - path[i - 1].x;
880                ty = path[i + 1].y - path[i - 1].y;
881            }
882            len = Math.hypot(tx, ty) || 1;
883            nx = -ty / len;
884            ny = tx / len;
885            w = (startW + (endWBase - startW) * t) * getArmSquishFactor(t, wristScale);
886            left.push({ x: pt.x + nx * w * 0.5, y: pt.y + ny * w * 0.5 });
887            right.push({ x: pt.x - nx * w * 0.5, y: pt.y - ny * w * 0.5 });
888        }
889
890        var armColor = resolveColor(config.arm, 0.2, now);
891        var outlineColor = resolveColor(config.outline, 0.5, now);
892        if (usePixelStyle) {
893            drawFuzzyPixelArm(left, right, getSpriteArmColors(now));
894            return;
895        }
896
897        ctx.fillStyle = armColor;
898        ctx.beginPath();
899        ctx.moveTo(left[0].x, left[0].y);
900        for (i = 1; i < left.length; i++) ctx.lineTo(left[i].x, left[i].y);
901        for (i = right.length - 1; i >= 0; i--) ctx.lineTo(right[i].x, right[i].y);
902        ctx.closePath();
903        ctx.fill();
904
905        if (config.outlineShow) {
906            ctx.strokeStyle = resolveColor(config.outline, 0.5, now);
907            ctx.lineWidth = BASE_OUTLINE * (config.outlineThickness / 100);
908            ctx.stroke();
909        }
910    }
911
912    function drawForearmCuff(layout, metrics, now, wristSquish) {
913        var wristScale = wristSquish || 1;
914        var w = metrics.cuffWidth * wristScale;
915        var h = metrics.forearmOverlap * 0.95;
916        ctx.save();
917        ctx.translate(layout.attachX, layout.attachY);
918        ctx.rotate(layout.aim);
919        if (metrics.useSprite) {
920            ctx.imageSmoothingEnabled = false;
921            var rw = Math.max(4, snapPixel(w));
922            var rh = Math.max(3, snapPixel(h));
923            var cx = snapPixel(metrics.forearmOverlap * 0.45);
924            ctx.fillStyle = SPRITE_ARM_COLORS.fill;
925            ctx.fillRect(cx - Math.round(rw * 0.5), -Math.round(rh * 0.5), rw, rh);
926            ctx.fillStyle = SPRITE_ARM_COLORS.edge;
927            ctx.fillRect(cx - Math.round(rw * 0.5), -Math.round(rh * 0.5), rw, 1);
928            ctx.fillRect(cx - Math.round(rw * 0.5), Math.round(rh * 0.5) - 1, rw, 1);
929            ctx.fillRect(cx - Math.round(rw * 0.5), -Math.round(rh * 0.5), 1, rh);
930            ctx.fillRect(cx + Math.round(rw * 0.5) - 1, -Math.round(rh * 0.5), 1, rh);
931            ctx.restore();
932            return;
933        }
934        var armColor = resolveColor(config.arm, 0.2, now);
935        ctx.fillStyle = armColor;
936        ctx.beginPath();
937        ctx.ellipse(metrics.forearmOverlap * 0.45, 0, w * 0.5, h * 0.5, 0, 0, Math.PI * 2);
938        ctx.fill();
939        if (config.outlineShow) {
940            ctx.strokeStyle = resolveColor(config.outline, 0.5, now);
941            ctx.lineWidth = BASE_OUTLINE * (config.outlineThickness / 100);
942            ctx.stroke();
943        }
944        ctx.restore();
945    }
946
947    function drawBean(x, y, rx, ry, rot) {
948        ctx.save();
949        ctx.translate(x, y);
950        ctx.rotate(rot);
951        ctx.beginPath();
952        ctx.ellipse(0, 0, rx, ry, 0, 0, Math.PI * 2);
953        ctx.fill();
954        if (config.outlineShow) ctx.stroke();
955        ctx.restore();
956    }
957
958    function drawCatPawBody(metrics, furColor, armColor) {
959        var s = metrics.scale;
960        var ps = metrics.padScale;
961        ctx.fillStyle = furColor;
962        ctx.beginPath();
963        ctx.moveTo(-10 * s * ps, 10 * s);
964        ctx.quadraticCurveTo(-11 * s, 4 * s, -8 * s * ps, -4 * s);
965        ctx.quadraticCurveTo(-4 * s, -12 * s * ps, 0, -13 * s * ps);
966        ctx.quadraticCurveTo(4 * s, -12 * s * ps, 8 * s * ps, -4 * s);
967        ctx.quadraticCurveTo(11 * s, 4 * s, 10 * s * ps, 10 * s);
968        ctx.quadraticCurveTo(6 * s, 14 * s, 0, 14 * s);
969        ctx.quadraticCurveTo(-6 * s, 14 * s, -10 * s * ps, 10 * s);
970        ctx.closePath();
971        ctx.fill();
972        if (config.outlineShow) ctx.stroke();
973
974        ctx.fillStyle = armColor;
975        ctx.beginPath();
976        ctx.moveTo(-9 * s * ps, 10 * s);
977        ctx.quadraticCurveTo(-8 * s, 16 * s, 0, 17 * s);
978        ctx.quadraticCurveTo(8 * s, 16 * s, 9 * s * ps, 10 * s);
979        ctx.closePath();
980        ctx.fill();
981        if (config.outlineShow) ctx.stroke();
982    }
983
984    function drawDogPawBody(metrics, furColor, armColor) {
985        var s = metrics.scale;
986        var ps = metrics.padScale;
987        ctx.fillStyle = furColor;
988        ctx.beginPath();
989        ctx.moveTo(-12 * s * ps, 11 * s);
990        ctx.quadraticCurveTo(-13 * s, 3 * s, -9 * s * ps, -5 * s);
991        ctx.quadraticCurveTo(-5 * s, -13 * s * ps, 0, -14 * s * ps);
992        ctx.quadraticCurveTo(5 * s, -13 * s * ps, 9 * s * ps, -5 * s);
993        ctx.quadraticCurveTo(13 * s, 3 * s, 12 * s * ps, 11 * s);
994        ctx.quadraticCurveTo(7 * s, 15 * s, 0, 15 * s);
995        ctx.quadraticCurveTo(-7 * s, 15 * s, -12 * s * ps, 11 * s);
996        ctx.closePath();
997        ctx.fill();
998        if (config.outlineShow) ctx.stroke();
999
1000        ctx.fillStyle = armColor;
1001        ctx.beginPath();
1002        ctx.moveTo(-10 * s * ps, 11 * s);
1003        ctx.quadraticCurveTo(-9 * s, 17 * s, 0, 18 * s);
1004        ctx.quadraticCurveTo(9 * s, 17 * s, 10 * s * ps, 11 * s);
1005        ctx.closePath();
1006        ctx.fill();
1007        if (config.outlineShow) ctx.stroke();
1008    }
1009
1010    function drawCatPads(metrics, padColor) {
1011        var s = metrics.scale;
1012        var ps = metrics.padScale;
1013        ctx.fillStyle = padColor;
1014        var toes = [
1015            [-6.5, -8.5, 2.8, 3.1, -0.3],
1016            [-2.2, -11, 2.6, 3, 0],
1017            [2.2, -11, 2.6, 3, 0],
1018            [6.5, -8.5, 2.8, 3.1, 0.3]
1019        ];
1020        toes.forEach(function (toe) {
1021            drawBean(toe[0] * s * ps, toe[1] * s * ps, toe[2] * s * ps, toe[3] * s * ps, toe[4]);
1022        });
1023        ctx.beginPath();
1024        ctx.ellipse(0, 4.5 * s * ps, 5 * s * ps, 4.2 * s * ps, 0, 0, Math.PI * 2);
1025        ctx.fill();
1026        if (config.outlineShow) ctx.stroke();
1027    }
1028
1029    function drawDogPads(metrics, padColor) {
1030        var s = metrics.scale;
1031        var ps = metrics.padScale;
1032        ctx.fillStyle = padColor;
1033        var toes = [
1034            [-8, -9, 3, 2.5, -0.2],
1035            [-2.8, -11.5, 2.8, 2.3, 0],
1036            [2.8, -11.5, 2.8, 2.3, 0],
1037            [8, -9, 3, 2.5, 0.2]
1038        ];
1039        toes.forEach(function (toe) {
1040            drawBean(toe[0] * s * ps, toe[1] * s * ps, toe[2] * s * ps, toe[3] * s * ps, toe[4]);
1041        });
1042        ctx.beginPath();
1043        ctx.ellipse(0, 5.5 * s * ps, 5.8 * s * ps, 4.6 * s * ps, 0, 0, Math.PI * 2);
1044        ctx.fill();
1045        if (config.outlineShow) ctx.stroke();
1046    }
1047
1048    function drawProceduralPaw(hotspotX, hotspotY, layout, metrics, now) {
1049        var furColor = resolveColor(config.pawFur, 0.1, now);
1050        var padColor = resolveColor(config.pawPads, 0.6, now);
1051        var armColor = resolveColor(config.arm, 0.2, now);
1052
1053        ctx.save();
1054        ctx.translate(hotspotX, hotspotY);
1055        ctx.rotate(getPawAngle(layout));
1056        ctx.scale(squish, 2 - squish);
1057        ctx.translate(0, metrics.hotspotOffset);
1058
1059        ctx.strokeStyle = resolveColor(config.outline, 0.5, now);
1060        ctx.lineWidth = BASE_OUTLINE * (config.outlineThickness / 100);
1061        ctx.lineJoin = 'round';
1062
1063        if (config.pawSpecies === 'dog') {
1064            if (config.showBeans !== false) drawDogPads(metrics, padColor);
1065            drawDogPawBody(metrics, furColor, armColor);
1066        } else {
1067            if (config.showBeans !== false) drawCatPads(metrics, padColor);
1068            drawCatPawBody(metrics, furColor, armColor);
1069        }
1070
1071        ctx.restore();
1072    }
1073
1074    function drawSpritePaw(hotspotX, hotspotY, layout, metrics, now) {
1075        var presetId = getActivePresetId();
1076        var baseImg = getTintedSprite(now);
1077        var beansImg = pawSpriteBeans[presetId];
1078        var dims = getSpriteDimensions();
1079        var meta = metrics.spriteMeta || getPawSpriteMeta(presetId);
1080        if (!baseImg || !dims) {
1081            drawProceduralPaw(hotspotX, hotspotY, layout, metrics, now);
1082            return;
1083        }
1084
1085        var pawAngle = getPawAngle(layout);
1086        var wristScale = getWristSquish();
1087        var drawX = -dims.width * 0.5;
1088        var drawY = -dims.height * meta.wristY;
1089
1090        ctx.save();
1091        ctx.imageSmoothingEnabled = false;
1092        ctx.translate(layout.attachX, layout.attachY);
1093        ctx.rotate(pawAngle);
1094        if (config.clickSquish && wristScale !== 1) {
1095            ctx.scale(wristScale, wristScale);
1096        }
1097        ctx.drawImage(baseImg, drawX, drawY, dims.width, dims.height);
1098        if (config.showBeans !== false && beansImg && beansImg.naturalWidth) {
1099            ctx.drawImage(beansImg, drawX, drawY, dims.width, dims.height);
1100        }
1101        ctx.restore();
1102    }
1103
1104    function drawUnifiedPaw(hotspotX, hotspotY, layout, metrics, now) {
1105        if (metrics.useSprite) {
1106            drawSpritePaw(hotspotX, hotspotY, layout, metrics, now);
1107            return;
1108        }
1109        drawProceduralPaw(hotspotX, hotspotY, layout, metrics, now);
1110    }
1111
1112    function drawClickHotspot(x, y, now) {
1113        var outline = resolveColor(config.outline, 0.5, now);
1114        ctx.save();
1115        ctx.strokeStyle = outline;
1116        ctx.fillStyle = 'rgba(255,255,255,0.92)';
1117        ctx.lineWidth = 1.4;
1118
1119        ctx.beginPath();
1120        ctx.arc(x, y, 3.2, 0, Math.PI * 2);
1121        ctx.fill();
1122        ctx.stroke();
1123
1124        ctx.globalAlpha = 0.75;
1125        ctx.beginPath();
1126        ctx.moveTo(x - 5, y);
1127        ctx.lineTo(x + 5, y);
1128        ctx.moveTo(x, y - 5);
1129        ctx.lineTo(x, y + 5);
1130        ctx.stroke();
1131        ctx.restore();
1132    }
1133
1134    function spawnPrint(x, y) {
1135        if (!config.printsEnabled || !printLayer) return;
1136        var max = config.printMax;
1137        while (prints.length >= max) {
1138            var old = prints.shift();
1139            if (old && old.el && old.el.parentNode) old.el.remove();
1140        }
1141        var el = document.createElement('div');
1142        var presetId = getActivePresetId();
1143        var spritePath = getPawSpritePath(presetId);
1144        var beansPath = getPawSpriteBeansPath(presetId);
1145        el.className = 'cat-paw-print' + (shouldUsePawSprite() ? ' cat-paw-print--sprite' : '');
1146        el.setAttribute('aria-hidden', 'true');
1147        var size = 28 * (config.printSize / 100);
1148        var fadeMs = PRINT_BASE_MS * (config.printFade / 100);
1149        el.style.width = size + 'px';
1150        el.style.height = size + 'px';
1151        el.style.left = (x - size * 0.5) + 'px';
1152        el.style.top = (y - size * 0.5) + 'px';
1153        el.style.setProperty('--cat-paw-print-fade', fadeMs + 'ms');
1154        el.dataset.species = config.pawPreset || config.pawSpecies;
1155        if (shouldUsePawSprite() && spritePath) {
1156            if (config.showBeans !== false && beansPath) {
1157                el.style.backgroundImage = 'url("' + beansPath + '"), url("' + spritePath + '")';
1158                el.style.backgroundSize = 'contain, contain';
1159                el.style.backgroundRepeat = 'no-repeat, no-repeat';
1160                el.style.backgroundPosition = 'center, center';
1161            } else {
1162                el.style.backgroundImage = 'url("' + spritePath + '")';
1163                el.style.backgroundSize = 'contain';
1164                el.style.backgroundRepeat = 'no-repeat';
1165                el.style.backgroundPosition = 'center';
1166            }
1167        } else {
1168            el.style.color = resolveColor(config.prints, 0.5, performance.now());
1169        }
1170        printLayer.appendChild(el);
1171        prints.push({ el: el });
1172        global.setTimeout(function () {
1173            if (el.parentNode) el.remove();
1174            prints = prints.filter(function (p) { return p.el !== el; });
1175        }, fadeMs + 80);
1176    }
1177
1178    function ensureStyles() {
1179        if (document.getElementById('cat-paw-arm-style')) return;
1180        var style = document.createElement('style');
1181        style.id = 'cat-paw-arm-style';
1182        style.textContent =
1183            'html.cat-paw-arm-active, html.cat-paw-arm-active *, ' +
1184            'body.cat-paw-arm-active, body.cat-paw-arm-active * { cursor: none !important; }' +
1185            'body.cat-paw-arm-active #settings-panel, body.cat-paw-arm-active #settings-panel *, ' +
1186            'body.cat-paw-arm-active #settings-backdrop, body.cat-paw-arm-active .settings-dialog-backdrop, ' +
1187            'body.cat-paw-arm-active .settings-dialog-backdrop * { cursor: auto !important; }' +
1188            'body.cat-paw-arm-active #settings-panel button, body.cat-paw-arm-active #settings-panel .settings-switch__input, ' +
1189            'body.cat-paw-arm-active #settings-panel .settings-switch__track, body.cat-paw-arm-active #settings-panel label { cursor: pointer !important; }' +
1190            '#cat-paw-arm-overlay, #cat-paw-arm-overlay * { pointer-events: none !important; touch-action: none !important; }' +
1191            '#cat-paw-print-layer { position: fixed; inset: 0; pointer-events: none; z-index: 101598; overflow: hidden; }' +
1192            '.cat-paw-print--sprite { opacity: 0.72; image-rendering: pixelated; image-rendering: crisp-edges; }' +
1193            '.cat-paw-print--sprite::before, .cat-paw-print--sprite::after { content: none !important; display: none !important; }' +
1194            '.cat-paw-print { position: fixed; pointer-events: none; opacity: 0.62; transform: scale(1); ' +
1195            'animation: catPawPrintFade var(--cat-paw-print-fade, 3s) ease-out forwards; }' +
1196            '.cat-paw-print::before { content: ""; position: absolute; inset: 0; background: currentColor; ' +
1197            'mask-image: radial-gradient(ellipse 38% 34% at 50% 58%, #000 96%, transparent 97%); ' +
1198            '-webkit-mask-image: radial-gradient(ellipse 38% 34% at 50% 58%, #000 96%, transparent 97%); }' +
1199            '.cat-paw-print[data-species="cat"]::after { content: ""; position: absolute; inset: 0; background: currentColor; ' +
1200            'mask: radial-gradient(circle at 26% 24%, #000 18%, transparent 19%), radial-gradient(circle at 74% 24%, #000 18%, transparent 19%), ' +
1201            'radial-gradient(circle at 38% 12%, #000 16%, transparent 17%), radial-gradient(circle at 62% 12%, #000 16%, transparent 17%); ' +
1202            '-webkit-mask: radial-gradient(circle at 26% 24%, #000 18%, transparent 19%), radial-gradient(circle at 74% 24%, #000 18%, transparent 19%), ' +
1203            'radial-gradient(circle at 38% 12%, #000 16%, transparent 17%), radial-gradient(circle at 62% 12%, #000 16%, transparent 17%); }' +
1204            '.cat-paw-print[data-species="dog"]::after { content: ""; position: absolute; inset: 0; background: currentColor; ' +
1205            'mask: radial-gradient(ellipse 16% 12% at 18% 28%, #000 96%, transparent 97%), radial-gradient(ellipse 16% 12% at 82% 28%, #000 96%, transparent 97%), ' +
1206            'radial-gradient(ellipse 14% 11% at 36% 14%, #000 96%, transparent 97%), radial-gradient(ellipse 14% 11% at 64% 14%, #000 96%, transparent 97%); ' +
1207            '-webkit-mask: radial-gradient(ellipse 16% 12% at 18% 28%, #000 96%, transparent 97%), radial-gradient(ellipse 16% 12% at 82% 28%, #000 96%, transparent 97%), ' +
1208            'radial-gradient(ellipse 14% 11% at 36% 14%, #000 96%, transparent 97%), radial-gradient(ellipse 14% 11% at 64% 14%, #000 96%, transparent 97%); }' +
1209            '@keyframes catPawPrintFade { 0% { opacity: 0.62; transform: scale(1); } 100% { opacity: 0; transform: scale(0.82); } }' +
1210            '@media (prefers-reduced-motion: reduce) { .cat-paw-print { animation-duration: 0.01ms; } }';
1211        document.head.appendChild(style);
1212    }
1213
1214    function setCursorActive(active) {
1215        document.documentElement.classList.toggle('cat-paw-arm-active', active);
1216        document.body.classList.toggle('cat-paw-arm-active', active);
1217    }
1218
1219    function onResize() {
1220        if (!canvas) return;
1221        canvas.width = global.innerWidth;
1222        canvas.height = global.innerHeight;
1223    }
1224
1225    var downLock = false;
1226
1227    function onMouseMove(e) {
1228        target.x = e.clientX;
1229        target.y = e.clientY;
1230        global.__pupwifeLastMouseX = e.clientX;
1231        global.__pupwifeLastMouseY = e.clientY;
1232    }
1233
1234    function onPointerMove(e) {
1235        if (e.pointerType === 'touch') return;
1236        onMouseMove(e);
1237    }
1238
1239    function onMouseDown(e) {
1240        if (downLock) return;
1241        downLock = true;
1242        if (config.clickSquish && !prefersReducedMotion()) {
1243            squishTarget = 0.82;
1244        }
1245        if (config.printsEnabled) spawnPrint(e.clientX, e.clientY);
1246    }
1247
1248    function onPointerDown(e) {
1249        if (e.pointerType === 'touch') return;
1250        onMouseDown(e);
1251    }
1252
1253    function onMouseUp() {
1254        downLock = false;
1255        squishTarget = 1;
1256    }
1257
1258    function tick(now) {
1259        if (!isActive) return;
1260        var reduced = prefersReducedMotion();
1261        var lag = config.followLag / 100;
1262        var follow = reduced ? 1 : Math.max(0.12, 0.38 - lag * 0.0026);
1263        display.x = lerp(display.x, target.x, follow);
1264        display.y = lerp(display.y, target.y, follow);
1265        squish = lerp(squish, squishTarget, reduced ? 1 : 0.28);
1266
1267        var anchor = getAnchor();
1268        var metrics = getPawMetrics();
1269        var pawPoint = { x: target.x, y: target.y };
1270        var layout = getPawLayout(anchor, pawPoint, metrics);
1271        var pixelStyle = !!metrics.useSprite;
1272
1273        ctx.clearRect(0, 0, canvas.width, canvas.height);
1274        ctx.imageSmoothingEnabled = !pixelStyle;
1275
1276        var wristSquish = getWristSquish();
1277        drawTaperedArm(anchor.x, anchor.y, layout.armEndX, layout.armEndY, metrics.cuffWidth, now, pixelStyle, wristSquish, layout.sideX, layout.sideY);
1278        if (!pixelStyle) drawForearmCuff(layout, metrics, now, wristSquish);
1279        drawUnifiedPaw(pawPoint.x, pawPoint.y, layout, metrics, now);
1280        if (!pixelStyle) drawClickHotspot(target.x, target.y, now);
1281
1282        animId = global.requestAnimationFrame(tick);
1283    }
1284
1285    function onSettingChanged(e) {
1286        if (!e || !e.detail) return;
1287        if (e.detail.key === 'catPawArm' || e.detail.key === 'customCursor' || e.detail.key === 'performanceMode') {
1288            if (isPerformanceMode()) {
1289                setCursor('none');
1290                return;
1291            }
1292            if (global.SettingsMenu && global.SettingsMenu.getSetting('customCursor') !== 'cat-paw-arm') {
1293                if (isActive) setCursor('none');
1294                return;
1295            }
1296            applySettings(readConfig());
1297            if (!isActive && global.SettingsMenu && global.SettingsMenu.getSetting('customCursor') === 'cat-paw-arm') {
1298                setCursor('cat-paw-arm');
1299            }
1300        }
1301    }
1302
1303    function applySettings(next) {
1304        config = mergeConfig(next);
1305        clearTintedSpriteCache();
1306        if (getActivePresetId()) preloadPawSprite(getActivePresetId());
1307        if (!isActive) return;
1308        if (printLayer) {
1309            prints.forEach(function (p) {
1310                if (p.el && p.el.parentNode) p.el.remove();
1311            });
1312            prints = [];
1313        }
1314    }
1315
1316    function initOverlay() {
1317        ensureStyles();
1318        preloadPawSprites();
1319        if (!overlay) {
1320            overlay = document.createElement('div');
1321            overlay.id = 'cat-paw-arm-overlay';
1322            overlay.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:101600;';
1323            canvas = document.createElement('canvas');
1324            canvas.style.cssText = 'width:100%;height:100%;display:block;image-rendering:pixelated;image-rendering:crisp-edges;';
1325            overlay.appendChild(canvas);
1326            printLayer = document.createElement('div');
1327            printLayer.id = 'cat-paw-print-layer';
1328            document.body.appendChild(printLayer);
1329            document.body.appendChild(overlay);
1330            ctx = canvas.getContext('2d');
1331        }
1332        onResize();
1333    }
1334
1335    function bindEvents() {
1336        global.addEventListener('mousemove', onMouseMove, { passive: true });
1337        global.addEventListener('pointermove', onPointerMove, { passive: true });
1338        global.addEventListener('mousedown', onMouseDown, { passive: true });
1339        global.addEventListener('pointerdown', onPointerDown, { passive: true });
1340        global.addEventListener('mouseup', onMouseUp, { passive: true });
1341        global.addEventListener('pointerup', onMouseUp, { passive: true });
1342        global.addEventListener('resize', onResize);
1343        document.addEventListener('pupwife:setting-changed', onSettingChanged);
1344    }
1345
1346    function unbindEvents() {
1347        global.removeEventListener('mousemove', onMouseMove);
1348        global.removeEventListener('pointermove', onPointerMove);
1349        global.removeEventListener('mousedown', onMouseDown);
1350        global.removeEventListener('pointerdown', onPointerDown);
1351        global.removeEventListener('mouseup', onMouseUp);
1352        global.removeEventListener('pointerup', onMouseUp);
1353        global.removeEventListener('resize', onResize);
1354        document.removeEventListener('pupwife:setting-changed', onSettingChanged);
1355    }
1356
1357    function cleanup() {
1358        isActive = false;
1359        if (animId) {
1360            global.cancelAnimationFrame(animId);
1361            animId = null;
1362        }
1363        unbindEvents();
1364        setCursorActive(false);
1365        if (prints.length) {
1366            prints.forEach(function (p) {
1367                if (p.el && p.el.parentNode) p.el.remove();
1368            });
1369            prints = [];
1370        }
1371        if (overlay && overlay.parentNode) overlay.remove();
1372        if (printLayer && printLayer.parentNode) printLayer.remove();
1373        overlay = null;
1374        canvas = null;
1375        printLayer = null;
1376        ctx = null;
1377    }
1378
1379    function setCursor(type) {
1380        if (type !== 'cat-paw-arm' || isPerformanceMode() || isCoarsePointer()) {
1381            cleanup();
1382            return;
1383        }
1384        if (isActive) {
1385            applySettings(readConfig());
1386            return;
1387        }
1388        config = readConfig();
1389        initOverlay();
1390        bindEvents();
1391        isActive = true;
1392        setCursorActive(true);
1393        target.x = typeof global.__pupwifeLastMouseX === 'number' ? global.__pupwifeLastMouseX : global.innerWidth / 2;
1394        target.y = typeof global.__pupwifeLastMouseY === 'number' ? global.__pupwifeLastMouseY : global.innerHeight / 2;
1395        display.x = target.x;
1396        display.y = target.y;
1397        squish = 1;
1398        squishTarget = 1;
1399        if (animId) global.cancelAnimationFrame(animId);
1400        animId = global.requestAnimationFrame(tick);
1401        emitDevLog('Stretchy paw cursor active');
1402    }
1403
1404    function readSavedCursor() {
1405        try {
1406            var raw = global.localStorage && global.localStorage.getItem('puppywifey_settings');
1407            if (!raw) return null;
1408            var parsed = JSON.parse(raw);
1409            if (parsed.customCursor === 'cat-paw-arm') return 'cat-paw-arm';
1410        } catch (e) { /* ignore */ }
1411        return null;
1412    }
1413
1414    global.CatPawArmCursor = {
1415        setCursor: setCursor,
1416        applySettings: applySettings,
1417        mergeConfig: mergeConfig,
1418        defaultConfig: defaultConfig,
1419        ANCHOR_PRESETS: ANCHOR_PRESETS,
1420        PAW_PRESETS: PAW_PRESETS,
1421        PAW_SPRITE_PATHS: PAW_SPRITE_PATHS,
1422        PAW_SPRITE_META: PAW_SPRITE_META,
1423        getPresetOptions: getPresetOptions,
1424        hasSpriteArt: hasSpriteArt,
1425        hasPresetArt: hasPresetArt,
1426        cleanup: cleanup
1427    };
1428
1429    preloadPawSprites();
1430
1431    document.addEventListener('pupwife:apply-custom-cursor', function () {
1432        var saved = readSavedCursor();
1433        if (saved) setCursor(saved);
1434    });
1435})(typeof window !== 'undefined' ? window : this);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.