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, '&') 189 .replace(/"/g, '"') 190 .replace(/</g, '<'); 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.