{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "snow-globe",
  "type": "registry:block",
  "title": "Snow Globe",
  "description": "A water-filled glass snow globe with a cabin and pine grove inside, on a turned walnut base. Drag or flick it to shake: the water lags the glass, about two thousand flakes swirl with the shake's real acceleration, then tumble down and pile up on the roof, the boughs and the ground.",
  "docs": "Snow Globe was added to public/vividsites/snow-globe.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/snow-globe",
  "categories": [
    "3d",
    "3d-objects"
  ],
  "files": [
    {
      "path": "components/vividsites/snow-globe.html",
      "type": "registry:file",
      "target": "public/vividsites/snow-globe.html",
      "content": "<!--\n  vividsites · snow-globe\n  A water-filled glass snow globe on a turned walnut base: a pine grove and a log\n  cabin with one lit window inside. Drag (or flick) the globe to shake it: it\n  tilts and swings on springs, the water inside lags the glass, and ~2,000 flakes\n  swirl with the shake's real acceleration, tumble as they sink, slide off steep\n  roofs and boughs, and pile up where they land.\n\n  Customise (attributes, read at mount; all live through the JS API too):\n    data-snow         amount of snow, 0.1 – 1 (default .7)\n    data-tint         the glass / water tint, any CSS colour (default #e9f1f8, clear)\n    data-settle       settle speed multiplier, .3 – 3 (default 1: ~15 s to settle)\n    data-auto-shake   present = give itself a shake after ~7 s idle\n  CSS variables:\n    --vs-snow-globe-bg      the stage (floor and wall), default #0b1220\n    --vs-snow-globe-glow    the cabin window's light, default #ffa24c\n    --vs-snow-globe-wood    the base's wood colour, default #3a261a\n    --vs-snow-globe-height  component height, default 100svh\n  JS:\n    el.vsSnowGlobe.shake(strength = 1)\n    el.vsSnowGlobe.set({ snow: .9, tint: '#bfe0ea', settle: 1.5, autoShake: false })\n    el.vsSnowGlobe.destroy()\n  Input: drag / flick with mouse or touch (touch-action: pan-y, so horizontal\n  shakes on phones; set touch-action: none on the element to capture both axes),\n  Space / Enter to shake, Left / Right arrows to knock it.\n  Needs: three@0.170.0 (importmap below). Remove the importmap if your page already maps \"three\".\n-->\n<div class=\"vs-snow-globe\" data-snow=\"0.7\" data-tint=\"#e9f1f8\" data-settle=\"1\" data-auto-shake tabindex=\"0\" role=\"img\" aria-label=\"A glass snow globe with a cabin and pines inside. Drag it, or press Space, to shake the snow.\">\n  <canvas class=\"vs-snow-globe-canvas\"></canvas>\n  <div class=\"vs-snow-globe-fallback\" aria-hidden=\"true\"><i></i><b></b></div>\n</div>\n\n<style>\n  .vs-snow-globe {\n    --vs-snow-globe-bg: #0b1220;\n    --vs-snow-globe-glow: #ffa24c;\n    --vs-snow-globe-wood: #3a261a;\n    --vs-snow-globe-height: 100svh;\n    position: relative; width: 100%; height: var(--vs-snow-globe-height); min-height: 420px; overflow: hidden; isolation: isolate;\n    background: radial-gradient(70% 60% at 50% 38%, color-mix(in srgb, var(--vs-snow-globe-bg) 80%, #3a5580), var(--vs-snow-globe-bg) 70%);\n    cursor: grab; touch-action: pan-y; outline: none; -webkit-tap-highlight-color: transparent; user-select: none; -webkit-user-select: none;\n  }\n  .vs-snow-globe.is-dragging { cursor: grabbing; }\n  .vs-snow-globe:focus-visible { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--vs-snow-globe-glow) 70%, transparent); }\n  .vs-snow-globe-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity 1.2s cubic-bezier(.16,1,.3,1); }\n  .vs-snow-globe[data-vs-state=\"ready\"] .vs-snow-globe-canvas { opacity: 1; }\n  .vs-snow-globe::after { /* vignette + fine grain over the render */\n    content: \"\"; position: absolute; inset: 0; pointer-events: none;\n    background: radial-gradient(120% 95% at 50% 42%, transparent 50%, rgba(3,5,10,.6) 100%),\n      url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .5 0 0 0 0 .5 0 0 0 0 .5 0 0 0 .45 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\");\n    mix-blend-mode: overlay; opacity: .5;\n  }\n  /* no WebGL: a still, drawn globe */\n  .vs-snow-globe-fallback { position: absolute; inset: 0; display: none; place-items: center; }\n  .vs-snow-globe[data-vs-state=\"nogl\"] .vs-snow-globe-fallback { display: grid; }\n  .vs-snow-globe-fallback i { width: min(52vmin, 380px); aspect-ratio: 1; border-radius: 50%; transform: translateY(-12%);\n    background: radial-gradient(circle at 32% 26%, rgba(255,255,255,.85) 0 2%, rgba(255,255,255,.12) 7%, transparent 22%),\n      radial-gradient(circle at 50% 78%, color-mix(in srgb, var(--vs-snow-globe-glow) 55%, transparent), transparent 38%),\n      radial-gradient(circle at 50% 50%, rgba(160,190,235,.06) 62%, rgba(200,220,255,.3) 70.5%, transparent 71%);\n    box-shadow: inset 0 -30px 60px rgba(240,245,255,.18); }\n  .vs-snow-globe-fallback b { position: absolute; top: 50%; width: min(64vmin, 470px); height: min(14vmin, 100px); margin-top: min(20vmin, 150px);\n    border-radius: 12% / 40%; background: linear-gradient(#4a3222, var(--vs-snow-globe-wood) 50%, #1b120c); box-shadow: 0 30px 50px -20px rgba(0,0,0,.8); }\n</style>\n\n<script type=\"importmap\">{ \"imports\": { \"three\": \"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js\" } }</script>\n<script type=\"module\">\nimport * as THREE from 'three';\n\nconst css = (el, n, f) => getComputedStyle(el).getPropertyValue(n).trim() || f;\nconst clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));\nconst rng = (seed) => { let a = seed | 0; return () => { a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; };\n\nclass Spring { // damped spring, substepped so it is frame-rate independent; .a is the last acceleration\n  constructor(x, k, c) { this.x = x; this.v = 0; this.t = x; this.k = k; this.c = c; this.a = 0; }\n  step(dt) { const n = Math.ceil(dt / (1 / 240)), h = dt / n; let a = 0; for (let i = 0; i < n; i++) { a = -this.k * (this.x - this.t) - this.c * this.v; this.v += a * h; this.x += this.v * h; } this.a = a; return this.x; }\n}\n\n// ---------- the turned walnut: wavy \"cathedral\" bands that wrap the lathe, plus pores\nfunction woodTexture(hex) {\n  const W = 512, H = 256, c = document.createElement('canvas'); c.width = W; c.height = H;\n  const g = c.getContext('2d'), img = g.createImageData(W, H), d = img.data, r = rng(11), N = 32, T = new Float32Array(N * N);\n  for (let i = 0; i < N * N; i++) T[i] = r();\n  const vn = (x, y) => { const xi = Math.floor(x), yi = Math.floor(y), fx = x - xi, fy = y - yi, sx = fx * fx * (3 - 2 * fx), sy = fy * fy * (3 - 2 * fy);\n    const at = (a, b) => T[((b % N + N) % N) * N + ((a % N + N) % N)];\n    return (at(xi, yi) * (1 - sx) + at(xi + 1, yi) * sx) * (1 - sy) + (at(xi, yi + 1) * (1 - sx) + at(xi + 1, yi + 1) * sx) * sy; };\n  const base = new THREE.Color(hex), dark = base.clone().multiplyScalar(0.6), light = base.clone().lerp(new THREE.Color('#9a6a44'), 0.55);\n  for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {\n    const u = x / W, v = y / H;\n    const warp = vn(u * 8, v * 4) * 1.4 + vn(u * 32, v * 16) * 0.3;\n    const band = 0.5 + 0.5 * Math.sin((v * 18 + 0.7 * Math.sin(u * Math.PI * 4) + warp) * Math.PI * 2);\n    const fine = 0.5 + 0.5 * Math.sin((v * 70 + warp * 3) * Math.PI * 2);\n    const pores = vn(u * 256, v * 30) * 0.3;\n    const k = 0.25 + Math.pow(band, 1.6) * 0.35 + fine * 0.12 + pores * 0.5;\n    const col = dark.clone().lerp(light, clamp(k));\n    const i = (y * W + x) * 4; d[i] = col.r * 255; d[i + 1] = col.g * 255; d[i + 2] = col.b * 255; d[i + 3] = 255;\n  }\n  g.putImageData(img, 0, 0);\n  const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; t.wrapS = THREE.RepeatWrapping; t.repeat.set(2, 1); t.anisotropy = 8; return t;\n}\n\n// ---------- the diorama: a log cabin with one lit window, and a grove of spruce, all dusted with snow\nfunction diorama(glowColor) {\n  const holder = new THREE.Group(); holder.position.y = -0.475;\n  const r = rng(7);\n  const timber = new THREE.MeshStandardMaterial({ color: '#5a3f2c', roughness: 0.85 });\n  const timberD = new THREE.MeshStandardMaterial({ color: '#35251b', roughness: 0.9 });\n  const stone = new THREE.MeshStandardMaterial({ color: '#6d6a66', roughness: 0.95 });\n  const snowM = new THREE.MeshStandardMaterial({ color: '#eef2f7', roughness: 0.8 });\n  const needle = new THREE.MeshStandardMaterial({ color: '#1f3a2b', roughness: 0.9, flatShading: true });\n  const lit = new THREE.MeshStandardMaterial({ color: 0x000000, emissive: glowColor, emissiveIntensity: 6 });\n  const cabin = new THREE.Group(); cabin.position.set(0.1, 0, 0.16); cabin.rotation.y = -0.32; holder.add(cabin);\n  const Wd = 0.46, Dp = 0.34, Hw = 0.2, LR = 0.017, logG = new THREE.CylinderGeometry(LR, LR, 1, 10);\n  for (let i = 0; i < 6; i++) {\n    const y = 0.03 + i * LR * 1.9;\n    for (const sz of [-1, 1]) { const m = new THREE.Mesh(logG, r() > 0.5 ? timber : timberD); m.rotation.z = Math.PI / 2; m.scale.y = Wd + 0.06; m.position.set(0, y + (i % 2) * LR * 0.95, sz * Dp / 2); cabin.add(m); }\n    for (const sx of [-1, 1]) { const m = new THREE.Mesh(logG, r() > 0.5 ? timber : timberD); m.rotation.x = Math.PI / 2; m.scale.y = Dp + 0.06; m.position.set(sx * Wd / 2, y + ((i + 1) % 2) * LR * 0.95, 0); cabin.add(m); }\n  }\n  const ridge = 0.17;\n  for (let i = 0; i < 5; i++) { const y = Hw + 0.02 + i * LR * 1.9, k = 1 - (i + 0.5) / 5.4; for (const sx of [-1, 1]) { const m = new THREE.Mesh(logG, timberD); m.rotation.x = Math.PI / 2; m.scale.y = (Dp + 0.02) * k; m.position.set(sx * Wd / 2, y, 0); cabin.add(m); } }\n  const pitch = Math.atan2(ridge, Dp / 2 + 0.05), slab = Math.hypot(ridge, Dp / 2 + 0.09);\n  for (const sz of [-1, 1]) {\n    const rf = new THREE.Mesh(new THREE.BoxGeometry(Wd + 0.14, 0.018, slab), timberD); rf.position.set(0, Hw + 0.02 + ridge / 2 + 0.01, sz * (Dp / 4 + 0.02)); rf.rotation.x = sz * pitch; cabin.add(rf);\n    const sg = new THREE.BoxGeometry(Wd + 0.17, 0.045, slab + 0.02, 8, 2, 6), P = sg.attributes.position;\n    for (let i = 0; i < P.count; i++) { const x = P.getX(i), y = P.getY(i), z = P.getZ(i); if (y > 0) P.setY(i, y + 0.012 * Math.cos(x * 9) * Math.cos(z * 11) + 0.008); P.setX(i, x * (1 + 0.03 * Math.sin(z * 20))); }\n    sg.computeVertexNormals();\n    const sn = new THREE.Mesh(sg, snowM); sn.position.set(0, Hw + 0.045 + ridge / 2 + 0.01, sz * (Dp / 4 + 0.022)); sn.rotation.x = sz * pitch; cabin.add(sn);\n  }\n  const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, Wd + 0.17, 10), snowM); cap.rotation.z = Math.PI / 2; cap.position.y = Hw + 0.02 + ridge + 0.035; cabin.add(cap);\n  const ch = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.2, 0.07), stone); ch.position.set(0.13, Hw + 0.17, -0.06); cabin.add(ch);\n  const chS = new THREE.Mesh(new THREE.BoxGeometry(0.085, 0.025, 0.085), snowM); chS.position.set(0.13, Hw + 0.28, -0.06); cabin.add(chS);\n  // the one lit window (front, right of the door), with its muntins\n  const win = new THREE.Group(); win.position.set(0.11, 0.105, Dp / 2 + 0.02); cabin.add(win);\n  win.add(new THREE.Mesh(new THREE.PlaneGeometry(0.1, 0.085), lit));\n  for (const [a, b, px, py] of [[0.116, 0.013, 0, 0.0425], [0.116, 0.013, 0, -0.0425], [0.013, 0.085, -0.05, 0], [0.013, 0.085, 0.05, 0], [0.007, 0.085, 0, 0], [0.1, 0.007, 0, 0]]) {\n    const f = new THREE.Mesh(new THREE.BoxGeometry(a, b, 0.012), timberD); f.position.set(px, py, 0.004); win.add(f);\n  }\n  const sill = new THREE.Mesh(new THREE.BoxGeometry(0.13, 0.014, 0.03), snowM); sill.position.set(0.11, 0.057, Dp / 2 + 0.03); cabin.add(sill);\n  const door = new THREE.Mesh(new THREE.PlaneGeometry(0.075, 0.145), new THREE.MeshStandardMaterial({ color: '#2c1c12', roughness: 0.8 })); door.position.set(-0.1, 0.075, Dp / 2 + 0.021); cabin.add(door);\n  for (let i = 0; i < 26; i++) { const a = i / 26, onX = i < 13, st = new THREE.Mesh(new THREE.BoxGeometry(0.045, 0.03, 0.03), stone), t = ((a * 2) % 1) - 0.5; st.position.set(onX ? t * Wd : (i < 20 ? -1 : 1) * Wd / 2, 0.012, onX ? Dp / 2 + 0.015 : t * Dp); cabin.add(st); }\n  // spruce: tiers of drooping cones, a snow cap on every tier\n  const spruce = (x, z, hgt, seed) => {\n    const t = new THREE.Group(); t.position.set(x, 0, z); holder.add(t);\n    const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.018, hgt * 0.3, 8), timberD); trunk.position.y = hgt * 0.15; t.add(trunk);\n    const tiers = 7;\n    for (let i = 0; i < tiers; i++) {\n      const k = i / (tiers - 1), rad = (1 - k) * hgt * 0.21 + 0.018, y = hgt * (0.16 + k * 0.74);\n      const cg = new THREE.ConeGeometry(rad, hgt * 0.2, 11, 2, true), P = cg.attributes.position;\n      for (let j = 0; j < P.count; j++) { const px = P.getX(j), py = P.getY(j), pz = P.getZ(j), a = Math.atan2(pz, px), bump = 1 + 0.16 * Math.sin(a * 5 + seed + i) + 0.08 * Math.sin(a * 11 + i * 2); if (py < 0) { P.setX(j, px * bump); P.setZ(j, pz * bump); P.setY(j, py - 0.012 * Math.sin(a * 5 + seed)); } }\n      cg.computeVertexNormals();\n      const c = new THREE.Mesh(cg, needle); c.position.y = y; c.rotation.y = seed + i; t.add(c);\n      const s2 = new THREE.Mesh(new THREE.ConeGeometry(rad * 0.58, hgt * 0.06, 11, 1, true), snowM); s2.position.y = y + hgt * 0.07; s2.rotation.y = seed + i * 1.7; t.add(s2);\n    }\n    const tip = new THREE.Mesh(new THREE.ConeGeometry(0.02, 0.06, 8), snowM); tip.position.y = hgt * 0.95; t.add(tip);\n  };\n  spruce(-0.42, -0.16, 1.0, 1.1); spruce(-0.14, -0.46, 1.16, 2.3); spruce(0.24, -0.42, 0.9, 4.2); spruce(0.5, -0.1, 0.74, 3.7); spruce(-0.6, 0.22, 0.5, 5.1); spruce(0.55, 0.36, 0.36, 6.3);\n  const drift = new THREE.Mesh(new THREE.SphereGeometry(1, 24, 12, 0, Math.PI * 2, 0, Math.PI / 2), snowM); drift.scale.set(0.36, 0.05, 0.3); drift.position.set(0.04, -0.012, 0.1); holder.add(drift);\n  // where the window sits, in holder space (for the light and the glow)\n  holder.updateMatrixWorld(true);\n  const winPos = new THREE.Vector3(); win.getWorldPosition(winPos); winPos.y -= holder.position.y; // holder-local (holder has no rotation/scale)\n  return { holder, lit, winPos, cabinYaw: cabin.rotation.y };\n}\n\n// ---------- the stage: floor sweeping up into a wall, a cool pool of top light, the base's contact shadow, the window's warm spill\nconst STAGE_FRAG = /* glsl */`\n  uniform vec3 uBg, uCool, uGlow; uniform float uGlowAmt, uLift, uWallZ; uniform vec2 uBase;\n  varying vec3 vW;\n  void main(){\n    vec3 c = uBg;\n    float wall = smoothstep(uWallZ + 1.8, uWallZ + 0.2, vW.z);\n    vec2 q = vec2(vW.x, vW.y - 1.7);\n    float halo = exp(-dot(q * vec2(0.26, 0.3), q * vec2(0.26, 0.3)));\n    float pool = exp(-(vW.x * vW.x * 0.1 + (vW.z + 0.3) * (vW.z + 0.3) * 0.16));\n    c *= 0.5 + 1.25 * mix(pool, halo, wall);\n    c += uCool * 0.018 * halo * wall;\n    float floorW = 1.0 - wall;\n    c *= mix(1.0, 0.55, smoothstep(1.5, 9.0, vW.z) * floorW);\n    // contact shadow + a tighter occlusion line where the wood meets the floor\n    vec2 d = vW.xz - uBase; float r = length(d * vec2(1.0, 1.0));\n    float soft = 0.32 + uLift * 2.2;\n    float sh = exp(-pow(max(r - 0.95, 0.0) / soft, 2.0));\n    float line = exp(-pow(max(r - 1.24, 0.0) / 0.05, 2.0)) * (1.0 - clamp(uLift * 5.0, 0.0, 1.0));\n    c *= 1.0 - (0.72 * sh + 0.35 * line) * floorW;\n    // the lit window throws a warm fan onto the floor in front of the globe\n    vec2 g = vW.xz - vec2(uBase.x + 0.2, uBase.y + 1.55);\n    float fan = exp(-(g.x * g.x * 2.2 + g.y * g.y * 2.6)) * smoothstep(1.2, 1.45, r);\n    c += uGlow * fan * 0.035 * uGlowAmt * floorW;\n    gl_FragColor = vec4(c, 1.0);\n    #include <colorspace_fragment>\n  }`;\n\nfunction mount(el) {\n  const canvas = el.querySelector('.vs-snow-globe-canvas');\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  const coarse = matchMedia('(pointer: coarse)').matches;\n  let renderer;\n  try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' }); } catch (e) { el.dataset.vsState = 'nogl'; return null; }\n  if (!renderer.getContext()) { el.dataset.vsState = 'nogl'; return null; }\n  renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.05;\n  if ('transmissionResolutionScale' in renderer) renderer.transmissionResolutionScale = coarse ? 0.75 : 1;\n\n  const bg = new THREE.Color(css(el, '--vs-snow-globe-bg', '#0b1220'));\n  const glowC = new THREE.Color(css(el, '--vs-snow-globe-glow', '#ffa24c'));\n  const scene = new THREE.Scene(); scene.background = bg.clone();\n  const camera = new THREE.PerspectiveCamera(24, 1, 0.1, 100);\n\n  // ---- the room the glass reflects: a cool overhead softbox, a window up-left, side strips for the rim\n  const env = new THREE.Scene();\n  const eb = (c, i) => new THREE.MeshBasicMaterial({ color: new THREE.Color(c).multiplyScalar(i), side: THREE.DoubleSide });\n  const room = new THREE.Mesh(new THREE.BoxGeometry(30, 16, 30), eb('#' + bg.getHexString(), 0.9)); room.material.side = THREE.BackSide; env.add(room);\n  const box = (w, h, x, y, z, c, i) => { const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), eb(c, i)); m.position.set(x, y, z); m.lookAt(0, 0, 0); env.add(m); };\n  box(6, 1.6, 0, 6, -5.5, '#dbe6ff', 2.4);     // overhead, behind: a thin bright lip along the top of the glass\n  box(0.9, 2.2, -6, 5, -0.6, '#f2f6ff', 34);   // the window up-left: a crisp highlight near the rim\n  box(1.6, 12, -5.5, 0.5, -8, '#9fb6e6', 3.2);  // rim strips behind left and right: they light the silhouette\n  box(1.6, 12, 5.5, 0.5, -8, '#9fb6e6', 2.6);\n  box(3.5, 0.35, 4, -1.2, 6, '#' + glowC.getHexString(), 1.6); // a warm sliver low right\n  const pmrem = new THREE.PMREMGenerator(renderer);\n  const envTex = pmrem.fromScene(env, 0.02).texture;\n  scene.environment = envTex; scene.environmentIntensity = 0.55;\n\n  // ---- lights\n  scene.add(new THREE.HemisphereLight('#6d86bd', '#1a120c', 0.5));\n  const top = new THREE.SpotLight('#d4e0ff', 2.6, 0, 0.5, 1, 0); top.position.set(0.8, 8, 3.2); top.target.position.set(0, 1, 0); scene.add(top, top.target);\n  const rimL = new THREE.DirectionalLight('#9fb8ff', 1.4); rimL.position.set(-3, 4, -5); scene.add(rimL);\n  const fill = new THREE.DirectionalLight('#ffe4c8', 0.75); fill.position.set(-3, 2.2, 6); scene.add(fill); // a low warm fill so the turned wood reads\n\n  // ---- stage\n  const FLOOR = 0, WALL_Z = -4.5, RC = 2.2;\n  const stageGeo = (() => {\n    const prof = [];\n    for (let i = 0; i <= 10; i++) prof.push([40 - i / 10 * (40 - (WALL_Z + RC)), FLOOR]);\n    for (let i = 1; i <= 16; i++) { const a = i / 16 * Math.PI / 2; prof.push([WALL_Z + RC - Math.sin(a) * RC, FLOOR + RC - Math.cos(a) * RC]); }\n    for (let i = 1; i <= 8; i++) prof.push([WALL_Z, FLOOR + RC + i / 8 * 16]);\n    const g = new THREE.BufferGeometry(), pos = [], idx = [], NX = 2;\n    prof.forEach(([z, y]) => { for (let j = 0; j <= NX; j++) pos.push(-50 + 100 * j / NX, y, z); });\n    for (let i = 0; i < prof.length - 1; i++) for (let j = 0; j < NX; j++) { const a = i * (NX + 1) + j, b = a + NX + 1; idx.push(a, b, a + 1, b, b + 1, a + 1); }\n    g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setIndex(idx); return g;\n  })();\n  const SU = { uBg: { value: bg }, uCool: { value: new THREE.Color('#9fb8ff') }, uGlow: { value: glowC }, uGlowAmt: { value: 1 }, uLift: { value: 0 }, uWallZ: { value: WALL_Z }, uBase: { value: new THREE.Vector2() } };\n  scene.add(new THREE.Mesh(stageGeo, new THREE.ShaderMaterial({ uniforms: SU, side: THREE.DoubleSide, fragmentShader: STAGE_FRAG,\n    vertexShader: 'varying vec3 vW; void main(){ vec4 w = modelMatrix * vec4(position, 1.0); vW = w.xyz; gl_Position = projectionMatrix * viewMatrix * w; }' })));\n\n  // ---- the globe: base (lift pivots near the hand), glass, water world\n  const GY = 1.13, PIV = 0.45;\n  const lift = new THREE.Group(); lift.position.y = PIV; scene.add(lift);\n  const rig = new THREE.Group(); rig.position.y = -PIV; lift.add(rig);\n  const woodMat = new THREE.MeshPhysicalMaterial({ map: woodTexture(css(el, '--vs-snow-globe-wood', '#3a261a')), roughness: 0.5, clearcoat: 0.6, clearcoatRoughness: 0.22, envMapIntensity: 0.9 });\n  const brass = new THREE.MeshStandardMaterial({ color: '#d2a262', metalness: 1, roughness: 0.26, envMapIntensity: 1.8 });\n  const baseProfile = [[0, 0], [1.24, 0], [1.275, 0.025], [1.275, 0.1], [1.22, 0.135], [1.185, 0.2], [1.1, 0.34], [1.02, 0.43], [0.98, 0.5], [0.9, 0.53], [0, 0.53]].map(([x, y]) => new THREE.Vector2(x, y));\n  rig.add(new THREE.Mesh(new THREE.LatheGeometry(baseProfile, 128), woodMat));\n  rig.add(new THREE.Mesh(new THREE.LatheGeometry([[1.205, 0.13], [1.228, 0.145], [1.228, 0.188], [1.2, 0.2]].map(([x, y]) => new THREE.Vector2(x, y)), 128), brass));\n  const inner = new THREE.Group(); inner.position.y = GY; rig.add(inner);\n  const collar = new THREE.Mesh(new THREE.TorusGeometry(0.8, 0.032, 16, 128), brass); collar.rotation.x = Math.PI / 2; collar.position.y = -0.6; inner.add(collar);\n\n  // the snow floor: a mound meeting the glass, with gentle drifts\n  const floorGeo = (() => {\n    const pts = [], N = 28;\n    for (let i = 0; i <= N; i++) { const rr = i / N * 0.86; pts.push(new THREE.Vector2(rr, -0.47 + 0.035 * Math.cos(rr * 3.3) - 0.02 * rr * rr)); }\n    for (let i = 1; i <= 6; i++) { const y = -0.5 - i / 6 * 0.1; pts.push(new THREE.Vector2(Math.sqrt(1 - y * y) - 0.012, y)); }\n    const g = new THREE.LatheGeometry(pts.reverse(), 128), P = g.attributes.position;\n    for (let i = 0; i < P.count; i++) { const x = P.getX(i), y = P.getY(i), z = P.getZ(i); if (y > -0.52) { const rr = Math.hypot(x, z), n = Math.sin(x * 7.1 + z * 3.3) * 0.012 + Math.sin(z * 9.7 - x * 2.1) * 0.008; P.setY(i, y + n * clamp(rr / 0.3) * (1 - clamp((rr - 0.8) / 0.06))); } }\n    g.computeVertexNormals(); return g;\n  })();\n  const content = new THREE.Group();\n  content.add(new THREE.Mesh(floorGeo, new THREE.MeshStandardMaterial({ color: '#e6ecf4', roughness: 0.85 })));\n  const D = diorama(glowC); content.add(D.holder);\n\n  // bake a top-down heightmap of the diorama: flakes land on it, and slide off anything steep\n  const HM = 96, Hmap = new Float32Array(HM * HM).fill(-1);\n  {\n    const rt = new THREE.WebGLRenderTarget(HM, HM, { depthBuffer: true });\n    const bake = new THREE.Scene(); bake.add(content); content.traverse((o) => { o.frustumCulled = false; });\n    bake.overrideMaterial = new THREE.ShaderMaterial({ side: THREE.DoubleSide,\n      vertexShader: 'varying float vY; void main(){ vec4 w = modelMatrix * vec4(position, 1.0); vY = w.y; gl_Position = vec4(w.x, w.z, 1.0 - clamp((w.y + 1.0) * 0.5, 0.0, 1.0) * 2.0, 1.0); }',\n      fragmentShader: 'varying float vY; void main(){ float h = clamp((vY + 1.0) * 0.5, 0.0, 1.0) * 255.0; gl_FragColor = vec4(floor(h) / 255.0, fract(h), 0.0, 1.0); }' });\n    const cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);\n    renderer.setRenderTarget(rt); renderer.setClearColor(0x000000, 0); renderer.clear(); renderer.render(bake, cam); renderer.setRenderTarget(null);\n    const px = new Uint8Array(HM * HM * 4); renderer.readRenderTargetPixels(rt, 0, 0, HM, HM, px);\n    for (let i = 0; i < HM * HM; i++) if (px[i * 4 + 3] > 0) Hmap[i] = (px[i * 4] + px[i * 4 + 1] / 255) / 255 * 2 - 1;\n    rt.dispose(); bake.overrideMaterial.dispose(); bake.remove(content); content.traverse((o) => { o.frustumCulled = true; });\n  }\n  inner.add(content);\n\n  // the window's light, and a soft bloom over the glass where it shines through\n  const winLight = new THREE.PointLight(glowC, 0.9, 1.6, 2);\n  const wl = D.winPos.clone(); wl.y += D.holder.position.y; wl.x += Math.sin(D.cabinYaw) * 0.1; wl.z += Math.cos(D.cabinYaw) * 0.1;\n  winLight.position.copy(wl); inner.add(winLight);\n  const glowTex = (() => { const c = document.createElement('canvas'); c.width = c.height = 128; const g = c.getContext('2d'); const gr = g.createRadialGradient(64, 64, 0, 64, 64, 64);\n    gr.addColorStop(0, 'rgba(255,255,255,1)'); gr.addColorStop(0.18, 'rgba(255,255,255,.45)'); gr.addColorStop(0.5, 'rgba(255,255,255,.08)'); gr.addColorStop(1, 'rgba(255,255,255,0)');\n    g.fillStyle = gr; g.fillRect(0, 0, 128, 128); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();\n  const bloom = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTex, color: glowC, blending: THREE.AdditiveBlending, depthTest: false, depthWrite: false, transparent: true, opacity: 0.55 }));\n  const wp = D.winPos.clone(); wp.y += D.holder.position.y; wp.x += Math.sin(D.cabinYaw) * 0.02; wp.z += Math.cos(D.cabinYaw) * 0.02;\n  bloom.position.copy(wp); bloom.scale.setScalar(0.42); bloom.renderOrder = 10; inner.add(bloom);\n\n  // the glass: water-filled, so ior 1.33 and a little thickness to magnify what's inside\n  const tint = new THREE.Color(el.dataset.tint || '#e9f1f8');\n  const glass = new THREE.MeshPhysicalMaterial({ color: 0xffffff, metalness: 0, roughness: 0.025, transmission: 1, thickness: 0.55, ior: 1.33, dispersion: 0.08,\n    attenuationColor: tint.clone(), attenuationDistance: 2.6, specularIntensity: 1, envMapIntensity: 2.4, clearcoat: 0.5, clearcoatRoughness: 0.02 });\n  const shell = new THREE.Mesh(new THREE.SphereGeometry(1, coarse ? 72 : 112, coarse ? 54 : 84), glass); shell.renderOrder = 2; inner.add(shell);\n\n  // ---- the flakes\n  const MAXF = coarse ? 2000 : 3400;\n  const flakeMat = new THREE.MeshStandardMaterial({ color: '#e7edf6', roughness: 0.4, emissive: '#aebfdc', emissiveIntensity: 0.32, side: THREE.DoubleSide, envMapIntensity: 1.2 });\n  const flakes = new THREE.InstancedMesh(new THREE.CircleGeometry(1, 6), flakeMat, MAXF); flakes.frustumCulled = false; inner.add(flakes);\n  const P = new Float32Array(MAXF * 3), V = new Float32Array(MAXF * 3), Q = new Float32Array(MAXF * 4), Wv = new Float32Array(MAXF * 3), Sz = new Float32Array(MAXF), Dg = new Float32Array(MAXF), Rest = new Uint8Array(MAXF), Cell = new Int32Array(MAXF).fill(-1), Dirty = new Uint8Array(MAXF);\n  const acc = new Float32Array(HM * HM); // snow piled on each cell by settled flakes\n  const R = rng(3);\n  for (let i = 0; i < MAXF; i++) { Sz[i] = (0.0056 + R() * 0.006) * (R() < 0.08 ? 1.5 : 1); Dg[i] = 1.6 + R() * 2; Q[i * 4 + 3] = 1; for (let k = 0; k < 3; k++) Wv[i * 3 + k] = (R() - 0.5) * 6; }\n  const cellOf = (x, z) => { const gx = Math.floor((x + 1) * 0.5 * HM), gz = Math.floor((z + 1) * 0.5 * HM); return gx < 0 || gz < 0 || gx >= HM || gz >= HM ? -1 : gz * HM + gx; };\n  const ground = (x, z) => { const rr = Math.hypot(x, z); return -0.47 + 0.035 * Math.cos(rr * 3.3) - 0.02 * rr * rr; };\n  // the surface under (x, z): bilinear between baked texels, snapping to the upper lip at a bough's edge; also its slope\n  const surf = { h: 0, gx: 0, gz: 0 };\n  const hm = (ix, iz) => (ix < 0 || iz < 0 || ix >= HM || iz >= HM) ? -9 : Hmap[iz * HM + ix];\n  function surface(x, z) {\n    const fx = (x + 1) * 0.5 * HM - 0.5, fz = (z + 1) * 0.5 * HM - 0.5, ix = Math.floor(fx), iz = Math.floor(fz), tx = fx - ix, tz = fz - iz;\n    const g = ground(x, z), a = Math.max(g, hm(ix, iz)), b = Math.max(g, hm(ix + 1, iz)), c = Math.max(g, hm(ix, iz + 1)), d = Math.max(g, hm(ix + 1, iz + 1));\n    const hi = Math.max(a, b, c, d), lo = Math.min(a, b, c, d), s = HM / 2;\n    if (hi - lo > 0.06) { surf.h = hi; surf.gx = 0; surf.gz = 0; return surf; }\n    surf.h = (a * (1 - tx) + b * tx) * (1 - tz) + (c * (1 - tx) + d * tx) * tz;\n    surf.gx = ((b - a) * (1 - tz) + (d - c) * tz) * s; surf.gz = ((c - a) * (1 - tx) + (d - b) * tx) * s;\n    return surf;\n  }\n  const dAcc = (i) => (Sz[i] / 0.01) * (Sz[i] / 0.01) * 0.0016;\n  const tq = new THREE.Quaternion(), te = new THREE.Euler(), tv = new THREE.Vector3(), ts = new THREE.Vector3(), tm = new THREE.Matrix4();\n  const flatQ = new THREE.Quaternion().setFromEuler(new THREE.Euler(-Math.PI / 2, 0, 0)), zA = new THREE.Vector3(0, 0, 1), yA = new THREE.Vector3(0, 1, 0), nq = new THREE.Quaternion(), tn = new THREE.Vector3();\n  function rest(i, x, z, k) {\n    Rest[i] = 1; Cell[i] = k; V[i * 3] = V[i * 3 + 1] = V[i * 3 + 2] = 0;\n    const sf = surface(x, z), h = sf.h + (k >= 0 ? acc[k] : 0) + 0.0025; if (k >= 0) acc[k] += dAcc(i);\n    P[i * 3] = x; P[i * 3 + 1] = h; P[i * 3 + 2] = z;\n    // lie on the slope it landed on\n    tq.setFromAxisAngle(zA, i * 2.399); tq.premultiply(flatQ); tq.premultiply(nq.setFromUnitVectors(yA, tn.set(-sf.gx, 1, -sf.gz).normalize()));\n    Q[i * 4] = tq.x; Q[i * 4 + 1] = tq.y; Q[i * 4 + 2] = tq.z; Q[i * 4 + 3] = tq.w; Dirty[i] = 1;\n  }\n  function wake(i) { Rest[i] = 0; if (Cell[i] >= 0) acc[Cell[i]] = Math.max(0, acc[Cell[i]] - dAcc(i)); Cell[i] = -1; }\n  function spawn(i, settled) {\n    let x, y, z; do { x = (R() * 2 - 1) * 0.9; z = (R() * 2 - 1) * 0.9; y = R() * 1.3 - 0.45; } while (x * x + y * y + z * z > 0.86);\n    Rest[i] = 0; Cell[i] = -1;\n    if (settled) { rest(i, x, z, cellOf(x, z)); return; }\n    P[i * 3] = x; P[i * 3 + 1] = y; P[i * 3 + 2] = z; V[i * 3] = V[i * 3 + 1] = V[i * 3 + 2] = 0;\n    tq.setFromEuler(te.set(R() * 6, R() * 6, R() * 6)); Q[i * 4] = tq.x; Q[i * 4 + 1] = tq.y; Q[i * 4 + 2] = tq.z; Q[i * 4 + 3] = tq.w; Dirty[i] = 1;\n  }\n\n  // ---- the water: a bulk current that lags the glass, a swirl, a rolling vortex, and turbulence, all fed by the shake\n  const W = { B: new THREE.Vector3(), E: 0, swirl: 0, roll: 0 };\n  const opt = { snow: clamp(parseFloat(el.dataset.snow || 0.7), 0.1, 1), settle: clamp(parseFloat(el.dataset.settle || 1), 0.3, 3), auto: 'autoShake' in el.dataset && !reduced };\n  let count = Math.round(MAXF * opt.snow);\n  for (let i = 0; i < MAXF; i++) spawn(i, R() < 0.72);\n  const aL = new THREE.Vector3(), gL = new THREE.Vector3(), iq = new THREE.Quaternion();\n  let moving = 0;\n  function simulate(dt, t, accW) {\n    inner.getWorldQuaternion(iq).invert();\n    aL.copy(accW).applyQuaternion(iq); gL.set(0, -1, 0).applyQuaternion(iq);\n    const am = aL.length();\n    W.B.addScaledVector(aL, -0.012).clampLength(0, 1.4); W.B.multiplyScalar(Math.exp(-dt * 1.8));\n    W.E = Math.min(1.8, W.E + am * dt * 0.05);\n    if (am > 8) W.roll = Math.min(1.2, W.roll + am * dt * 0.02);\n    const st = opt.settle;\n    W.E *= Math.exp(-dt * 0.75 * st); W.swirl *= Math.exp(-dt * 0.4 * st); W.roll *= Math.exp(-dt * 0.6 * st);\n    const E = W.E, sw = W.swirl, roll = W.roll, Bx = W.B.x, By = W.B.y, Bz = W.B.z, G = 0.2 * st, tt = t * 0.9;\n    moving = 0;\n    for (let i = 0; i < count; i++) {\n      const i3 = i * 3;\n      let x = P[i3], y = P[i3 + 1], z = P[i3 + 2];\n      if (Rest[i]) {\n        // a shake lifts the pile: the harder it is, the more of the floor goes up (the top layers first)\n        const lift = E > 0.12 ? E * dt * (3 + (i % 7)) + (Cell[i] >= 0 ? acc[Cell[i]] * 40 * E * dt : 0) : 0;\n        if (lift > 0 && R() < lift) { wake(i); V[i3] = (R() - 0.5) * 0.6 * E + Bx * 0.5; V[i3 + 1] = 0.3 + R() * 0.9 * E; V[i3 + 2] = (R() - 0.5) * 0.6 * E + Bz * 0.5; }\n        else continue;\n      }\n      moving++;\n      let vx = V[i3], vy = V[i3 + 1], vz = V[i3 + 2];\n      const drag = Dg[i], rr = Math.hypot(x, z) + 1e-4;\n      let ux = -z * sw + Bx, uz = x * sw + Bz, uy = By;\n      uy += roll * (rr - 0.45) * 1.4; ux += roll * (x / rr) * (-y) * 0.8; uz += roll * (z / rr) * (-y) * 0.8;\n      ux += E * 0.55 * (Math.sin(y * 4.1 + tt * 1.3 + z * 2.3) + 0.6 * Math.sin(z * 7.3 - tt * 2.1));\n      uy += E * 0.45 * (Math.sin(z * 3.7 - tt * 1.1 + x * 2.9) + 0.6 * Math.sin(x * 6.1 + tt * 1.7));\n      uz += E * 0.55 * (Math.sin(x * 4.4 + tt * 1.5 + y * 2.7) + 0.6 * Math.sin(y * 6.7 - tt * 1.9));\n      const k = 1 - Math.exp(-drag * dt), sink = G / drag;\n      vx += (ux + gL.x * sink - vx) * k - aL.x * 0.25 * dt;\n      vy += (uy + gL.y * sink - vy) * k - aL.y * 0.25 * dt;\n      vz += (uz + gL.z * sink - vz) * k - aL.z * 0.25 * dt;\n      x += vx * dt; y += vy * dt; z += vz * dt;\n      const rw = Math.sqrt(x * x + y * y + z * z), RW = 0.935;\n      if (rw > RW) { const nx = x / rw, ny = y / rw, nz = z / rw; x = nx * RW; y = ny * RW; z = nz * RW; const vn = vx * nx + vy * ny + vz * nz; if (vn > 0) { vx -= 1.5 * vn * nx; vy -= 1.5 * vn * ny; vz -= 1.5 * vn * nz; } }\n      const c = cellOf(x, z), sf = surface(x, z), h = sf.h + (c >= 0 ? acc[c] : 0);\n      if (y < h) {\n        const sp = vx * vx + vy * vy + vz * vz;\n        // steep roofs and cone sides shed their snow: it slides downhill and drops off the edge\n        const gx = sf.gx, gz = sf.gz, slope = Math.hypot(gx, gz);\n        if (slope > 1.6) { y = h + 0.001; vx = -gx / slope * 0.25; vz = -gz / slope * 0.25; vy = -0.05; }\n        else if (E < 0.2 && sp < 0.02) { rest(i, x, z, c); continue; }\n        else { y = h; vy = Math.abs(vy) * 0.25; vx *= 0.7; vz *= 0.7; }\n      }\n      P[i3] = x; P[i3 + 1] = y; P[i3 + 2] = z; V[i3] = vx; V[i3 + 1] = vy; V[i3 + 2] = vz;\n      const spin = Math.min(1.5, Math.sqrt(vx * vx + vy * vy + vz * vz) * 6 + 0.15) * dt;\n      tq.set(Q[i * 4], Q[i * 4 + 1], Q[i * 4 + 2], Q[i * 4 + 3]).multiply(iq.setFromEuler(te.set(Wv[i3] * spin, Wv[i3 + 1] * spin, Wv[i3 + 2] * spin))).normalize();\n      Q[i * 4] = tq.x; Q[i * 4 + 1] = tq.y; Q[i * 4 + 2] = tq.z; Q[i * 4 + 3] = tq.w; Dirty[i] = 1;\n    }\n  }\n  function writeFlakes(all) {\n    let any = false;\n    for (let i = 0; i < count; i++) {\n      if (!all && !Dirty[i]) continue; Dirty[i] = 0; any = true;\n      tv.set(P[i * 3], P[i * 3 + 1], P[i * 3 + 2]); tq.set(Q[i * 4], Q[i * 4 + 1], Q[i * 4 + 2], Q[i * 4 + 3]); ts.setScalar(Sz[i]);\n      flakes.setMatrixAt(i, tm.compose(tv, tq, ts));\n    }\n    flakes.count = count;\n    if (any || all) flakes.instanceMatrix.needsUpdate = true;\n  }\n\n  // ---- the hand: springs for the globe's travel, lift and lean; its acceleration is what shakes the water\n  const H = { x: new Spring(0, 230, 15), y: new Spring(0, 170, 17), h: new Spring(0, 110, 16) };\n  const cam = { x: new Spring(0, 40, 12), y: new Spring(0, 40, 12) };\n  const S = { visible: true, raf: 0, last: 0, t: 0, drag: false, x0: 0, y0: 0, upp: 0.004, travel: 0.34, idle: 0, script: null, glow: 1, px: 0, py: 0 };\n  let view = { vh: 3.4 };\n\n  function resize() {\n    const w = el.clientWidth, h = el.clientHeight; if (!w || !h) return;\n    renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.setSize(w, h, false);\n    camera.aspect = w / h;\n    // globe + base ≈ 2.2 tall, 2.6 wide: fill ~66% of the height, or ~86% of the width on a narrow screen\n    const vh = Math.max(2.2 / 0.66, (2.62 / 0.9) / camera.aspect);\n    const dist = vh / 2 / Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));\n    view = { vh, dist, lookY: camera.aspect < 0.8 ? 1.0 : 1.02 };\n    S.upp = vh / h; S.travel = Math.min(0.34, Math.max(0.12, (vh * camera.aspect - 2.62) / 2 + 0.12));\n    camera.updateProjectionMatrix();\n  }\n  function placeCamera() {\n    const el0 = 0.1 + cam.y.x * 0.05, az = cam.x.x * 0.09;\n    camera.position.set(Math.sin(az) * Math.cos(el0) * view.dist, view.lookY + Math.sin(el0) * view.dist, Math.cos(az) * Math.cos(el0) * view.dist);\n    camera.lookAt(0, view.lookY, 0);\n  }\n\n  function shake(strength = 1) {\n    const s = clamp(strength, 0, 2) * S.travel / 0.34, dir = R() < 0.5 ? -1 : 1;\n    S.script = { t: 0, steps: [[0, 0.24 * dir * s, 0.12], [0.13, -0.26 * dir * s, 0.14], [0.27, 0.22 * dir * s, 0.14], [0.41, -0.16 * dir * s, 0.12], [0.55, 0.08 * dir * s, 0.08], [0.68, 0, 0]] };\n    S.idle = 0; kick();\n  }\n\n  const accW = new THREE.Vector3(); let simT = 0;\n  function frame(now) {\n    S.raf = 0;\n    const dt = Math.min(0.05, (now - (S.last || now)) / 1000); S.last = now; S.t += dt; simT += dt;\n    // scripted shake (auto / keyboard / API)\n    if (S.script && !S.drag) {\n      S.script.t += dt; let cur = null; for (const st of S.script.steps) if (S.script.t >= st[0]) cur = st;\n      if (cur) { H.x.t = cur[1]; H.h.t = cur[2]; H.y.t = 0; }\n      if (S.script.t > 0.9) S.script = null;\n    }\n    H.x.step(dt); H.y.step(dt); H.h.step(dt); cam.x.step(dt); cam.y.step(dt);\n    const ly = Math.max(0, H.y.x + H.h.x);\n    lift.position.set(H.x.x, PIV + ly, 0);\n    lift.rotation.z = clamp(-H.x.v * 0.075 - H.x.x * 0.45, -0.42, 0.42);\n    lift.rotation.x = clamp(H.y.v * 0.03, -0.18, 0.18);\n    placeCamera();\n    // the globe's acceleration in world space (from the springs themselves, so it's smooth)\n    accW.set(H.x.a, H.y.a + H.h.a, 0).clampLength(0, 70);\n    if (!reduced) W.swirl = clamp(W.swirl - H.x.v * dt * 1.6 * (S.drag ? 1 : 0.5), -2.2, 2.2);\n    lift.updateMatrixWorld(true);\n    simulate(dt, simT, accW); writeFlakes(false);\n    // the stove behind the window breathes\n    S.glow = 0.92 + 0.05 * Math.sin(simT * 5.1) + 0.03 * Math.sin(simT * 11.7);\n    D.lit.emissiveIntensity = 6 * S.glow; winLight.intensity = 0.9 * S.glow; bloom.material.opacity = 0.5 * S.glow;\n    SU.uGlowAmt.value = S.glow; SU.uLift.value = ly; SU.uBase.value.set(H.x.x, 0);\n    renderer.render(scene, camera);\n    if (el.dataset.vsState !== 'ready') el.dataset.vsState = 'ready';\n    const springs = [H.x, H.y, H.h, cam.x, cam.y].some((s) => Math.abs(s.v) > 1e-4 || Math.abs(s.x - s.t) > 1e-4);\n    if (!S.drag && !S.script && moving === 0 && W.E < 0.02) S.idle += dt; else S.idle = 0;\n    const busy = S.drag || S.script || springs || moving > 0 || W.E > 0.02;\n    if (S.visible && !document.hidden && busy) S.raf = requestAnimationFrame(frame);\n  }\n  const kick = () => { if (!S.raf && S.visible) { S.last = performance.now(); S.raf = requestAnimationFrame(frame); } };\n\n  // idle watcher: auto-shake after ~7 s of stillness (runs even while the render loop sleeps)\n  let quiet = 0;\n  const watch = setInterval(() => {\n    if (!opt.auto || !S.visible || document.hidden || S.drag || S.script) { quiet = 0; return; }\n    if (S.raf === 0 || W.E < 0.02) quiet += 0.5; else quiet = 0;\n    if (quiet >= 7) { quiet = 0; shake(0.9); }\n  }, 500);\n\n  // ---- input\n  const onDown = (e) => { if (e.button > 0) return; S.drag = true; S.script = null; S.x0 = e.clientX; S.y0 = e.clientY; el.classList.add('is-dragging'); el.setPointerCapture(e.pointerId); H.h.t = 0.16; kick(); };\n  const onMove = (e) => {\n    const r = el.getBoundingClientRect();\n    if (e.pointerType === 'mouse') { S.px = ((e.clientX - r.left) / r.width) * 2 - 1; S.py = ((e.clientY - r.top) / r.height) * 2 - 1; cam.x.t = -S.px; cam.y.t = S.py; }\n    if (S.drag) {\n      const rx = (e.clientX - S.x0) * S.upp * 1.3, ry = -(e.clientY - S.y0) * S.upp * 1.3;\n      H.x.t = S.travel * Math.tanh(rx / S.travel); H.y.t = clamp(0.22 * Math.tanh(ry / 0.22), -0.12, 0.22);\n    }\n    kick();\n  };\n  const onUp = () => { if (!S.drag) return; S.drag = false; el.classList.remove('is-dragging'); H.x.t = 0; H.y.t = 0; H.h.t = 0; kick(); };\n  const onLeave = () => { cam.x.t = 0; cam.y.t = 0; kick(); };\n  const onKey = (e) => {\n    if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); shake(1); }\n    else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); H.x.v += (e.key === 'ArrowLeft' ? -1 : 1) * 3.2; kick(); }\n  };\n  el.addEventListener('pointerdown', onDown); el.addEventListener('pointermove', onMove); el.addEventListener('pointerup', onUp); el.addEventListener('pointercancel', onUp); el.addEventListener('pointerleave', onLeave); el.addEventListener('keydown', onKey);\n\n  const ro = new ResizeObserver(() => { resize(); kick(); }); ro.observe(el);\n  const io = new IntersectionObserver(([en]) => { S.visible = en.isIntersecting; if (S.visible) kick(); }, { rootMargin: '100px' }); io.observe(el);\n  const onVis = () => { if (!document.hidden) kick(); }; document.addEventListener('visibilitychange', onVis);\n  const onLost = (e) => { e.preventDefault(); cancelAnimationFrame(S.raf); S.raf = 0; S.visible = false; };\n  const onRestored = () => { destroy(); el.__vsRemount(); };\n  canvas.addEventListener('webglcontextlost', onLost); canvas.addEventListener('webglcontextrestored', onRestored);\n  function destroy() {\n    cancelAnimationFrame(S.raf); clearInterval(watch); ro.disconnect(); io.disconnect(); document.removeEventListener('visibilitychange', onVis);\n    ['pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'pointerleave', 'keydown'].forEach((t, i) => el.removeEventListener(t, [onDown, onMove, onUp, onUp, onLeave, onKey][i]));\n    canvas.removeEventListener('webglcontextlost', onLost); canvas.removeEventListener('webglcontextrestored', onRestored);\n    scene.traverse((o) => { if (o.geometry) o.geometry.dispose(); if (o.material) { if (o.material.map) o.material.map.dispose(); o.material.dispose(); } });\n    envTex.dispose(); pmrem.dispose(); renderer.dispose();\n  }\n  function set(o = {}) {\n    if (o.snow != null) {\n      const n = Math.round(MAXF * clamp(+o.snow, 0.1, 1));\n      if (n > count) for (let i = count; i < n; i++) { spawn(i, false); P[i * 3 + 1] = 0.35 + R() * 0.45; } // new snow arrives from the top\n      else for (let i = n; i < count; i++) if (Rest[i]) wake(i);\n      count = n; opt.snow = +o.snow; el.dataset.snow = String(o.snow); writeFlakes(true);\n    }\n    if (o.tint) { tint.set(o.tint); glass.attenuationColor.copy(tint); el.dataset.tint = o.tint; }\n    if (o.settle != null) { opt.settle = clamp(+o.settle, 0.3, 3); el.dataset.settle = String(opt.settle); }\n    if (o.autoShake != null) { opt.auto = !!o.autoShake && !reduced; if (o.autoShake) el.dataset.autoShake = ''; else delete el.dataset.autoShake; }\n    kick();\n  }\n\n  // open mid-fall: a shake has just happened\n  W.E = reduced ? 0 : 0.35; W.swirl = reduced ? 0 : 0.5;\n  resize(); placeCamera();\n  for (let k = 0; k < 24; k++) simulate(1 / 30, k / 30, accW.set(0, 0, 0));\n  if (reduced) { for (let i = 0; i < MAXF; i++) if (!Rest[i]) { const x = P[i * 3], z = P[i * 3 + 2]; rest(i, x, z, cellOf(x, z)); } W.E = 0; }\n  writeFlakes(true);\n  renderer.compile(scene, camera); kick();\n  return { shake, set, destroy, get energy() { return W.E; } };\n}\n\ndocument.querySelectorAll('.vs-snow-globe:not([data-vs-mounted])').forEach((el) => {\n  el.dataset.vsMounted = '';\n  const go = () => { const api = mount(el); if (api) el.vsSnowGlobe = api; };\n  el.__vsRemount = go; go();\n});\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D objects",
    "tags": [
      "snow",
      "globe",
      "glass",
      "transmission",
      "particles",
      "physics",
      "drag",
      "winter",
      "instanced"
    ],
    "tech": [
      "three.js",
      "GLSL",
      "JS",
      "CSS"
    ],
    "deps": [
      "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js"
    ],
    "source": "hollowpine",
    "free": true,
    "accent": "#ffa24c",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/snow-globe/index.html",
    "thumbnail": "https://vividsites.app/api/component/snow-globe/thumb.jpg",
    "video": "https://vividsites.app/api/component/snow-globe/thumb.mp4",
    "page": "https://vividsites.app/c/snow-globe"
  }
}