{
  "id": "photo-story-hero",
  "kind": "element",
  "title": "Photo story hero",
  "summary": "A dark photography hero that scrolls into a pinned 3D sphere of photo cards, turning twice as you scroll while the story beside it changes.",
  "url": "https://enrichr.dev/elements/photo-story-hero.html",
  "section": "hero",
  "techniques": [
    "scroll-driven",
    "3d-css",
    "parallax"
  ],
  "stack": [
    "Vanilla JS",
    "CSS"
  ],
  "frameworks": [
    "React",
    "Next.js",
    "Vanilla"
  ],
  "mood": [
    "cinematic",
    "immersive"
  ],
  "intents": [
    "3d sphere photo gallery on scroll",
    "photography portfolio hero",
    "scroll rotating image sphere css 3d",
    "fibonacci sphere gallery",
    "pinned scroll gallery with changing text"
  ],
  "status": "tested",
  "params": [
    {
      "key": "headline",
      "label": "Headline",
      "type": "text",
      "value": "Capturing Corgis, Crafting Stories"
    },
    {
      "key": "lead",
      "label": "Intro line",
      "type": "text",
      "value": "Follow one small dog through bright colours, open fields and the best light of the day."
    },
    {
      "key": "auto",
      "label": "Autoplay",
      "type": "select",
      "options": [
        [
          "1",
          "On"
        ],
        [
          "0",
          "Off"
        ]
      ],
      "value": "1"
    },
    {
      "key": "turns",
      "label": "Turns over the gallery",
      "type": "range",
      "min": 0.5,
      "max": 4,
      "step": 0.5,
      "value": 2
    },
    {
      "key": "tilt",
      "label": "Tilt (deg)",
      "type": "range",
      "min": 0,
      "max": 90,
      "step": 1,
      "value": 45
    },
    {
      "key": "smoothing",
      "label": "Scroll smoothing (s)",
      "type": "range",
      "min": 0,
      "max": 3,
      "step": 0.1,
      "value": 1
    },
    {
      "key": "cards",
      "label": "Cards on the sphere",
      "type": "range",
      "min": 12,
      "max": 40,
      "step": 1,
      "value": 24
    },
    {
      "key": "background",
      "label": "Background",
      "type": "color",
      "value": "#0c1e18"
    },
    {
      "key": "clay",
      "label": "Cards and pills",
      "type": "color",
      "value": "#172c25"
    },
    {
      "key": "ink",
      "label": "Text",
      "type": "color",
      "value": "#fbf8f1"
    },
    {
      "key": "accent",
      "label": "Buttons and logo",
      "type": "color",
      "value": "#e6ff55"
    }
  ],
  "license": "Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.",
  "contract": "function mount(root, opts) -> { update(partialOpts), destroy() }; root must contain the element markup",
  "exports": {
    "claude": "Add the \"Photo story hero\" element to this project.\n\nThis is the \"Photo story hero\" element from the Enrichr Library: A dark photography hero that scrolls into a pinned 3D sphere of photo cards, turning twice as you scroll while the story beside it changes.\nPage: https://enrichr.dev/elements/photo-story-hero.html\nMachine-readable spec: https://enrichr.dev/elements/photo-story-hero.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nSteps:\n1. Look at the project first to see its framework and where components, styles and static files live. Follow its conventions.\n2. Fetch https://enrichr.dev/elements/photo-story-hero.json if you can read URLs (fields: source.markup, source.css, source.js, params); otherwise use the source at the end of this message.\n3. Add the files, mount it where I tell you (default: the top of the home page), and pass the settings below as the opts.\n4. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n5. Run the dev server and check it. with reduced motion on, nothing moves; it works at 375px wide and with touch only; there are no console errors; scrolling stays smooth; destroy() is called when it unmounts.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "chatgpt": "I want the \"Photo story hero\" element on my website. Below is its complete source. Please:\n1. Build one self-contained index.html that I can open locally to see it working (put the CSS in a <style> tag and the JavaScript in a <script> tag, with the markup in the body).\n2. Then explain, step by step, which parts go where in my own site, and how to mount it. My stack is: [tell me yours, or say \"plain HTML\"].\n3. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nThis is the \"Photo story hero\" element from the Enrichr Library: A dark photography hero that scrolls into a pinned 3D sphere of photo cards, turning twice as you scroll while the story beside it changes.\nPage: https://enrichr.dev/elements/photo-story-hero.html\nMachine-readable spec: https://enrichr.dev/elements/photo-story-hero.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}\n\nVerify: with reduced motion on, nothing moves; it works at 375px wide and with touch only; there are no console errors; scrolling stays smooth; destroy() is called when it unmounts.",
    "cursor": "Add the \"Photo story hero\" element to this codebase.\n\nThis is the \"Photo story hero\" element from the Enrichr Library: A dark photography hero that scrolls into a pinned 3D sphere of photo cards, turning twice as you scroll while the story beside it changes.\nPage: https://enrichr.dev/elements/photo-story-hero.html\nMachine-readable spec: https://enrichr.dev/elements/photo-story-hero.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nUse Composer / agent mode. First search the repo for how components, styles and scripts are organised, then add the files in the matching places, mount it on the page I name, and run the app to confirm it works. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}\n\nVerify: with reduced motion on, nothing moves; it works at 375px wide and with touch only; there are no console errors; scrolling stays smooth; destroy() is called when it unmounts.\n\nOptional: save this as .cursor/rules/enrichr-photo-story-hero.mdc so the rule stays with the project:\n---\ndescription: How to use the Enrichr Photo story hero element\nalwaysApply: false\n---\nmount(root, opts) expects the element markup inside root and returns { update, destroy }. Always call destroy() on unmount and never edit the ef- class names.",
    "v0": "Create a Next.js (App Router) client component for the \"Photo story hero\" element. Tailwind is available but this component needs no utility classes.\n\nThis is the \"Photo story hero\" element from the Enrichr Library: A dark photography hero that scrolls into a pinned 3D sphere of photo cards, turning twice as you scroll while the story beside it changes.\nPage: https://enrichr.dev/elements/photo-story-hero.html\nMachine-readable spec: https://enrichr.dev/elements/photo-story-hero.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nFiles to create:\n- photo-story-hero.js : the JavaScript below, with one line appended at the end: export { mount };  (and the markup exported as: export const markup = \"<the markup string>\";). If the project uses TypeScript and does not allow JS imports, put // @ts-nocheck on the first line.\n- the CSS below goes in your global stylesheet (or a plain .css file imported once at the app root). The class names are global and start with ef-.\n\nUse this component as the wrapper (adjust the import paths):\n```tsx\n\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport { markup, mount } from \"./photo-story-hero\"; // markup is the HTML string; mount is the JavaScript's mount function\n\nexport default function PhotoStoryHero(props: Record<string, unknown>) {\n  const ref = useRef<HTMLDivElement>(null);\n  const fx = useRef<{ update: (o: object) => void; destroy: () => void } | null>(null);\n  useEffect(() => {\n    const root = ref.current;\n    if (!root) return;\n    root.innerHTML = markup;\n    fx.current = mount(root, props);\n    return () => { fx.current?.destroy(); fx.current = null; root.innerHTML = \"\"; };\n  }, []);\n  useEffect(() => { fx.current?.update(props); }, [JSON.stringify(props)]);\n  return <div ref={ref} />;\n}\n```\n\nRender it on the home page so I can see it. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "lovable": "Add the \"Photo story hero\" element to this React + Vite + TypeScript + Tailwind project. Do not install any packages.\n\nThis is the \"Photo story hero\" element from the Enrichr Library: A dark photography hero that scrolls into a pinned 3D sphere of photo cards, turning twice as you scroll while the story beside it changes.\nPage: https://enrichr.dev/elements/photo-story-hero.html\nMachine-readable spec: https://enrichr.dev/elements/photo-story-hero.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nFiles to create:\n- photo-story-hero.js : the JavaScript below, with one line appended at the end: export { mount };  (and the markup exported as: export const markup = \"<the markup string>\";). If the project uses TypeScript and does not allow JS imports, put // @ts-nocheck on the first line.\n- the CSS below goes in your global stylesheet (or a plain .css file imported once at the app root). The class names are global and start with ef-.\n\nWrapper component (src/components/PhotoStoryHero.tsx):\n```tsx\n\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport { markup, mount } from \"./photo-story-hero\"; // markup is the HTML string; mount is the JavaScript's mount function\n\nexport default function PhotoStoryHero(props: Record<string, unknown>) {\n  const ref = useRef<HTMLDivElement>(null);\n  const fx = useRef<{ update: (o: object) => void; destroy: () => void } | null>(null);\n  useEffect(() => {\n    const root = ref.current;\n    if (!root) return;\n    root.innerHTML = markup;\n    fx.current = mount(root, props);\n    return () => { fx.current?.destroy(); fx.current = null; root.innerHTML = \"\"; };\n  }, []);\n  useEffect(() => { fx.current?.update(props); }, [JSON.stringify(props)]);\n  return <div ref={ref} />;\n}\n```\n\nRemove the \"use client\" line if it is not needed. Show it on the home page. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "bolt": "Create a small Vite project (vanilla JavaScript, no framework unless I already have one) that shows the \"Photo story hero\" element.\n\nThis is the \"Photo story hero\" element from the Enrichr Library: A dark photography hero that scrolls into a pinned 3D sphere of photo cards, turning twice as you scroll while the story beside it changes.\nPage: https://enrichr.dev/elements/photo-story-hero.html\nMachine-readable spec: https://enrichr.dev/elements/photo-story-hero.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nFiles: index.html (a container element, then a script tag), style.css (the CSS below), photo-story-hero.js (the JavaScript below) and main.js that sets the container's innerHTML to the markup and calls mount(container, opts) with the settings below. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}\n\nVerify: with reduced motion on, nothing moves; it works at 375px wide and with touch only; there are no console errors; scrolling stays smooth; destroy() is called when it unmounts.",
    "replit": "Create a new HTML, CSS and JavaScript Repl that shows the \"Photo story hero\" element. No packages, no server code.\n\nThis is the \"Photo story hero\" element from the Enrichr Library: A dark photography hero that scrolls into a pinned 3D sphere of photo cards, turning twice as you scroll while the story beside it changes.\nPage: https://enrichr.dev/elements/photo-story-hero.html\nMachine-readable spec: https://enrichr.dev/elements/photo-story-hero.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nFiles: index.html (a container element, link style.css, load photo-story-hero.js and then main.js), style.css (the CSS below), photo-story-hero.js (the JavaScript below), main.js (sets the container's innerHTML to the markup, then calls mount(container, opts) with the settings below). Press Run to preview. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "figma": "Designer handoff notes for the \"Photo story hero\" element (Enrichr Library). Figma cannot run this JavaScript inside a design file, so use it as a motion reference and hand the code to a developer.\n\nThis is the \"Photo story hero\" element from the Enrichr Library: A dark photography hero that scrolls into a pinned 3D sphere of photo cards, turning twice as you scroll while the story beside it changes.\nPage: https://enrichr.dev/elements/photo-story-hero.html\nMachine-readable spec: https://enrichr.dev/elements/photo-story-hero.json\n\nWhat it does: The hero, the gallery and a closing section sit in the element's own scroller, with the nav fixed at the top of the box. The photo cards are placed on a sphere with the Fibonacci spiral: card i of n sits at the polar angle acos(1 - 2(i + 0.5) / n) and turns by the golden angle each step, and each card is rotated with atan2 and asin so it faces outward. The sphere lives in a CSS 3D scene with perspective, so there is no WebGL. The gallery section is three box heights tall and its scene is sticky, so it stays in view for two box heights of scroll. Over that stretch the sphere turns two full times around its vertical axis and tilts 45 degrees, smoothed the way GSAP ScrollTrigger's scrub: 1 does it, by restarting a one second expo.out tween toward the scroll position on every update. The three cards nearest the scroll position drop their grey filter, and the caption beside the sphere fades out for 0.2 seconds, changes, and fades back in as you pass each quarter. In the closing section six scattered cards drift up 100 px with a 0.1 stagger. The original's sizes (a 380 px radius and 160 by 220 px cards for a 720 px tall window, a smaller sphere under 768 px wide) are scaled to the box. Autoplay scrolls down and back so a preview moves on its own.\nAccessibility: The headline, captions and buttons are real text and links, and the scroller is a focusable region that arrow keys and Page Down scroll. The sphere is one labelled image for screen readers, and caption changes are announced politely. With prefers-reduced-motion the sphere holds still at one angle, captions change without fading, the scattered cards do not drift and autoplay is off. Autoplay stops as soon as you scroll, touch, press a key or hover.\n\nTunable settings:\n- Headline: text, default Capturing Corgis, Crafting Stories\n- Intro line: text, default Follow one small dog through bright colours, open fields and the best light of the day.\n- Autoplay: select (On / Off), default 1\n- Turns over the gallery: range 0.5 to 4, step 0.5, default 2\n- Tilt (deg): range 0 to 90, step 1, default 45\n- Scroll smoothing (s): range 0 to 3, step 0.1, default 1\n- Cards on the sphere: range 12 to 40, step 1, default 24\n- Background: color, default #0c1e18\n- Cards and pills: color, default #172c25\n- Text: color, default #fbf8f1\n- Buttons and logo: color, default #e6ff55\n\nCurrent settings:\n{{SETTINGS}}\n\nFor the developer: Element contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\nIn Figma:\n- Draw the resting state as a frame and the end state as a second frame; link them with Smart Animate to show the motion in a prototype.\n- Put the settings above in the frame's notes or Dev Mode annotations so the developer matches them.\n- Link https://enrichr.dev/elements/photo-story-hero.html in the handoff: the live demo and the copy-ready code are there. Developers can paste the prompts for Claude, Cursor or v0 from the Export menu.\nNothing is copied from another designer's file; this is original code from Enrichr.",
    "framer": "Add the \"Photo story hero\" element to Framer as a code component. Framer code components are React, so wrap the vanilla code like this. Check it in the Framer editor, as Framer's component API can change.\n\nThis is the \"Photo story hero\" element from the Enrichr Library: A dark photography hero that scrolls into a pinned 3D sphere of photo cards, turning twice as you scroll while the story beside it changes.\nPage: https://enrichr.dev/elements/photo-story-hero.html\nMachine-readable spec: https://enrichr.dev/elements/photo-story-hero.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\n1. In Framer: Assets > Code > New component. Name it PhotoStoryHero.\n2. Paste the JavaScript below into a separate code file named photo-story-hero.js in the same project, with  export { mount };  and  export const markup = \"<the markup string>\";  added. Put the CSS into the component as a <style> tag.\n3. Component:\n```tsx\nimport { addPropertyControls, ControlType } from \"framer\"\nimport { useEffect, useRef } from \"react\";\nimport { markup, mount } from \"./photo-story-hero\"; // markup is the HTML string; mount is the JavaScript's mount function\n\nexport default function PhotoStoryHero(props: any) {\n  const ref = useRef<HTMLDivElement>(null);\n  const fx = useRef<{ update: (o: object) => void; destroy: () => void } | null>(null);\n  useEffect(() => {\n    const root = ref.current;\n    if (!root) return;\n    root.innerHTML = markup;\n    fx.current = mount(root, props);\n    return () => { fx.current?.destroy(); fx.current = null; root.innerHTML = \"\"; };\n  }, []);\n  useEffect(() => { fx.current?.update(props); }, [JSON.stringify(props)]);\n  return <div ref={ref} />;\n}\n\naddPropertyControls(PhotoStoryHero, {\n  headline: { type: ControlType.String, title: \"Headline\", defaultValue: \"Capturing Corgis, Crafting Stories\" },\n  lead: { type: ControlType.String, title: \"Intro line\", defaultValue: \"Follow one small dog through bright colours, open fields and the best light of the day.\" },\n  auto: { type: ControlType.Enum, title: \"Autoplay\", options: [\"1\",\"0\"], optionTitles: [\"On\",\"Off\"], defaultValue: \"1\" },\n  turns: { type: ControlType.Number, title: \"Turns over the gallery\", min: 0.5, max: 4, step: 0.5, defaultValue: 2 },\n  tilt: { type: ControlType.Number, title: \"Tilt\", min: 0, max: 90, step: 1, defaultValue: 45 },\n  smoothing: { type: ControlType.Number, title: \"Scroll smoothing\", min: 0, max: 3, step: 0.1, defaultValue: 1 },\n  cards: { type: ControlType.Number, title: \"Cards on the sphere\", min: 12, max: 40, step: 1, defaultValue: 24 },\n  background: { type: ControlType.Color, title: \"Background\", defaultValue: \"#0c1e18\" },\n  clay: { type: ControlType.Color, title: \"Cards and pills\", defaultValue: \"#172c25\" },\n  ink: { type: ControlType.Color, title: \"Text\", defaultValue: \"#fbf8f1\" },\n  accent: { type: ControlType.Color, title: \"Buttons and logo\", defaultValue: \"#e6ff55\" },\n})\n```\n4. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}",
    "webflow": "Add the \"Photo story hero\" element to a Webflow site with an Embed element. Test it on the published or preview site: scripts in embeds may not run inside the Designer canvas.\n\nThis is the \"Photo story hero\" element from the Enrichr Library: A dark photography hero that scrolls into a pinned 3D sphere of photo cards, turning twice as you scroll while the story beside it changes.\nPage: https://enrichr.dev/elements/photo-story-hero.html\nMachine-readable spec: https://enrichr.dev/elements/photo-story-hero.json\n\nElement contract (vanilla JavaScript, no dependencies):\n- The JavaScript defines a function mount(root, opts).\n- root is an empty container element. Set root.innerHTML to the markup first, then call mount(root, opts).\n- It returns an object with update(partialOpts) and destroy(). Call destroy() when the element is removed. Some elements also return replay().\n- The CSS classes all start with \"ef-\" so they will not collide with your styles.\n\n1. Add an Embed element where it should appear. Paste a <style> block with the CSS, one container <div id=\"enrichr-photo-story-hero\"></div>, and a <script> block with the JavaScript followed by:\n   mount(document.getElementById(\"enrichr-photo-story-hero\"), { ...your settings... });\n   (set the container's innerHTML to the markup first, as in the contract).\n2. Embed elements have a size limit (check Webflow's current number). If the code is longer, host the JS and CSS as files and link them from Custom code in the page settings, then keep only the container and the mount call in the Embed.\n3. Keep the behaviour of the source unchanged. Do not add dependencies. Respect prefers-reduced-motion. Keep text as real HTML.\n\nSettings:\n{{SETTINGS}}\n\nSource:\n{{CODE}}"
  },
  "exportTools": [
    "claude",
    "chatgpt",
    "cursor",
    "v0",
    "lovable",
    "bolt",
    "replit",
    "figma",
    "framer",
    "webflow"
  ],
  "accessibility": "The headline, captions and buttons are real text and links, and the scroller is a focusable region that arrow keys and Page Down scroll. The sphere is one labelled image for screen readers, and caption changes are announced politely. With prefers-reduced-motion the sphere holds still at one angle, captions change without fading, the scattered cards do not drift and autoplay is off. Autoplay stops as soon as you scroll, touch, press a key or hover.",
  "performance": "Only one transform, on the sphere, changes while it turns; each card's own position is set once and again only after a resize. Card highlights change a class, not inline styles, and nothing is written when the scroll has not moved. Six photos are reused across all cards, so the browser decodes each once. The loop pauses off screen and in hidden tabs, and with reduced motion there is no loop at all, only a scroll listener.",
  "dependencies": [],
  "codepen": "https://codepen.io/Enrichr-Dev/pen/myWajaG",
  "source": {
    "markup": "<div class=\"ef-psh\">\n  <div class=\"ef-psh__grid\" aria-hidden=\"true\"></div>\n  <div class=\"ef-psh__scroll\" tabindex=\"0\" role=\"region\" aria-label=\"Photo story. Scroll inside it to turn the gallery\">\n    <header class=\"ef-psh__hero\">\n      <span class=\"ef-psh__badge\">&#10022; A photo journal</span>\n      <h2 class=\"ef-psh__h\">Capturing Corgis, Crafting Stories</h2>\n      <p class=\"ef-psh__lead\">Follow one small dog through bright colours, open fields and the best light of the day.</p>\n    </header>\n    <section class=\"ef-psh__gallery\" aria-label=\"Gallery\">\n      <div class=\"ef-psh__float\" aria-live=\"polite\">\n        <h3 class=\"ef-psh__title\">Orange Hour</h3>\n        <p class=\"ef-psh__desc\">A puppy against a bright orange wall, shot at eye level so the ears do all the talking. One clean colour, one curious face, and nothing else in the frame.</p>\n      </div>\n      <div class=\"ef-psh__scene\" role=\"img\" aria-label=\"A sphere of corgi photo cards that turns as you scroll, with the cards at the front in full colour\">\n        <div class=\"ef-psh__sphere\"></div>\n      </div>\n      <svg class=\"ef-psh__lines\" viewBox=\"0 0 100 100\" preserveAspectRatio=\"none\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M0,50 Q25,30 50,50 T100,50\" stroke=\"rgba(255,255,255,0.05)\" stroke-width=\"0.2\" fill=\"none\"/><path d=\"M20,0 L80,100\" stroke=\"rgba(255,255,255,0.03)\" stroke-width=\"0.2\" fill=\"none\"/></svg>\n    </section>\n    <section class=\"ef-psh__journey\" aria-label=\"Book a session\">\n      <div class=\"ef-psh__scatter\" aria-hidden=\"true\"></div>\n      <div class=\"ef-psh__cta\">\n        <h3 class=\"ef-psh__ctah\">Start Your Own Story</h3>\n        <p class=\"ef-psh__ctap\">Bring your dog. We will bring the light.</p>\n        <a href=\"#\" class=\"ef-psh__btn ef-psh__btn--chunky ef-psh__btn--big\">Book a session</a>\n      </div>\n    </section>\n  </div>\n  <nav class=\"ef-psh__nav\" aria-label=\"Main\">\n    <a href=\"#\" class=\"ef-psh__logo\"><svg viewBox=\"0 0 50 50\" aria-hidden=\"true\" focusable=\"false\"><path fill=\"currentColor\" d=\"m34.213 25.361 c0 2.655 1.381 4.808 3.086 4.808 0.781 0 1.494 -0.452 2.038 -1.198 0.267 -0.366 0.688 -0.589 1.142 -0.589 h0.054 c0.774 0 1.401 0.627 1.401 1.401 v10.65 h-10.651 c-0.774 0 -1.401 0.627 -1.401 1.401 v0.054 c0 0.453 0.222 0.874 0.589 1.141 0.746 0.544 1.198 1.257 1.198 2.038 0 1.705 -2.152 3.086 -4.808 3.086 -2.655 0 -4.808 -1.381 -4.808 -3.086 0 -0.781 0.452 -1.494 1.198 -2.038 0.366 -0.267 0.589 -0.688 0.589 -1.142 v-0.054 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-10.65 v-10.651 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-0.054 c-0.453 0 -0.875 0.222 -1.142 0.589 -0.543 0.746 -1.257 1.198 -2.038 1.198 -1.705 0 -3.086 -2.152 -3.086 -4.808 0 -2.655 1.381 -4.808 3.086 -4.808 0.781 0 1.495 0.452 2.038 1.198 0.267 0.366 0.688 0.589 1.141 0.589 h0.054 c0.774 0 1.401 -0.627 1.401 -1.401 v-10.65 h10.65 c0.774 0 1.401 -0.627 1.401 -1.401 v-0.054 c0 -0.453 -0.222 -0.875 -0.589 -1.142 -0.746 -0.543 -1.198 -1.257 -1.198 -2.038 0 -1.705 2.152 -3.086 4.808 -3.086 2.655 0 4.808 1.381 4.808 3.086 0 0.781 -0.452 1.495 -1.198 2.038 -0.366 0.267 -0.589 0.688 -0.589 1.141 v0.054 c0 0.774 0.627 1.401 1.401 1.401 h10.65 v10.65 c0 0.774 -0.627 1.401 -1.401 1.401 h-0.053 c-0.453 0 -0.874 -0.222 -1.141 -0.589 -0.544 -0.746 -1.257 -1.198 -2.038 -1.198 -1.705 0 -3.086 2.152 -3.086 4.808 z\"/></svg>Enrichr</a>\n    <div class=\"ef-psh__links\">\n      <a href=\"#\" class=\"ef-psh__link\">Gallery</a>\n      <a href=\"#\" class=\"ef-psh__link\">Stories</a>\n      <a href=\"#\" class=\"ef-psh__link\">Log in</a>\n      <a href=\"#\" class=\"ef-psh__btn ef-psh__btn--chunky\">Sign up</a>\n    </div>\n    <button type=\"button\" class=\"ef-psh__btn ef-psh__menu\">Menu <span aria-hidden=\"true\">=</span></button>\n  </nav>\n</div>",
    "css": "/* Enrichr Library · https://enrichr.dev */\n.ef-psh { --ef-psh-w: 1280px; --ef-psh-h: 720px; --ef-psh-k: 1; --ef-psh-bg: #0c1e18; --ef-psh-clay: #172c25; --ef-psh-ink: #fbf8f1; --ef-psh-acc: #e6ff55; --ef-psh-muted: color-mix(in srgb, var(--ef-psh-ink) 62%, var(--ef-psh-bg)); --ef-psh-cw: 160px; --ef-psh-ch: 220px; position: relative; isolation: isolate; width: 100%; min-height: 32rem; overflow: hidden; border-radius: 1rem; background: var(--ef-psh-bg); color: var(--ef-psh-ink); font-family: 'Instrument Sans', system-ui, sans-serif; font-size: 16px; line-height: 1.5; text-align: left; }\n.ef-psh .ef-psh__grid { position: absolute; inset: 0; z-index: 0; pointer-events: none; background-image: linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px); background-size: 40px 40px; }\n.ef-psh .ef-psh__scroll { position: absolute; inset: 0; z-index: 1; overflow-x: hidden; overflow-y: auto; outline: none; scrollbar-width: none; }\n.ef-psh .ef-psh__scroll::-webkit-scrollbar { display: none; }\n.ef-psh .ef-psh__scroll:focus-visible { box-shadow: inset 0 0 0 3px var(--ef-psh-acc); border-radius: 1rem; }\n\n/* nav: stays at the top of the box like the original's fixed bar */\n.ef-psh .ef-psh__nav { position: absolute; top: 0; left: 0; right: 0; z-index: 3; box-sizing: border-box; display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: calc(24px * var(--ef-psh-k)) calc(48px * var(--ef-psh-k)); background: linear-gradient(to bottom, color-mix(in srgb, var(--ef-psh-bg) 90%, transparent), transparent); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); pointer-events: none; }\n.ef-psh .ef-psh__nav > * { pointer-events: auto; }\n.ef-psh .ef-psh__logo { display: flex; align-items: center; gap: 10px; color: var(--ef-psh-ink); text-decoration: none; font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-size: 1.2rem; font-weight: 700; letter-spacing: -0.03em; white-space: nowrap; }\n.ef-psh .ef-psh__logo svg { width: 26px; height: 26px; color: var(--ef-psh-acc); }\n.ef-psh .ef-psh__links { display: flex; align-items: center; gap: 2rem; padding: 0.5rem 1rem 0.5rem 2rem; border-radius: 40px; background: var(--ef-psh-clay); border: 1px solid rgba(255, 255, 255, 0.02); box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.9), inset 1px 1px 2px rgba(255, 255, 255, 0.04), inset -1px -1px 2px rgba(0, 0, 0, 0.6); }\n.ef-psh .ef-psh__link { color: var(--ef-psh-ink); text-decoration: none; font-size: 0.9rem; transition: color 0.3s ease; }\n.ef-psh .ef-psh__link:hover { color: var(--ef-psh-muted); }\n.ef-psh .ef-psh__btn { display: inline-flex; align-items: center; gap: 8px; padding: 0.6rem 1.2rem; border: none; border-radius: 30px; font-family: inherit; font-size: 0.9rem; font-weight: 500; line-height: 1.2; text-decoration: none; cursor: pointer; color: var(--ef-psh-bg); background: var(--ef-psh-ink); transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); white-space: nowrap; }\n.ef-psh .ef-psh__btn--chunky { color: var(--ef-psh-bg); background: var(--ef-psh-acc); box-shadow: 0 4px 0 color-mix(in srgb, var(--ef-psh-acc) 45%, #000), 0 6px 10px rgba(0, 0, 0, 0.4), inset 0 -2px 5px rgba(0, 0, 0, 0.1), inset 0 2px 5px rgba(255, 255, 255, 0.8); }\n.ef-psh .ef-psh__btn--chunky:active { transform: translateY(4px); box-shadow: 0 0 0 color-mix(in srgb, var(--ef-psh-acc) 45%, #000), 0 2px 5px rgba(0, 0, 0, 0.4), inset 0 -1px 2px rgba(0, 0, 0, 0.1); }\n.ef-psh .ef-psh__menu { color: var(--ef-psh-ink); background: var(--ef-psh-clay); border: 1px solid rgba(255, 255, 255, 0.05); box-shadow: 0 4px 0 #050d0a, 0 6px 10px rgba(0, 0, 0, 0.6), inset 1px 1px 2px rgba(255, 255, 255, 0.04); }\n.ef-psh .ef-psh__btn:focus-visible, .ef-psh .ef-psh__link:focus-visible, .ef-psh .ef-psh__logo:focus-visible { outline: 3px solid var(--ef-psh-acc); outline-offset: 3px; }\n\n/* hero copy */\n.ef-psh .ef-psh__hero { position: relative; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; gap: calc(24px * var(--ef-psh-k)); padding: calc(192px * var(--ef-psh-k)) 2rem calc(64px * var(--ef-psh-k)); text-align: center; }\n.ef-psh .ef-psh__badge { display: inline-block; padding: 0.5rem 1rem; border-radius: 20px; font-size: 0.8rem; line-height: 1.5; color: var(--ef-psh-muted); background: var(--ef-psh-clay); border: 1px solid rgba(255, 255, 255, 0.05); box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.9), inset 2px 2px 4px rgba(255, 255, 255, 0.04), inset -2px -2px 4px rgba(0, 0, 0, 0.6); }\n.ef-psh .ef-psh__h { max-width: 800px; margin: 0; font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-size: clamp(1.7rem, calc(var(--ef-psh-w) * 0.05), 4.5rem); font-weight: 500; line-height: 1.4; letter-spacing: -0.03em; color: var(--ef-psh-ink); opacity: 1; text-transform: none; }\n.ef-psh .ef-psh__lead { max-width: 500px; margin: 0; font-size: 1rem; line-height: 1.5; color: var(--ef-psh-muted); opacity: 1; }\n\n/* the pinned gallery: three box heights of scroll with the scene stuck in view */\n.ef-psh .ef-psh__gallery { position: relative; height: calc(var(--ef-psh-h) * 3); width: 100%; }\n.ef-psh .ef-psh__float { position: sticky; top: 50%; z-index: 10; width: min(300px, 40%); margin-left: 10%; transform: translateY(-50%); }\n.ef-psh .ef-psh__title { margin: 0 0 1rem; font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-size: clamp(1.15rem, calc(32px * var(--ef-psh-k)), 2rem); font-weight: 500; line-height: 1.5; letter-spacing: -0.03em; color: var(--ef-psh-ink); opacity: 1; text-shadow: 0 4px 10px rgba(0, 0, 0, 0.8); }\n.ef-psh .ef-psh__desc { margin: 0; font-size: 0.9rem; line-height: 1.5; color: var(--ef-psh-muted); opacity: 1; }\n.ef-psh .ef-psh__scene { position: sticky; top: 0; height: var(--ef-psh-h); width: 100%; display: flex; justify-content: center; align-items: center; perspective: calc(1200px * var(--ef-psh-k)); overflow: hidden; }\n.ef-psh .ef-psh__sphere { position: relative; width: 0; height: 0; transform-style: preserve-3d; }\n.ef-psh .ef-psh__card { position: absolute; box-sizing: border-box; width: var(--ef-psh-cw); height: var(--ef-psh-ch); left: calc(var(--ef-psh-cw) / -2); top: calc(var(--ef-psh-ch) / -2); padding: calc(8px * var(--ef-psh-k)); border-radius: calc(16px * var(--ef-psh-k)); background: var(--ef-psh-clay); border: 1px solid rgba(255, 255, 255, 0.03); transform-style: preserve-3d; backface-visibility: visible; box-shadow: 8px 8px 16px rgba(0, 0, 0, 0.9), -4px -4px 10px rgba(255, 255, 255, 0.02), inset 3px 3px 6px rgba(255, 255, 255, 0.04), inset -3px -3px 6px rgba(0, 0, 0, 0.6); transition: filter 0.4s ease; }\n.ef-psh .ef-psh__img { display: block; width: 100%; height: 100%; max-width: none; margin: 0; object-fit: cover; border-radius: calc(10px * var(--ef-psh-k)); filter: grayscale(80%) brightness(0.6); transition: all 0.4s ease; user-select: none; pointer-events: none; }\n.ef-psh .ef-psh__card.is-active { filter: drop-shadow(0 0 20px rgba(255, 255, 255, 0.1)); }\n.ef-psh .ef-psh__card.is-active .ef-psh__img { filter: grayscale(0%) brightness(1); }\n.ef-psh .ef-psh__lines { position: absolute; top: 0; left: 0; z-index: -1; width: 100%; height: 100%; pointer-events: none; }\n\n/* closing section with scattered cards drifting up */\n.ef-psh .ef-psh__journey { position: relative; box-sizing: border-box; min-height: var(--ef-psh-h); display: flex; justify-content: center; align-items: center; padding: 2rem 1rem; overflow: hidden; background: radial-gradient(circle at center, color-mix(in srgb, var(--ef-psh-bg) 95%, var(--ef-psh-ink)) 0%, var(--ef-psh-bg) 70%); }\n.ef-psh .ef-psh__scatter { position: absolute; inset: 0; pointer-events: none; }\n.ef-psh .ef-psh__scatter .ef-psh__card { width: calc(100px * var(--ef-psh-k)); height: calc(140px * var(--ef-psh-k)); opacity: 0.5; }\n.ef-psh .ef-psh__cta { position: relative; z-index: 2; box-sizing: border-box; max-width: 100%; padding: calc(48px * var(--ef-psh-k)); text-align: center; border-radius: 24px; background: color-mix(in srgb, var(--ef-psh-bg) 60%, transparent); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, 0.05); box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5); }\n.ef-psh .ef-psh__ctah { margin: 0 0 0.5rem; font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-size: clamp(1.5rem, calc(48px * var(--ef-psh-k)), 3rem); font-weight: 500; line-height: 1.3; letter-spacing: -0.03em; color: var(--ef-psh-ink); opacity: 1; }\n.ef-psh .ef-psh__ctap { margin: 0 0 2rem; font-size: 1rem; line-height: 1.5; color: var(--ef-psh-muted); opacity: 1; }\n.ef-psh .ef-psh__btn--big { font-size: 1.1rem; padding: 1rem 2.5rem; border-radius: 40px; }\n\n/* narrow boxes, like the original's 768px breakpoint */\n.ef-psh.ef-psh--narrow .ef-psh__links { display: none; }\n.ef-psh.ef-psh--narrow .ef-psh__h { font-size: clamp(1.5rem, calc(var(--ef-psh-w) * 0.075), 2.5rem); }\n.ef-psh.ef-psh--narrow .ef-psh__float { position: absolute; top: 80%; margin-left: 5%; width: 90%; text-align: center; }",
    "js": "// Enrichr Library · https://enrichr.dev\n// Photo story hero: a dark photography hero (nav, badge, centred headline) that scrolls into a pinned 3D gallery. Photo\n// cards sit on a sphere laid out with the Fibonacci (golden angle) spiral, each turned to face outward, in a CSS 3D scene.\n// While the gallery section is pinned for two box heights of scroll, the sphere turns two full times around its vertical\n// axis and tilts 45 degrees, with 1 s of scrub smoothing (an expo.out tween restarted toward the scroll position on every\n// update, like GSAP ScrollTrigger's scrub: 1). The cards nearest the scroll position light up in full colour, and the story\n// text beside the sphere cross-fades (0.2 s out, 0.2 s in) through four captions. A closing section has scattered cards\n// drifting up with a staggered parallax. Everything lives in the element's own scroller, so it works inside any box.\n// Autoplay scrolls it down and back up on its own until someone scrolls, touches or hovers it. Reduced motion holds the\n// sphere still at one angle, swaps the captions without fading, drops the parallax and turns autoplay off.\n// The loop pauses off screen and in hidden tabs.\n// mount(root, { headline, lead, auto (\"1\" on, \"0\" off), turns (full turns over the gallery), tilt (deg), smoothing (s),\n//               cards (number on the sphere), background, clay (card and pill colour), ink (text), accent (buttons, logo) })\nfunction mount(root, o) {\n  var reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  var box = root.querySelector(\".ef-psh\");\n  var scroller = root.querySelector(\".ef-psh__scroll\");\n  var gallery = root.querySelector(\".ef-psh__gallery\");\n  var sphere = root.querySelector(\".ef-psh__sphere\");\n  var journey = root.querySelector(\".ef-psh__journey\");\n  var scatterBox = root.querySelector(\".ef-psh__scatter\");\n  var floatEl = root.querySelector(\".ef-psh__float\");\n  var titleEl = root.querySelector(\".ef-psh__title\");\n  var descEl = root.querySelector(\".ef-psh__desc\");\n  var headEl = root.querySelector(\".ef-psh__h\");\n  var leadEl = root.querySelector(\".ef-psh__lead\");\n  var IMGS = [\"/img/library/corgi-1.jpg\", \"/img/library/corgi-2.jpg\", \"/img/library/corgi-3.jpg\", \"/img/library/corgi-4.jpg\", \"/img/library/corgi-5.jpg\", \"/img/library/corgi-6.jpg\"];\n  var CROPS = [\"50% 40%\", \"35% 50%\", \"65% 55%\", \"50% 75%\"]; // repeats of a photo show a different part of it\n  var STORIES = [\n    [\"Orange Hour\", \"A puppy against a bright orange wall, shot at eye level so the ears do all the talking.\"],\n    [\"Field of Flowers\", \"Wildflowers to the horizon and a dog who sat still for exactly one frame.\"],\n    [\"Autumn Lookout\", \"Up on a rock among the turning leaves, keeping watch over the whole trail.\"],\n    [\"Road Trip\", \"A corgi and a friend mid-stride on a dirt road, with nothing ahead but dust and warm light.\"]\n  ];\n  var SCATTER = [[\"10%\", \"15%\"], [\"20%\", \"80%\"], [\"60%\", \"10%\"], [\"75%\", \"85%\"], [\"85%\", \"30%\"], [\"15%\", \"50%\"]];\n  var opts = {}, cards = [], scat = [], W = 1, H = 1, k = 1, narrow = false, base = null;\n  var rot = { from: 0, to: 0, cur: 0, t: 0 }, lastRaw = -1, lastQ = -1, focus = -99, drawnRot = -1, shown = 0, want = 0, fade = null;\n  var raf = 0, last = 0, visible = true, destroyed = false, io = null, ro = null, offs = [], hold = 0, auto = null, built = 0;\n\n  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }\n  function now() { return performance.now(); }\n  function clamp(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }\n  function rand(i, salt) { var h = Math.imul(i + 1 + salt * 7919, 374761393) ^ Math.imul(salt + 13, 668265263); h = Math.imul(h ^ (h >>> 13), 1274126177); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; }\n  function expoOut(p) { return p >= 1 ? 1 : 1 - Math.pow(2, -10 * p); } // ScrollTrigger's scrub ease\n  function sineInOut(p) { return (1 - Math.cos(Math.PI * p)) / 2; }\n  function card(src, crop) {\n    var c = document.createElement(\"div\"); c.className = \"ef-psh__card\";\n    var im = document.createElement(\"img\"); im.className = \"ef-psh__img\"; im.src = src; im.alt = \"\"; im.decoding = \"async\"; im.draggable = false;\n    im.style.objectPosition = crop;\n    c.appendChild(im);\n    return c;\n  }\n  function build() {\n    if (built === opts.cards) return;\n    built = opts.cards;\n    sphere.textContent = \"\"; cards = [];\n    for (var i = 0; i < opts.cards; i++) {\n      var c = card(IMGS[i % IMGS.length], CROPS[Math.floor(i / IMGS.length) % CROPS.length]);\n      sphere.appendChild(c); cards.push(c);\n    }\n    focus = -99; lastRaw = -1;\n  }\n  function buildScatter() {\n    scatterBox.textContent = \"\"; scat = [];\n    SCATTER.forEach(function (pos, i) {\n      var c = card(IMGS[i % IMGS.length], \"50% 45%\");\n      c.style.top = pos[0]; c.style.left = pos[1];\n      scatterBox.appendChild(c);\n      scat.push({ el: c, rot: (rand(i, 3) - 0.5) * 40 });\n    });\n  }\n  // golden-angle spiral on a sphere; each card turned so it faces out from the centre\n  function place() {\n    var n = cards.length, r = base.r * k;\n    cards.forEach(function (c, i) {\n      var phi = Math.acos(1 - 2 * (i + 0.5) / n), theta = Math.PI * (1 + Math.sqrt(5)) * i;\n      var x = r * Math.cos(theta) * Math.sin(phi), y = r * Math.sin(theta) * Math.sin(phi), z = r * Math.cos(phi);\n      var ry = Math.atan2(x, z) * 180 / Math.PI, rx = Math.asin(-y / r) * 180 / Math.PI;\n      c.style.transform = \"translate3d(\" + x.toFixed(2) + \"px,\" + y.toFixed(2) + \"px,\" + z.toFixed(2) + \"px) rotateY(\" + ry.toFixed(3) + \"deg) rotateX(\" + rx.toFixed(3) + \"deg)\";\n    });\n  }\n  // the original sized everything for a 720 px tall window (radius 380, cards 160 x 220) and switched to a smaller sphere\n  // under 768 px wide; here the box is the window, so the same numbers are scaled by the box size\n  function size() {\n    W = Math.max(1, box.clientWidth); H = Math.max(1, box.clientHeight);\n    narrow = W < 768;\n    base = narrow ? { r: 200, cw: 120, ch: 160 } : { r: 380, cw: 160, ch: 220 };\n    k = Math.max(0.3, Math.min(1.3, Math.min(H / 720, W / (narrow ? 400 : 1100))));\n    box.classList.toggle(\"ef-psh--narrow\", narrow);\n    box.style.setProperty(\"--ef-psh-w\", W + \"px\");\n    box.style.setProperty(\"--ef-psh-h\", H + \"px\");\n    box.style.setProperty(\"--ef-psh-k\", k.toFixed(4));\n    box.style.setProperty(\"--ef-psh-cw\", (base.cw * k).toFixed(2) + \"px\");\n    box.style.setProperty(\"--ef-psh-ch\", (base.ch * k).toFixed(2) + \"px\");\n    place();\n    lastRaw = -1; lastQ = -1; drawnRot = -1;\n  }\n  function galleryProgress() { // trigger the gallery, start \"top top\", end \"bottom bottom\"\n    var span = gallery.offsetHeight - scroller.clientHeight;\n    return span > 0 ? clamp((scroller.scrollTop - gallery.offsetTop) / span) : 0;\n  }\n  function journeyProgress() { // start \"top bottom\", end \"bottom top\"\n    var span = journey.offsetHeight + scroller.clientHeight;\n    return clamp((scroller.scrollTop + scroller.clientHeight - journey.offsetTop) / span);\n  }\n  function drawSphere(t) {\n    var d = opts.smoothing * 1000;\n    rot.cur = reduce ? 0.15 : d > 0 ? rot.from + (rot.to - rot.from) * expoOut((t - rot.t) / d) : rot.to;\n    if (Math.abs(rot.cur - drawnRot) < 0.00005) return;\n    drawnRot = rot.cur;\n    sphere.style.transform = \"rotateY(\" + (opts.turns * 360 * rot.cur).toFixed(3) + \"deg) rotateX(\" + (opts.tilt * rot.cur).toFixed(3) + \"deg)\";\n  }\n  function setText(i) { titleEl.textContent = STORIES[i][0]; descEl.textContent = STORIES[i][1]; }\n  function setOpacity(v) { titleEl.style.opacity = descEl.style.opacity = String(v); }\n  // 0.2 s fade out (power1.out), swap to the newest caption, 0.2 s fade in; a change mid-fade-in fades out from where it is\n  function stepFade(t) {\n    if (reduce) { if (want !== shown) { shown = want; setText(shown); } return; }\n    if (!fade) { if (want === shown) return; fade = { out: true, t: t, from: 1 }; }\n    else if (!fade.out && want !== shown) fade = { out: true, t: t, from: Number(titleEl.style.opacity || 1) };\n    var p = Math.min(1, (t - fade.t) / 200), e = 1 - (1 - p) * (1 - p);\n    if (fade.out) {\n      setOpacity(fade.from * (1 - e));\n      if (p >= 1) { shown = want; setText(shown); fade = { out: false, t: t }; }\n    } else {\n      setOpacity(e);\n      if (p >= 1) fade = null;\n    }\n  }\n  function onScroll(t) {\n    var raw = galleryProgress();\n    if (raw !== lastRaw) {\n      lastRaw = raw;\n      rot.from = rot.cur; rot.to = raw; rot.t = t;\n      want = Math.floor(raw * STORIES.length) % STORIES.length; // wraps back to the first caption at the very end, as before\n      var f = Math.floor(raw * cards.length);\n      if (f !== focus) { focus = f; cards.forEach(function (c, i) { c.classList.toggle(\"is-active\", Math.abs(i - f) < 2); }); }\n    }\n    var q = reduce ? 0 : journeyProgress();\n    if (q !== lastQ) {\n      lastQ = q;\n      var total = 0.5 + 0.1 * (scat.length - 1); // stagger 0.1 on 0.5 s tweens, scrubbed straight to the scroll\n      scat.forEach(function (s, i) {\n        var local = clamp((q * total - 0.1 * i) / 0.5);\n        s.el.style.transform = \"translate3d(0,\" + (-100 * k * local).toFixed(2) + \"px,0) rotate(\" + s.rot.toFixed(2) + \"deg) scale(0.8)\";\n      });\n    }\n  }\n\n  // autoplay: down through the gallery to the end, pause, back to the top, pause, again\n  function autoOn() { return !reduce && String(opts.auto) !== \"0\"; }\n  function autoStep(t) {\n    var max = Math.max(0, scroller.scrollHeight - scroller.clientHeight);\n    if (!auto) { var at = scroller.scrollTop, down = at < max * 0.5; auto = { from: at, to: down ? max : 0, t: t + 1500, d: down ? 14000 : 3500 }; }\n    var p = (t - auto.t) / auto.d;\n    if (p < 0) return;\n    scroller.scrollTop = auto.from + (auto.to - auto.from) * sineInOut(Math.min(1, p));\n    if (p >= 1) { var back = auto.to > 0; auto = { from: auto.to, to: back ? 0 : max, t: t + (back ? 2000 : 1500), d: back ? 3500 : 14000 }; }\n  }\n  function pause(ms) { hold = Math.max(hold, now() + ms); auto = null; }\n\n  function frame(t) {\n    drawSphere(t);   // render the scrub first, then restart it toward the new scroll position (GSAP's ticker order)\n    onScroll(t);\n    stepFade(t);\n  }\n  function loop(t) {\n    raf = requestAnimationFrame(loop);\n    last = t;\n    if (!visible || document.hidden) return;\n    if (autoOn() && t > hold) autoStep(t);\n    frame(t);\n  }\n\n  function on(el, type, fn, opt) { el.addEventListener(type, fn, opt); offs.push(function () { el.removeEventListener(type, fn, opt); }); }\n  function onUser() { pause(4000); }\n\n  function update(next) {\n    opts = Object.assign({ headline: \"Capturing Corgis, Crafting Stories\", lead: \"Follow one small dog through bright colours, open fields and the best light of the day.\", auto: \"1\", turns: 2, tilt: 45, smoothing: 1, cards: 24, background: \"#0c1e18\", clay: \"#172c25\", ink: \"#fbf8f1\", accent: \"#e6ff55\" }, opts, next || {});\n    opts.turns = num(opts.turns, 2); opts.tilt = num(opts.tilt, 45); opts.smoothing = Math.max(0, num(opts.smoothing, 1));\n    opts.cards = Math.max(6, Math.min(60, Math.round(num(opts.cards, 24))));\n    box.style.setProperty(\"--ef-psh-bg\", opts.background);\n    box.style.setProperty(\"--ef-psh-clay\", opts.clay);\n    box.style.setProperty(\"--ef-psh-ink\", opts.ink);\n    box.style.setProperty(\"--ef-psh-acc\", opts.accent);\n    headEl.textContent = opts.headline; leadEl.textContent = opts.lead;\n    headEl.hidden = !opts.headline; leadEl.hidden = !opts.lead;\n    if (destroyed) return;\n    build();\n    if (base) place();\n    drawnRot = -1; lastRaw = -1;\n    frame(now());\n  }\n\n  buildScatter();\n  update(o);\n  size();\n  frame(now());\n  on(box, \"click\", function (e) { var a = e.target.closest && e.target.closest('a[href=\"#\"]'); if (a) e.preventDefault(); });\n  if (\"ResizeObserver\" in window) { ro = new ResizeObserver(function () { if (destroyed) return; if (box.clientWidth !== W || box.clientHeight !== H) { size(); frame(now()); } }); ro.observe(box); }\n  if (\"IntersectionObserver\" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(box); }\n  if (reduce) {\n    // nothing moves on its own: just follow the scroll for the captions and the highlighted cards\n    on(scroller, \"scroll\", function () { if (!destroyed) frame(now()); }, { passive: true });\n  } else {\n    [\"wheel\", \"touchstart\", \"pointerdown\", \"keydown\"].forEach(function (t) { on(scroller, t, onUser, { passive: true }); });\n    on(scroller, \"pointerenter\", function (e) { if (e.pointerType === \"mouse\") pause(1e9); });\n    on(scroller, \"pointerleave\", function (e) { if (e.pointerType === \"mouse\") hold = now() + 2000; });\n    last = now(); raf = requestAnimationFrame(loop);\n  }\n  return {\n    update: update,\n    destroy: function () {\n      destroyed = true; cancelAnimationFrame(raf);\n      offs.forEach(function (f) { f(); }); offs = [];\n      if (io) io.disconnect();\n      if (ro) ro.disconnect();\n    }\n  };\n}"
  }
}