javascript
/* Procedural surface only. Never writes host card geometry, scale, order or colour allocation. */
(()=>{'use strict';
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
const palettes=[
{name:'Twilight',top:'#b7b6e4',bottom:'#f0a24a',blobs:['#8ea2e4','#e7a4cc','#f6c15c','#ee6d42'],duration:24},
{name:'Earth',top:'#9eb8b4',bottom:'#4a261c',blobs:['#6f9e98','#e0a86a','#e07a40','#6a3024'],duration:26},
{name:'Tidal',top:'#d4e2e8',bottom:'#0e3c4c',blobs:['#c5d8e4','#6aacc0','#1d7084','#0c4556'],duration:22},
{name:'Moss',top:'#d0d8d6',bottom:'#1c4c34',blobs:['#c2d4cc','#7aaa78','#3d7a52','#184830'],duration:28},
{name:'Violet',top:'#d4c0ea',bottom:'#2c184c',blobs:['#e0c4f0','#eea4d0','#8a62b8','#3c2268'],duration:25},
{name:'Oxide',top:'#d4dadd',bottom:'#8e3c2c',blobs:['#dce2e4','#f2c4a8','#e08058','#a44636'],duration:20}
];
function hash(s){let h=2166136261;for(const c of String(s)){h^=c.charCodeAt(0);h=Math.imul(h,16777619);}return h>>>0;}
function noise(size=128){let seed=97131;const bytes=new Uint8ClampedArray(size*size*4);for(let i=0;i<bytes.length;i+=4){seed=(Math.imul(seed,1664525)+1013904223)>>>0;const a=seed>>>24;seed=(Math.imul(seed,1664525)+1013904223)>>>0;const b=seed>>>24;const v=88+((a+b)>>2);bytes[i]=bytes[i+1]=bytes[i+2]=v;bytes[i+3]=255;}return bytes;}
// Mass=1, k=70, c=2*zeta*sqrt(k). Analytic under-damped step is frame-rate independent.
function spring(s,target,dt){const w=Math.sqrt(70),a=.86*w,wd=w*Math.sqrt(1-.86**2),d=s.p-target,b=(s.v+a*d)/wd,e=Math.exp(-a*dt),c=Math.cos(wd*dt),n=Math.sin(wd*dt);return{p:target+e*(d*c+b*n),v:e*((-a*d+wd*b)*c+(-a*b-wd*d)*n)};}
const near=(a,b)=>Math.min(a.x+a.w,b.x+b.w)-Math.max(a.x,b.x)>3&&Math.min(Math.abs(a.y+a.h-b.y),Math.abs(b.y+b.h-a.y))<18||Math.min(a.y+a.h,b.y+b.h)-Math.max(a.y,b.y)>3&&Math.min(Math.abs(a.x+a.w-b.x),Math.abs(b.x+b.w-a.x))<18;
function select(nodes,box){
const eligible=nodes.filter(n=>n.eligible&&n.w>=100&&n.h>=76),limit=Math.min(3,Math.max(1,Math.ceil(nodes.length*.22))),budget=box.w*box.h*.18,picked=[];let area=0;
// Keep prior admissions first; deterministic hash breaks ties. Zoom/hover cannot reshuffle skins.
eligible.sort((a,b)=>Number(b.existing)-Number(a.existing)||hash(a.id)-hash(b.id));
for(const n of eligible){if(picked.length>=limit)break;if(area+n.w*n.h>budget||picked.some(p=>near(p,n)))continue;picked.push(n);area+=n.w*n.h;}
return picked.map(n=>n.id);
}
globalThis.JournalFrost={palettes,noise,spring,select};
if(typeof document==='undefined')return;
const wall=document.querySelector('[data-frost-wall]'),board=document.querySelector('[data-bubble-board]');if(!wall&&!board)return;
const media=matchMedia('(prefers-reduced-motion: reduce)'),states=new Map(),active=new Set();
let raf=0,last=0,queueTimer=0,dead=false,tile=null,readerLocked=false;
const calm=()=>media.matches||document.body.classList.contains('reduced-motion')||wall?.dataset.reduce==='true';
const paused=()=>document.hidden||readerLocked||!!document.querySelector('#reader[open],#visual-reader[open],.desk-preferences[open],[data-memory-dialog][open]')||board?.dataset.depthMoving==='true'||board?.dataset.arriving==='true';
function pattern(kind){
const ns='http://www.w3.org/2000/svg',svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 400 400');svg.setAttribute('class','frost-pattern');svg.setAttribute('aria-hidden','true');
const add=(tag,attrs)=>{const n=document.createElementNS(ns,tag);for(const[k,v]of Object.entries(attrs))n.setAttribute(k,v);n.setAttribute('vector-effect','non-scaling-stroke');svg.append(n);};
if(kind==='rays')for(let i=0;i<36;i++){const a=i*Math.PI/18;add('path',{d:`M200 200L${200+310*Math.cos(a)} ${200+310*Math.sin(a)}`});}
if(kind==='flow')for(let j=0;j<16;j++){let d='';for(let y=-20;y<=420;y+=4){const x=28+j*23+Math.sin(y*.018+j*.55)*16;d+=(y===-20?'M':'L')+x+' '+y;}add('path',{d});}
if(kind==='contours')for(let i=0;i<13;i++){const r=30+i*17;add('ellipse',{cx:168,cy:236,rx:r,ry:r*.82,transform:'rotate(-24 168 236)'});}
if(kind==='waves')for(let j=0;j<15;j++){let d='';for(let x=-20;x<=420;x+=4){const y=44+j*23+Math.sin(x*.021+j*.19)*22;d+=(x===-20?'M':'L')+x+' '+y;}add('path',{d});}
if(kind==='dots')for(let y=20;y<400;y+=19)for(let x=20;x<400;x+=19)add('circle',{cx:x,cy:y,r:.65,fill:'white',stroke:'none'});
if(kind==='lens')for(let i=0;i<17;i++){const d=12+i*9;add('path',{d:`M200 12Q${200-d*1.65} 200 200 388Q${200+d*1.65} 200 200 12Z`});}
return svg;
}
function makeTile(){if(tile)return tile;tile=document.createElement('canvas');tile.width=tile.height=128;const ctx=tile.getContext('2d');const image=ctx.createImageData(128,128);image.data.set(noise());ctx.putImageData(image,0,0);return tile;}
// Large projected cards stay at the original 0.4 overlay. Smaller ones rise
// smoothly and stop at 0.64 — a bound, not an ever-increasing opacity.
function grainOpacity(px){const t=clamp((160-px)/96,0,1),s=t*t*(3-2*t);return .4+s*.24;}
function drawGrain(s,r){
// Bitmap is the projected device pixels, and the CSS box matches that bitmap.
// scale(layout/projected) cancels the camera so the tile is not enlarged and then filtered down.
const dpr=devicePixelRatio||1,lw=s.card.offsetWidth,lh=s.card.offsetHeight,sx=lw?r.width/lw:1,sy=lh?r.height/lh:1,c=s.canvas;
const w=Math.max(1,Math.ceil(r.width*dpr)),h=Math.max(1,Math.ceil(r.height*dpr));
c.style.setProperty('--frost-grain',grainOpacity(Math.min(r.width,r.height)).toFixed(3));
if(Math.abs(sx-1)<.01&&Math.abs(sy-1)<.01)c.style.width=c.style.height=c.style.transform='';
else if(lw&&lh){const cw=w/dpr,ch=h/dpr;c.style.width=cw+'px';c.style.height=ch+'px';c.style.transform=`scale(${(lw/cw).toFixed(5)},${(lh/ch).toFixed(5)})`;}
if(c.dataset.noisePixels===w+'×'+h&&c.width===w&&c.height===h)return;c.width=w;c.height=h;const ctx=c.getContext('2d');if(!ctx)return;ctx.imageSmoothingEnabled=false;ctx.fillStyle=ctx.createPattern(makeTile(),'repeat');ctx.fillRect(0,0,w,h);c.dataset.noiseDpr=String(dpr);c.dataset.noisePixels=w+'×'+h;
}
function parkGrain(){for(const s of states.values())s.canvas.style.width=s.canvas.style.height=s.canvas.style.transform='';}
function paletteIndex(card){const t=card.dataset.fieldTone||card.dataset.boardTone||'';return t==='lilac'||t==='violet'?4:t==='sage'||t==='moss'?3:t==='coral'||t==='red'?0:t==='sand'||t==='amber'?1:t==='blue'?2:t==='mist'?2:hash(card.dataset.contentId)%6;}
function mount(card,index,kind){
if(states.has(card))return states.get(card);
const layer=document.createElement('span');layer.className='frost-surface';layer.setAttribute('aria-hidden','true');
layer.innerHTML='<span class="frost-parallax"><span class="frost-clouds"><i></i><i></i><i></i><i></i></span></span><span class="frost-shade"></span><span class="frost-glow"></span><canvas class="frost-grain"></canvas>';
if(kind)layer.append(pattern(kind));card.prepend(layer);card.dataset.frosted='true';
const p=palettes[index];for(const[k,v]of Object.entries({top:p.top,bottom:p.bottom,...Object.fromEntries(p.blobs.map((v,i)=>['blob-'+i,v])),duration:p.duration+'s'}))layer.style.setProperty('--frost-'+k,v);
const s={card,layer,canvas:layer.querySelector('canvas'),index,x:{p:0,v:0},y:{p:0,v:0},glow:{p:0,v:0},tx:0,ty:0,tg:0,visible:false,rect:null};states.set(card,s);intersection.observe(card);sizeObserver.observe(card);return s;
}
function reset(s){s.tx=s.ty=s.tg=0;active.add(s);wake();}
function remove(card){const s=states.get(card);if(!s)return;intersection.unobserve(card);sizeObserver.unobserve(card);s.layer.remove();delete card.dataset.frosted;states.delete(card);active.delete(s);}
function write(s){s.layer.style.setProperty('--frost-x',s.x.p+'px');s.layer.style.setProperty('--frost-y',s.y.p+'px');s.layer.style.setProperty('--frost-light',s.glow.p);s.layer.style.setProperty('--frost-glow-x',(50+s.x.p*(100/24))+'%');s.layer.style.setProperty('--frost-glow-y',(50+s.y.p*(100/24))+'%');}
function frame(now){
raf=0;if(dead||calm()||paused()){last=0;return;}const dt=last?Math.min(.05,(now-last)/1000):1/60;last=now;
for(const s of active){if(!s.visible){active.delete(s);continue;}s.x=spring(s.x,s.tx,dt);s.y=spring(s.y,s.ty,dt);s.glow=spring(s.glow,s.tg,dt);write(s);
if(Math.abs(s.x.p-s.tx)+Math.abs(s.y.p-s.ty)+Math.abs(s.glow.p-s.tg)<.015&&Math.abs(s.x.v)+Math.abs(s.y.v)+Math.abs(s.glow.v)<.04){s.x={p:s.tx,v:0};s.y={p:s.ty,v:0};s.glow={p:s.tg,v:0};write(s);active.delete(s);}
}if(active.size)raf=requestAnimationFrame(frame);else last=0;
}
function wake(){if(!raf&&!calm()&&!paused()&&active.size)raf=requestAnimationFrame(frame);}
function sync(){
const stop=calm()||paused();for(const s of states.values()){s.card.dataset.frostRunning=String(!stop&&s.visible);if(stop){s.tx=s.ty=s.tg=0;s.x={p:0,v:0};s.y={p:0,v:0};s.glow={p:0,v:0};write(s);}}
if(stop){if(raf)cancelAnimationFrame(raf);raf=0;last=0;active.clear();}else wake();
}
const intersection=new IntersectionObserver(entries=>{for(const e of entries){const s=states.get(e.target);if(!s)continue;s.visible=e.isIntersecting&&!s.card.hidden;queue();}sync();});
const sizeObserver=new ResizeObserver(()=>queue());
function reconcile(){
queueTimer=0;if(dead||document.hidden)return;
if(board&&(paused()||board.dataset.bubbleState==='changing'||board.dataset.hoverState==='expanded'||board.dataset.depthHover==='true')){sync();return;}
// Batch geometry reads, then selection and writes. Never a per-pointer DOM measurement.
const cards=board?[...board.querySelectorAll('.discovery-row>.desk-card,[data-depth-card]')]:[...wall.querySelectorAll('[data-frost-card]')],rects=new Map(cards.map(c=>[c,c.getBoundingClientRect()]));
if(board){
const area=board.getBoundingClientRect(),visible=cards.filter(c=>{const r=rects.get(c),cs=getComputedStyle(c);return !c.hidden&&!c.inert&&cs.visibility!=='hidden'&&+cs.opacity>.9&&r.width>0&&r.height>0;});
const nodes=visible.map(c=>{const r=rects.get(c),article=c.dataset.contentKind==='article'&&!c.classList.contains('has-cover'),product=c.dataset.cardPresentation==='product-entry'&&c.dataset.surfaceMode==='solid';return{id:c.dataset.contentId,x:r.x,y:r.y,w:r.width,h:r.height,eligible:!c.hasAttribute('data-brand-surface')&&!c.dataset.previewKind&&(article||product),existing:states.has(c)};});
const selected=new Set(select(nodes,{w:area.width,h:area.height}));
for(const card of [...states.keys()])if(!selected.has(card.dataset.contentId)&&(!visible.includes(card)||!card.matches(':hover,:focus-within')))remove(card);
let twilight=[...states.values()].some(s=>s.index===0);
for(const card of visible)if(selected.has(card.dataset.contentId)&&!states.has(card)){let index=paletteIndex(card);if(!twilight){index=0;twilight=true;}mount(card,index,null);}
board.dataset.frostedCount=String(states.size);
}
for(const s of states.values()){const r=rects.get(s.card);if(r&&r.width&&r.height&&s.visible)drawGrain(s,r);}sync();
}
function queue(){clearTimeout(queueTimer);if(!dead&&!document.hidden)queueTimer=setTimeout(reconcile,160);}
const target=wall||board;
target.addEventListener('pointermove',e=>{if(e.pointerType==='touch'||calm()||paused())return;const card=e.target.closest('[data-frosted]'),s=states.get(card);if(!s||!s.visible)return;
if(!s.rect)s.rect=card.getBoundingClientRect();const r=s.rect;s.tx=clamp((e.clientX-r.x)/r.width-.5,-.5,.5)*24;s.ty=clamp((e.clientY-r.y)/r.height-.5,-.5,.5)*24;s.tg=1;active.add(s);wake();
},{passive:true});
target.addEventListener('pointerover',e=>{const s=states.get(e.target.closest('[data-frosted]'));if(s&&!s.card.contains(e.relatedTarget))s.rect=null;});
target.addEventListener('pointerout',e=>{const s=states.get(e.target.closest('[data-frosted]'));if(s&&!s.card.contains(e.relatedTarget)){s.rect=null;reset(s);}});
target.addEventListener('focusin',e=>{const s=states.get(e.target.closest('[data-frosted]'));if(s&&!calm()){s.tg=.45;active.add(s);wake();}});
target.addEventListener('focusout',e=>{const s=states.get(e.target.closest('[data-frosted]'));if(s&&!s.card.contains(e.relatedTarget))reset(s);});
if(wall)for(const card of wall.querySelectorAll('[data-frost-card]'))mount(card,+card.dataset.frostCard,card.dataset.frostPattern);
// An added article/project is discovered through DOM membership, not a hard-coded list.
const mutations=new MutationObserver(changes=>{if(board?.dataset.depthMoving==='true')parkGrain();if(changes.some(m=>m.type==='childList'&&([...m.addedNodes,...m.removedNodes].some(n=>n.nodeType===1&&!n.matches?.('.frost-surface')))||m.type==='attributes'))queue();});
if(board){mutations.observe(board,{childList:true,subtree:true,attributes:true,attributeFilter:['data-field-tone','data-surface-mode','data-depth-moving','data-bubble-state','data-hover-state','data-depth-hover','data-arriving']});}
const pauseObserver=new MutationObserver(()=>{sync();queue();});pauseObserver.observe(document.body,{attributes:true,attributeFilter:['class']});
for(const d of document.querySelectorAll('dialog'))pauseObserver.observe(d,{attributes:true,attributeFilter:['open']});
media.addEventListener('change',()=>{sync();queue();});document.addEventListener('visibilitychange',()=>{sync();queue();});
window.addEventListener('resize',()=>{for(const s of states.values())s.rect=null;queue();});
window.addEventListener('journal:reader-lock',()=>{readerLocked=true;sync();});window.addEventListener('journal:reader-release',()=>{readerLocked=false;sync();queue();});
for(const name of ['journal:layout-ready','journal:depth-change','journal:board-turn','journal:theme-change','journal:frost-controls'])window.addEventListener(name,()=>{for(const s of states.values())s.rect=null;sync();queue();});
window.addEventListener('pagehide',e=>{if(raf)cancelAnimationFrame(raf);raf=0;last=0;clearTimeout(queueTimer);if(e.persisted)return;dead=true;intersection.disconnect();sizeObserver.disconnect();mutations.disconnect();pauseObserver.disconnect();active.clear();});
window.addEventListener('pageshow',()=>{sync();queue();});queue();
})();