PageSourceSearch

https://pupwife.com/scripts/desktop/citrusFolderIcon.js

js pupwife.com collected 2026-09-30 15:04:14 UTC 15,222 bytes, 418 lines download raw bytes

1/**
2 * CitrusOS folder icon spritesheet — layered back/peek/front with fanned child tiles.
3 * Spritesheet: /images/CitrusOS/CitrusOS_folder.png (3×1 @ 640px per frame).
4 */
5(function (global) {
6    'use strict';
7
8    var SPRITE_SRC = '/images/CitrusOS/CitrusOS_folder.png';
9    var ICON_TYPE = 'citrus-folder';
10    var FRAME_COUNT = 3;
11    var PEEK_MAX_DEFAULT = 5;
12
13    var FRAMES = {
14        composite: 0,
15        back: 1,
16        front: 2
17    };
18
19    var PEEK_MAX = {
20        desktop: 5,
21        start: 5,
22        grid: 5,
23        window: 0
24    };
25
26    var FRAME_PX = 640;
27    var PEEK_VISIBLE_RATIO = 0.5;
28    var geometryReady = false;
29
30  /** Fallback from spritesheet analysis (foreground frame flap top). */
31    var DEFAULT_GEOMETRY = {
32        flapTopPct: 38.12,
33        peekCenterXPct: 52,
34        tileSizePct: {
35            desktop: 34,
36            start: 30,
37            grid: 36,
38            window: 0
39        }
40    };
41
42    /** Nudge peek fan toward the skewed flap lip after pocket centroid is computed. */
43    var PEEK_CENTER_BIAS_PCT = 3;
44
45    function peekTileSizePct(variant) {
46        if (variant && DEFAULT_GEOMETRY.tileSizePct[variant] != null) {
47            return DEFAULT_GEOMETRY.tileSizePct[variant];
48        }
49        return 34;
50    }
51
52    function peekBottomPct(flapTopPct, tileSizePct) {
53        return 100 - flapTopPct - (tileSizePct * PEEK_VISIBLE_RATIO);
54    }
55
56    function applyGeometry(geo) {
57        var root = document.documentElement;
58        var flapTop = geo.flapTopPct;
59        var peekCenterX = geo.peekCenterXPct != null ? geo.peekCenterXPct : DEFAULT_GEOMETRY.peekCenterXPct;
60        root.style.setProperty('--citrus-folder-flap-top-pct', flapTop.toFixed(3));
61        root.style.setProperty('--citrus-folder-peek-center-pct', peekCenterX.toFixed(3));
62        root.style.setProperty('--citrus-folder-peek-visible-ratio', String(PEEK_VISIBLE_RATIO));
63
64        var variants = ['desktop', 'start', 'grid'];
65        for (var i = 0; i < variants.length; i++) {
66            var variant = variants[i];
67            var tilePct = peekTileSizePct(variant);
68            var bottomPct = peekBottomPct(flapTop, tilePct);
69            root.style.setProperty('--citrus-folder-peek-tile-' + variant + '-pct', String(tilePct));
70            root.style.setProperty('--citrus-folder-peek-bottom-' + variant + '-pct', bottomPct.toFixed(3));
71        }
72
73        geometryReady = true;
74        try {
75            global.dispatchEvent(new CustomEvent('pupwife:citrus-folder-geometry-ready', { detail: geo }));
76        } catch (e) { /* ignore */ }
77    }
78
79    function readFrameData(ctx, img, frameIndex) {
80        ctx.clearRect(0, 0, FRAME_PX, FRAME_PX);
81        ctx.drawImage(
82            img,
83            frameIndex * FRAME_PX,
84            0,
85            FRAME_PX,
86            FRAME_PX,
87            0,
88            0,
89            FRAME_PX,
90            FRAME_PX
91        );
92        return ctx.getImageData(0, 0, FRAME_PX, FRAME_PX).data;
93    }
94
95    function analyzeSpriteGeometry(img) {
96        var canvas = document.createElement('canvas');
97        canvas.width = FRAME_PX;
98        canvas.height = FRAME_PX;
99        var ctx = canvas.getContext('2d', { willReadFrequently: true });
100        if (!ctx) return null;
101
102        var frontData = readFrameData(ctx, img, FRAMES.front);
103        var backData = readFrameData(ctx, img, FRAMES.back);
104        var bandL = Math.floor(FRAME_PX * 0.22);
105        var bandR = Math.floor(FRAME_PX * 0.78);
106        var alphaThreshold = 40;
107        var rowThreshold = 0.08;
108        var flapTop = null;
109
110        for (var y = 0; y < FRAME_PX; y++) {
111            var opaque = 0;
112            var total = 0;
113            for (var x = bandL; x < bandR; x++) {
114                if (frontData[(y * FRAME_PX + x) * 4 + 3] > alphaThreshold) opaque++;
115                total++;
116            }
117            if (opaque / total > rowThreshold) {
118                flapTop = y;
119                break;
120            }
121        }
122
123        if (flapTop == null) return null;
124
125        var pocketSum = 0;
126        var pocketCount = 0;
127        var flapRowSum = 0;
128        var flapRowCount = 0;
129        var yStart = Math.max(0, flapTop - 28);
130
131        for (var py = yStart; py < flapTop; py++) {
132            for (var px = bandL; px < bandR; px++) {
133                var backAlpha = backData[(py * FRAME_PX + px) * 4 + 3];
134                var frontAlpha = frontData[(py * FRAME_PX + px) * 4 + 3];
135                if (backAlpha > alphaThreshold && frontAlpha <= alphaThreshold) {
136                    pocketSum += px;
137                    pocketCount++;
138                }
139            }
140        }
141
142        for (var fx = bandL; fx < bandR; fx++) {
143            if (frontData[(flapTop * FRAME_PX + fx) * 4 + 3] > alphaThreshold) {
144                flapRowSum += fx;
145                flapRowCount++;
146            }
147        }
148
149        var pocketCenterXPct = pocketCount ? (pocketSum / pocketCount / FRAME_PX) * 100 : 50;
150        var flapRowCenterXPct = flapRowCount ? (flapRowSum / flapRowCount / FRAME_PX) * 100 : pocketCenterXPct;
151        var peekCenterXPct = pocketCenterXPct +
152            (flapRowCenterXPct - pocketCenterXPct) * 0.45 +
153            PEEK_CENTER_BIAS_PCT;
154
155        return {
156            flapTopPct: (flapTop / FRAME_PX) * 100,
157            peekCenterXPct: peekCenterXPct
158        };
159    }
160
161    function initSpriteGeometry() {
162        applyGeometry({
163            flapTopPct: DEFAULT_GEOMETRY.flapTopPct,
164            peekCenterXPct: DEFAULT_GEOMETRY.peekCenterXPct
165        });
166
167        var img = new Image();
168        img.decoding = 'async';
169        img.onload = function () {
170            var analyzed = analyzeSpriteGeometry(img);
171            if (analyzed && analyzed.flapTopPct) {
172                applyGeometry(analyzed);
173            }
174        };
175        img.src = SPRITE_SRC;
176    }
177
178    function isFolderIcon(item) {
179        if (!item) return false;
180        if (item.iconType === ICON_TYPE) return true;
181        if (item.type === 'folder') return true;
182        if (item.launchTier === 'folder') return true;
183        return false;
184    }
185
186    function escapeHtml(value) {
187        return String(value || '')
188            .replace(/&/g, '&amp;')
189            .replace(/"/g, '&quot;')
190            .replace(/</g, '&lt;');
191    }
192
193    function createSpriteElement(variant, frame) {
194        var el = document.createElement('span');
195        el.className = 'citrus-folder-sprite' + (variant ? ' citrus-folder-sprite--' + variant : '');
196        el.setAttribute('data-citrus-folder-frame', String(frame != null ? frame : FRAMES.composite));
197        el.setAttribute('role', 'presentation');
198        el.setAttribute('aria-hidden', 'true');
199        return el;
200    }
201
202    function spriteMarkup(variant, frame) {
203        var cls = 'citrus-folder-sprite' + (variant ? ' citrus-folder-sprite--' + variant : '');
204        return '<span class="' + cls + '" data-citrus-folder-frame="' +
205            String(frame != null ? frame : FRAMES.composite) +
206            '" role="presentation" aria-hidden="true"></span>';
207    }
208
209    function peekLimitForVariant(variant) {
210        if (variant && PEEK_MAX[variant] != null) return PEEK_MAX[variant];
211        return PEEK_MAX_DEFAULT;
212    }
213
214    function fanTransform(index, count) {
215        if (count <= 0) return { rotate: 0, offsetX: 0, lift: 0, z: 1 };
216        if (count === 1) {
217            return { rotate: -3, offsetX: 0, lift: 0, z: 2 };
218        }
219        var spread = Math.min(44, 20 + (count - 1) * 6);
220        var half = (count - 1) / 2;
221        var step = count > 1 ? spread / (count - 1) : 0;
222        var rotate = -spread / 2 + step * index;
223        var offsetX = (index - half) * 5.5;
224        var lift = Math.max(0, 2 - Math.abs(rotate) * 0.04);
225        return {
226            rotate: rotate,
227            offsetX: offsetX,
228            lift: lift,
229            z: index + 1
230        };
231    }
232
233    function normalizeFaClass(icon) {
234        var fa = icon || 'fa-solid fa-window-maximize';
235        if (fa.indexOf('fa-solid') !== -1 || fa.indexOf('fa-brands') !== -1 || fa.indexOf('fa-regular') !== -1) {
236            return fa;
237        }
238        if (fa.indexOf('fa-') === 0) return 'fa-solid ' + fa;
239        return 'fa-solid fa-window-maximize';
240    }
241
242    function resolvePeekMeta(item) {
243        var href = item && item.href ? item.href : '';
244        var key = href.replace(/^\//, '').split('?')[0].split('#')[0];
245        var accent = '#5a9fd4';
246        var fa = normalizeFaClass(item && item.icon);
247        var imgSrc = '';
248
249        if (global.CitrusAppIcon && href && typeof global.CitrusAppIcon.resolveForHref === 'function') {
250            var app = global.CitrusAppIcon.resolveForHref(href, item && item.icon);
251            var spec = global.CitrusAppIcon.resolve(app);
252            if (spec.kind === 'img' && spec.src) {
253                imgSrc = spec.src;
254            } else if (spec.kind === 'fa' && spec.fa) {
255                fa = spec.fa;
256            }
257        }
258
259        if (global.ArcadeHubMeta && typeof global.ArcadeHubMeta.getGame === 'function') {
260            var game = global.ArcadeHubMeta.getGame(key);
261            if (game) {
262                if (game.accent) accent = game.accent;
263                if (!imgSrc && game.icon) fa = 'fa-solid ' + game.icon;
264            }
265        }
266
267        return {
268            fa: fa,
269            accent: accent,
270            label: item && item.label ? item.label : '',
271            imgSrc: imgSrc
272        };
273    }
274
275    function peekTileStyle(fan) {
276        return '--peek-rotate-val:' + fan.rotate +
277            ';--peek-offset-x-val:' + fan.offsetX +
278            ';--peek-lift-val:' + fan.lift +
279            ';z-index:' + fan.z;
280    }
281
282    function buildPeekTileMarkup(item, index, count) {
283        var meta = resolvePeekMeta(item);
284        var fan = fanTransform(index, count);
285        var iconMarkup = meta.imgSrc
286            ? '<img class="citrus-folder-peek-tile__img" src="' + escapeHtml(meta.imgSrc) + '" alt="" draggable="false">'
287            : '<i class="' + escapeHtml(meta.fa) + '" aria-hidden="true"></i>';
288        return '<span class="citrus-folder-peek-tile" style="' + peekTileStyle(fan) + '" title="' + escapeHtml(meta.label) + '">' +
289            '<span class="citrus-folder-peek-tile__surface" style="--peek-accent:' + escapeHtml(meta.accent) + '">' +
290            iconMarkup +
291            '</span></span>';
292    }
293
294    function buildPeekMarkup(peekItems, variant) {
295        var limit = peekLimitForVariant(variant);
296        var items = (peekItems || []).slice(0, limit);
297        if (!items.length || limit === 0) return '';
298        var html = '';
299        for (var i = 0; i < items.length; i++) {
300            html += buildPeekTileMarkup(items[i], i, items.length);
301        }
302        return html;
303    }
304
305    function buildPeekTileElement(item, index, count) {
306        var meta = resolvePeekMeta(item);
307        var fan = fanTransform(index, count);
308        var tile = document.createElement('span');
309        tile.className = 'citrus-folder-peek-tile';
310        tile.style.setProperty('--peek-rotate-val', String(fan.rotate));
311        tile.style.setProperty('--peek-offset-x-val', String(fan.offsetX));
312        tile.style.setProperty('--peek-lift-val', String(fan.lift));
313        tile.style.zIndex = String(fan.z);
314        if (meta.label) tile.title = meta.label;
315
316        var surface = document.createElement('span');
317        surface.className = 'citrus-folder-peek-tile__surface';
318        surface.style.setProperty('--peek-accent', meta.accent);
319
320        if (meta.imgSrc) {
321            var peekImg = document.createElement('img');
322            peekImg.className = 'citrus-folder-peek-tile__img';
323            peekImg.src = meta.imgSrc;
324            peekImg.alt = '';
325            peekImg.draggable = false;
326            surface.appendChild(peekImg);
327        } else {
328            var icon = document.createElement('i');
329            icon.className = meta.fa;
330            icon.setAttribute('aria-hidden', 'true');
331            surface.appendChild(icon);
332        }
333        tile.appendChild(surface);
334        return tile;
335    }
336
337    function appendPeekTiles(peekEl, peekItems, variant) {
338        if (!peekEl) return;
339        peekEl.innerHTML = '';
340        var limit = peekLimitForVariant(variant);
341        var items = (peekItems || []).slice(0, limit);
342        for (var i = 0; i < items.length; i++) {
343            peekEl.appendChild(buildPeekTileElement(items[i], i, items.length));
344        }
345    }
346
347    function createLayeredStack(variant, peekItems) {
348        var stack = document.createElement('span');
349        stack.className = 'citrus-folder-sprite-stack' + (variant ? ' citrus-folder-sprite-stack--' + variant : '');
350
351        var back = createSpriteElement('layer-back', FRAMES.back);
352        stack.appendChild(back);
353
354        var peek = document.createElement('span');
355        peek.className = 'citrus-folder-sprite-stack__peek';
356        appendPeekTiles(peek, peekItems, variant);
357        stack.appendChild(peek);
358
359        var front = createSpriteElement('layer-front', FRAMES.front);
360        stack.appendChild(front);
361
362        return stack;
363    }
364
365    function folderIconMarkup(variant, peekItems) {
366        var stackClass = 'citrus-folder-sprite-stack' + (variant ? ' citrus-folder-sprite-stack--' + variant : '');
367        return '<span class="' + stackClass + '">' +
368            spriteMarkup('layer-back', FRAMES.back) +
369            '<span class="citrus-folder-sprite-stack__peek">' + buildPeekMarkup(peekItems, variant) + '</span>' +
370            spriteMarkup('layer-front', FRAMES.front) +
371            '</span>';
372    }
373
374    function createFolderIcon(variant, peekItems) {
375        return createLayeredStack(variant, peekItems);
376    }
377
378    function resolvePeekItemsForApp(app) {
379        if (!app || !global.CitrusFolderRegistry) return [];
380        if (app.folderId && app.folderPath && app.folderPath.length &&
381            typeof global.CitrusFolderRegistry.getPeekItemsForPath === 'function') {
382            return global.CitrusFolderRegistry.getPeekItemsForPath(app.folderId, app.folderPath);
383        }
384        if (app.folderId && typeof global.CitrusFolderRegistry.getPeekItemsForHub === 'function') {
385            return global.CitrusFolderRegistry.getPeekItemsForHub(app.folderId);
386        }
387        return [];
388    }
389
390    function normalizeFolderApp(app) {
391        if (!app || !isFolderIcon(app)) return app;
392        app.icon = SPRITE_SRC;
393        app.iconType = ICON_TYPE;
394        return app;
395    }
396
397    global.CitrusFolderIcon = {
398        SPRITE_SRC: SPRITE_SRC,
399        ICON_TYPE: ICON_TYPE,
400        FRAME_COUNT: FRAME_COUNT,
401        FRAMES: FRAMES,
402        PEEK_MAX: PEEK_MAX,
403        PEEK_MAX_DEFAULT: PEEK_MAX_DEFAULT,
404        PEEK_VISIBLE_RATIO: PEEK_VISIBLE_RATIO,
405        isFolderIcon: isFolderIcon,
406        isGeometryReady: function () { return geometryReady; },
407        createSpriteElement: createSpriteElement,
408        createLayeredStack: createLayeredStack,
409        createFolderIcon: createFolderIcon,
410        folderIconMarkup: folderIconMarkup,
411        spriteMarkup: spriteMarkup,
412        resolvePeekItemsForApp: resolvePeekItemsForApp,
413        normalizeFolderApp: normalizeFolderApp,
414        initSpriteGeometry: initSpriteGeometry
415    };
416
417    initSpriteGeometry();
418})(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.