> ## Documentation Index
> Fetch the complete documentation index at: /llms.txt
> Use this file to discover all available pages before exploring further.

# Wireframe Portal Title

> A wireframe portal bursts open, the title comes through, then its letters swap into a second phrase.

# Wireframe Portal Title

A wireframe portal bursts open, the title comes through, then its letters swap into a second phrase.

**Wireframe Portal Title**

```html wireframe-portal-title.html theme={null}
<!doctype html>
<html
  lang="en"
  data-composition-variables='[{"id":"title","type":"string","label":"Title","default":"BREAKTHROUGH","maxLength":18},{"id":"replacementPhrase","type":"string","label":"3D replacement phrase (auto layout)","default":"Lets do this sir!","description":"Every word is preserved. Whole-word line wrapping and type size adapt to the 3D frame."},{"id":"phraseDuration","type":"number","label":"Phrase transition duration (s)","default":1.85,"min":0.65,"max":2.9,"step":0.05,"unit":"s","description":"Total time for the letter transition, including the stagger. Lower is faster. The opening and white return keep their original timing."},{"id":"phraseEasing","type":"number","label":"Transition easing (1 gentle – 8 snap)","default":8,"min":1,"max":8,"step":0.25,"description":"Higher values keep the beginning slow and concentrate acceleration at the letter swap, then settle gently. 4 is the default."},{"id":"settleGlitch","type":"number","label":"Letter settle glitch strength","default":0.2,"min":0,"max":4,"step":0.05,"description":"Scales the deterministic block glitch and letter jitter around swap and settling. 0 disables it; 4 makes it strongest."},{"id":"depthFog","type":"boolean","label":"Depth fog (early fade always on)","default":true,"description":"Dims receding outlines into the dark scene. Early opacity fades remain active when fog is off."},{"id":"subtitle","type":"string","label":"Subtitle","default":"HYPERFRAMES PORTAL TITLE","maxLength":48},{"id":"accent","type":"color","label":"Accent","default":"#F5C518"},{"id":"burstChaos","type":"number","label":"Burst chaos (0-2)","default":1,"min":0,"max":2,"step":0.01}]'
>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>Wireframe Portal Title</title>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/clipper-lib@6.4.2/clipper.js"></script>
    <script type="importmap">
      {
        "imports": {
          "three": "https://cdn.jsdelivr.net/npm/three@0.181.2/build/three.module.js",
          "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.181.2/examples/jsm/"
        }
      }
    </script>
    <style>
      @font-face {
        font-family: "Geist";
        src: url("assets/fonts/Geist-Regular.ttf") format("truetype");
        font-weight: 400;
      }
      @font-face {
        font-family: "Geist";
        src: url("assets/fonts/Geist-SemiBold.ttf") format("truetype");
        font-weight: 600;
      }
      @font-face {
        font-family: "Geist";
        src: url("assets/fonts/Geist-Bold.ttf") format("truetype");
        font-weight: 700;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: #000;
      }
      body {
        font-family: "Geist", "Inter", system-ui, sans-serif;
      }
      #root {
        position: relative;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
      }
      .clip {
        position: absolute;
        inset: 0;
      }
      /* dark underlay (never on the root itself) */
      #wpt-under {
        position: absolute;
        inset: 0;
        background: #0b0b0b;
      }
      /* ---- flat 2D title card ---- */
      #wpt-card {
        position: absolute;
        inset: 0;
        background: #e8e8e8;
        color: #0a0a0a;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
      }
      #wpt-kicker {
        font-weight: 600;
        font-size: 22px;
        letter-spacing: 0;
        text-indent: 0;
        color: #1f1f1f;
        margin-bottom: 34px;
        display: flex;
        align-items: center;
        gap: 18px;
      }
      #wpt-headline {
        font-weight: 700;
        font-size: 176px;
        line-height: 1;
        letter-spacing: 0.03em;
        white-space: nowrap;
        color: #0a0a0a;
      }
      #wpt-headline span {
        display: inline-block;
      }
      #wpt-subtitle {
        margin-top: 40px;
        font-weight: 400;
        font-size: 34px;
        letter-spacing: 0.02em;
        color: #3d3d3d;
      }
      /* ---- canvas layers (intentionally cover the 2D card once the portal opens) ---- */
      #wpt-three,
      #wpt-warp {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
        display: block;
      }
      #wpt-white {
        position: absolute;
        inset: 0;
        background: #ffffff;
        opacity: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
    </style>
  </head>
  <body>
    <div
      data-hf-id="hf-ek69"
      id="root"
      data-composition-id="wireframe-portal-title"
      data-start="0"
      data-duration="8"
      data-width="1920"
      data-height="1080"
    >
      <section
        data-hf-id="hf-50tz"
        id="wpt-scene"
        class="clip"
        data-start="0"
        data-duration="8"
        data-track-index="1"
      >
        <div data-hf-id="hf-6av1" id="wpt-under"></div>

        <div data-hf-id="hf-r32l" id="wpt-card" data-layout-allow-occlusion="">
          <div data-hf-id="hf-0bgu" id="wpt-kicker">
            <span data-hf-id="hf-m027" id="wpt-kicker-text">HYPERFRAMES PRESENTS</span>
          </div>
          <h1 data-hf-id="hf-pwyf" id="wpt-headline"></h1>
          <div data-hf-id="hf-jivp" id="wpt-subtitle"></div>
        </div>

        <div data-hf-id="hf-dugk" id="wpt-white" data-layout-allow-occlusion=""></div>
      </section>
    </div>

    <script>
      // ---- variables (safe fallback when API absent) ----
      (function () {
        var vars = {};
        try {
          if (window.__hyperframes && window.__hyperframes.getVariables) {
            vars = window.__hyperframes.getVariables() || {};
          }
        } catch (e) {}
        var title =
          String(vars.title || "BREAKTHROUGH")
            .trim()
            .toUpperCase() || "BREAKTHROUGH";
        var subtitle = String(vars.subtitle || "HYPERFRAMES PORTAL TITLE");
        // Preserve every word. The 3D layout wraps whole words and scales to fit.
        window.__wptPhrase =
          String(vars.replacementPhrase || "BEYOND LIMITS")
            .trim()
            .toUpperCase()
            .replace(/\s+/g, " ") || "BEYOND LIMITS";
        function numberVar(id, fallback, min, max) {
          var value = vars[id] === undefined ? fallback : Number(vars[id]);
          return isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
        }
        window.__wptTransition = {
          start: 2.85,
          duration: numberVar("phraseDuration", 1.85, 0.65, 2.9),
          easing: numberVar("phraseEasing", 4, 1, 8),
          glitch: numberVar("settleGlitch", 1, 0, 4),
          fog: vars.depthFog !== false,
        };
        var accent = String(vars.accent || "#F5C518");
        document.documentElement.style.setProperty("--wpt-accent", accent);
        window.__wptAccent = accent;
        var bc = Number(vars.burstChaos);
        window.__wptBurstChaos = isFinite(bc) ? Math.max(0, Math.min(2, bc)) : 1;

        function fillHeadline(id, text, prefix) {
          var hl = document.getElementById(id);
          for (var i = 0; i < text.length; i++) {
            var ch = text[i];
            var span = document.createElement("span");
            if (ch === " ") {
              span.innerHTML = "&nbsp;";
            } else {
              span.textContent = ch;
              span.setAttribute("data-3d-char", prefix + i);
            }
            hl.appendChild(span);
          }
        }
        fillHeadline("wpt-headline", title, "t-");
        document.getElementById("wpt-subtitle").textContent = subtitle;
        // Runtime canvases keep the Studio variables parser intact.
        ["wpt-three", "wpt-warp"].forEach(function (id) {
          var canvas = document.createElement("canvas");
          canvas.id = id;
          canvas.width = 1920;
          canvas.height = 1080;
          document
            .getElementById("wpt-scene")
            .insertBefore(canvas, document.getElementById("wpt-white"));
        });
      })();
    </script>

    <script>
      // ---- state proxy + single paused timeline ----
      window.__timelines = window.__timelines || {};
      const S = {
        portal: 0, // 0 = flat 2D page, 1 = 3D wireframe world
        push: 0, // exit camera push through the letterforms
        glitch: 0, // block-glitch tick amount
        warp: 0, // warp-dot visibility
        travel: 0, // warp-dot accumulated z travel (world units)
        exitFx: 0, // zoom-blur / brightness lift on the way out
      };
      window.__wptState = S;

      const tl = gsap.timeline({ paused: true });

      // IN — the flat 2D card settles first (the 3D module measures letter
      // positions via offset* geometry, so these transforms don't skew it)
      tl.from("#wpt-kicker", { y: -24, opacity: 0, duration: 0.45, ease: "power3.out" }, 0);
      tl.from(
        "#wpt-headline span",
        { y: 44, opacity: 0, duration: 0.5, stagger: 0.025, ease: "power3.out" },
        0.03,
      );
      tl.from("#wpt-subtitle", { y: 18, opacity: 0, duration: 0.45, ease: "power3.out" }, 0.22);

      // IN — portal irises open (p = 0.5 around t ~= 1.47s)
      tl.to(S, { portal: 1, duration: 2.05, ease: "power2.inOut" }, 0.45);
      // The 3D layer owns the copy by now. Keep the original card background
      // under the portal feather, while retiring its covered DOM text.
      tl.set(["#wpt-headline", "#wpt-kicker", "#wpt-subtitle"], { autoAlpha: 0 }, 2.5);

      // warp dots: burst through the transition, settle to a slow stream in the hold
      tl.to(S, { warp: 1, duration: 1.0, ease: "sine.in" }, 0.45);
      tl.to(S, { warp: 0.28, duration: 1.2, ease: "sine.out" }, 1.5);
      tl.to(S, { warp: 0.85, duration: 0.8, ease: "power1.in" }, 6.0);
      tl.to(S, { travel: 16, duration: 2.05, ease: "sine.inOut" }, 0.45);
      tl.to(S, { travel: 22, duration: 3.5, ease: "none" }, 2.5);
      tl.to(S, { travel: 38, duration: 2.0, ease: "power2.in" }, 6.0);

      // faint glitch ticks on two beats
      tl.to(S, { glitch: 0.55, duration: 0.09, ease: "power1.in" }, 3.8);
      tl.to(S, { glitch: 0, duration: 0.24, ease: "power2.out" }, 3.89);
      tl.to(S, { glitch: 0.55, duration: 0.09, ease: "power1.in" }, 5.2);
      tl.to(S, { glitch: 0, duration: 0.24, ease: "power2.out" }, 5.29);

      // OUT — camera pushes through and past the letterforms, frame lifts to white
      tl.to(S, { push: 1, duration: 1.6, ease: "power2.in" }, 6.0);
      tl.to(S, { exitFx: 1, duration: 1.6, ease: "power2.in" }, 6.4);
      tl.to("#wpt-white", { opacity: 1, duration: 0.95, ease: "sine.inOut" }, 6.9);
      // The white plate is intentionally empty and fully opaque for the last hold.
      tl.addLabel("light-card", 0.8);
      tl.addLabel("portal-alignment", 1.47);
      tl.addLabel(
        "phrase-handoff",
        window.__wptTransition.start + window.__wptTransition.duration * 0.6,
      );
      tl.addLabel("phrase-hold", window.__wptTransition.start + window.__wptTransition.duration);
      tl.addLabel("white-empty", 7.9);

      tl.eventCallback("onUpdate", function () {
        if (window.__wptRender) window.__wptRender(tl.time());
      });
      /* Studio's player seeks with GSAP events suppressed and dispatches an
         hf-seek CustomEvent instead — re-sync and repaint so the stage follows. */
      window.addEventListener("hf-seek", function (event) {
        var d = event && event.detail;
        if (d && typeof d.time === "number")
          tl.totalTime(Math.min(d.time, tl.totalDuration()), true);
        var fn = tl.eventCallback("onUpdate");
        if (fn) fn();
      });

      window.__timelines["wireframe-portal-title"] = tl;
    </script>

    <script type="module">
      import * as THREE from "three";
      import { FontLoader } from "three/addons/loaders/FontLoader.js";
      import { TTFLoader } from "three/addons/loaders/TTFLoader.js";
      import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
      import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
      import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
      import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
      import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
      import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
      import { LineMaterial } from "three/addons/lines/LineMaterial.js";

      // ---------- seeded PRNG (replaces the experiment's Math.random) ----------
      function mulberry32(a) {
        return function () {
          a |= 0;
          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;
        };
      }
      const rand = mulberry32(20260824);
      const phraseRand = mulberry32(20260831);

      // ---------- params (preset 31 defaults from the experiment config) ----------
      const P = {
        gridLineColor: "#ffffff",
        gridSpacing: 29,
        gridSize: 500,
        gridOpacity: 0.15,
        gridFadeStart: 1,
        gridFadeEnd: 175,
        gridOffsetX: -14.5,
        gridGlowSpeed: 8.7,
        gridGlowIntensity: 1.3,
        textDepthMin: 0.28,
        textDepthMax: 2,
        letterZSpread: 4, // experiment: 9 — tightened so perspective shifts don't split the word or crop the frame
        outlineColor: "#ffffff",
        outlineWidth: 1.5,
        starburstPoints: 24,
        portalChromatic: 0,
        portalEdgeBlur: 0.15,
        portalIrregularity: window.__wptBurstChaos !== undefined ? window.__wptBurstChaos : 1,
        portalStartColor: "#e8e8e8",
        portalScale: 0.8,
        portalColorSpeed: 1.7,
        warpDotCount: 1000,
        warpDotSize: 2,
        warpDotOpacity: 0.7,
        warpDotColor: "#888888",
        transChromatic: 0.01,
        transZoomBlur: 3.2,
        transDistortion: 0.05,
        transBrightness: 1.6,
        transContrast: 1.25,
        transSaturation: 0,
        cameraDistance: 22,
        initialFOV: 100,
        cameraDistance3D: 66, // pulled back from the experiment's 48.5 so the full word composes in-frame at 1920x1080
        finalFOV: 72,
        contentZOffset: 30,
        vignetteIntensity: 1.7,
        chromaticAberration: 0.006,
        scanlineIntensity: 0.25,
        edgeBlur: 50,
        edgeBlurSpread: 0.15,
        glitchPower: 0.2,
        glitchSpeed: 3,
        glitchBlockSize: 15,
        glitchColorRate: 0.009,
        ambientIntensity: 0.5,
        keyLightIntensity: 4.25,
        keyLightX: 2,
        keyLightY: -2.5,
        keyLightZ: 4.5,
        bgColor: "#000000",
      };
      const W = 1920;
      const H = 1080;
      const ACCENT = window.__wptAccent || "#F5C518";

      // ---------- shaders (ported verbatim from the experiment) ----------
      const PortalCompositeShader = {
        uniforms: {
          tDiffuse: { value: null },
          uPortalRadius: { value: 0.0 },
          uPortalCenter: { value: new THREE.Vector2(0.5, 0.5) },
          uStarburstPoints: { value: P.starburstPoints },
          uChromaticStrength: { value: P.portalChromatic },
          uEdgeBlur: { value: P.portalEdgeBlur },
          uIrregularity: { value: P.portalIrregularity },
          uStartColor: { value: new THREE.Color(P.portalStartColor) },
          uPortalProgress: { value: 0.0 },
          uTime: { value: 0 },
          uOuterBlur: { value: 0.0 },
        },
        vertexShader: `
          varying vec2 vUv;
          void main() {
            vUv = uv;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
          }
        `,
        fragmentShader: `
          uniform sampler2D tDiffuse;
          uniform float uPortalRadius;
          uniform vec2 uPortalCenter;
          uniform int uStarburstPoints;
          uniform float uChromaticStrength;
          uniform float uEdgeBlur;
          uniform float uIrregularity;
          uniform vec3 uStartColor;
          uniform float uPortalProgress;
          uniform float uTime;
          uniform float uOuterBlur;
          varying vec2 vUv;

          float hash(float n) { return fract(sin(n) * 43758.5453); }

          void main() {
            vec2 uv = vUv;
            vec2 center = uPortalCenter;
            vec2 delta = uv - center;
            float dist = length(delta);
            float angle = atan(delta.y, delta.x);

            float points = float(uStarburstPoints);
            float spikeLength = 0.2 * uPortalRadius;
            float valleyDepth = 0.08 * uPortalRadius;
            float baseR = uPortalRadius - valleyDepth;

            float seed = floor(uTime * 0.4) * 7.13;

            // Low-frequency undulation: the base ellipse itself wobbles.
            float wob = sin(angle * 2.0 + seed * 1.7)
                      * 0.45 + sin(angle * 3.0 - seed * 2.3 + 1.9) * 0.33
                      + sin(angle * 5.0 + seed * 0.9 + 4.2) * 0.22;
            baseR *= 1.0 + wob * 0.16 * uIrregularity;

            // Warp the angle before segmenting: spikes cluster and gap
            // instead of sitting on an even sunburst lattice.
            float aw = angle
                     + (sin(angle * 3.0 + seed + 0.7) * 0.55
                      + sin(angle * 5.0 - seed * 1.3 + 2.3) * 0.35)
                       * 0.45 * uIrregularity;

            float segAngle = 6.28318 / points;
            float localAngle = mod(aw + 3.14159, segAngle);
            float segIndex = floor((aw + 3.14159) / segAngle);

            float t = localAngle / segAngle;

            // Per-spike character: off-centre tips, fat-to-needle sharpness,
            // broad length spread with occasional dominant spikes.
            float h1 = hash(segIndex * 17.3 + seed);
            float h2 = hash(segIndex * 31.7 + seed + 5.1);
            float h3 = hash(segIndex * 47.9 + seed + 9.7);
            float tip = mix(0.5, mix(0.2, 0.8, h2), min(uIrregularity, 1.0));
            float spike = t < tip ? t / max(tip, 1e-3)
                                  : (1.0 - t) / max(1.0 - tip, 1e-3);
            spike = pow(clamp(spike, 0.0, 1.0),
                        mix(1.0, mix(0.55, 3.2, h3), min(uIrregularity, 1.0)));
            float lenVar = mix(0.35, 1.15, h1) + pow(h2, 6.0) * 1.8;
            float spikeScale = mix(1.0, lenVar, min(uIrregularity, 1.5));

            float thisSpike = spike * spikeLength * spikeScale;
            float starShape = baseR + thisSpike;

            float feather = max(0.002, uEdgeBlur);
            float mask = smoothstep(starShape + feather, starShape - feather, dist);

            if (mask < 0.001) {
              gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);
              return;
            }

            float edgeDist = abs(dist - starShape);
            float edgeFactor = smoothstep(feather * 3.0, 0.0, edgeDist) * uChromaticStrength;
            vec2 chrDir = normalize(delta + 0.001) * edgeFactor;

            float r = texture2D(tDiffuse, uv + chrDir).r;
            float g = texture2D(tDiffuse, uv).g;
            float b = texture2D(tDiffuse, uv - chrDir).b;
            vec3 sceneColor = vec3(r, g, b);

            gl_FragColor = vec4(sceneColor, mask);
          }
        `,
      };

      const TransitionFXShader = {
        uniforms: {
          tDiffuse: { value: null },
          uTransAmount: { value: 0.0 },
          uChromatic: { value: P.transChromatic },
          uZoomBlur: { value: P.transZoomBlur },
          uDistortion: { value: P.transDistortion },
          uBrightness: { value: P.transBrightness },
          uContrast: { value: P.transContrast },
          uSaturation: { value: P.transSaturation },
        },
        vertexShader: `
          varying vec2 vUv;
          void main() {
            vUv = uv;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
          }
        `,
        fragmentShader: `
          uniform sampler2D tDiffuse;
          uniform float uTransAmount;
          uniform float uChromatic;
          uniform float uZoomBlur;
          uniform float uDistortion;
          uniform float uBrightness;
          uniform float uContrast;
          uniform float uSaturation;
          varying vec2 vUv;

          void main() {
            if (uTransAmount < 0.001) {
              gl_FragColor = texture2D(tDiffuse, vUv);
              return;
            }

            vec2 uv = vUv;
            vec2 center = uv - 0.5;
            float dist = length(center);

            vec2 distortedUV = uv + center * dist * dist * uDistortion * uTransAmount;
            distortedUV = clamp(distortedUV, 0.0, 1.0);

            vec3 color = vec3(0.0);
            float totalWeight = 0.0;
            float blurAmount = uZoomBlur * uTransAmount * 0.1;
            for (int i = 0; i < 10; i++) {
              float t = float(i) / 9.0;
              float weight = 1.0 - t * 0.6;
              vec2 sampleUV = clamp(distortedUV + center * blurAmount * t, 0.0, 1.0);
              color += texture2D(tDiffuse, sampleUV).rgb * weight;
              totalWeight += weight;
            }
            color /= totalWeight;

            float caAmount = uChromatic * uTransAmount;
            vec2 caDir = normalize(center + 0.001) * caAmount;
            color.r = mix(color.r, texture2D(tDiffuse, clamp(distortedUV + caDir, 0.0, 1.0)).r, uTransAmount);
            color.b = mix(color.b, texture2D(tDiffuse, clamp(distortedUV - caDir, 0.0, 1.0)).b, uTransAmount);

            float brightness = mix(1.0, uBrightness, uTransAmount);
            color *= brightness;

            float contrast = mix(1.0, uContrast, uTransAmount);
            color = (color - 0.5) * contrast + 0.5;

            float saturation = mix(1.0, uSaturation, uTransAmount);
            float lum = dot(color, vec3(0.299, 0.587, 0.114));
            color = mix(vec3(lum), color, saturation);

            gl_FragColor = vec4(clamp(color, 0.0, 1.0), texture2D(tDiffuse, uv).a);
          }
        `,
      };

      const GlitchShader = {
        uniforms: {
          tDiffuse: { value: null },
          uTime: { value: 0 },
          uShakePower: { value: 0.0 },
          uShakeRate: { value: 0.0 },
          uShakeSpeed: { value: P.glitchSpeed },
          uShakeBlockSize: { value: P.glitchBlockSize },
          uShakeColorRate: { value: 0.0 },
        },
        vertexShader: `
          varying vec2 vUv;
          void main() {
            vUv = uv;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
          }
        `,
        fragmentShader: `
          uniform sampler2D tDiffuse;
          uniform float uTime;
          uniform float uShakePower;
          uniform float uShakeRate;
          uniform float uShakeSpeed;
          uniform float uShakeBlockSize;
          uniform float uShakeColorRate;
          varying vec2 vUv;

          float random(float seed) {
            return fract(543.2543 * sin(dot(vec2(seed, seed), vec2(3525.46, -54.3415))));
          }

          void main() {
            float enable_shift = float(
              random(trunc(uTime * uShakeSpeed)) < uShakeRate
            );

            vec2 fixed_uv = vUv;
            fixed_uv.x += (
              random(
                (trunc(vUv.y * uShakeBlockSize) / uShakeBlockSize) + uTime
              ) - 0.5
            ) * uShakePower * enable_shift;

            vec4 pixel_color = texture2D(tDiffuse, fixed_uv);
            pixel_color.r = mix(
              pixel_color.r,
              texture2D(tDiffuse, fixed_uv + vec2(uShakeColorRate, 0.0)).r,
              enable_shift
            );
            pixel_color.b = mix(
              pixel_color.b,
              texture2D(tDiffuse, fixed_uv + vec2(-uShakeColorRate, 0.0)).b,
              enable_shift
            );

            gl_FragColor = pixel_color;
          }
        `,
      };

      const EdgeBlurShader = {
        uniforms: {
          tDiffuse: { value: null },
          uDirection: { value: new THREE.Vector2(1, 0) },
          uBlurAmount: { value: 0.0 },
          uSpread: { value: P.edgeBlurSpread },
          uResolution: { value: new THREE.Vector2(W, H) },
        },
        vertexShader: `
          varying vec2 vUv;
          void main() {
            vUv = uv;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
          }
        `,
        fragmentShader: `
          uniform sampler2D tDiffuse;
          uniform vec2 uDirection;
          uniform float uBlurAmount;
          uniform float uSpread;
          uniform vec2 uResolution;
          varying vec2 vUv;

          void main() {
            vec2 center = vUv - 0.5;
            float dist = length(center);
            float blurStart = (1.0 - uSpread) * 0.5;
            float edgeFactor = smoothstep(blurStart, blurStart + 0.3, dist);
            float radius = edgeFactor * uBlurAmount;

            if (radius < 0.5) {
              gl_FragColor = texture2D(tDiffuse, vUv);
              return;
            }

            vec2 texelSize = 1.0 / uResolution;
            vec2 step = uDirection * texelSize * radius;

            vec4 result = vec4(0.0);
            result += texture2D(tDiffuse, vUv - step * 6.0) * 0.002;
            result += texture2D(tDiffuse, vUv - step * 5.0) * 0.009;
            result += texture2D(tDiffuse, vUv - step * 4.0) * 0.028;
            result += texture2D(tDiffuse, vUv - step * 3.0) * 0.066;
            result += texture2D(tDiffuse, vUv - step * 2.0) * 0.121;
            result += texture2D(tDiffuse, vUv - step * 1.0) * 0.175;
            result += texture2D(tDiffuse, vUv)               * 0.198;
            result += texture2D(tDiffuse, vUv + step * 1.0) * 0.175;
            result += texture2D(tDiffuse, vUv + step * 2.0) * 0.121;
            result += texture2D(tDiffuse, vUv + step * 3.0) * 0.066;
            result += texture2D(tDiffuse, vUv + step * 4.0) * 0.028;
            result += texture2D(tDiffuse, vUv + step * 5.0) * 0.009;
            result += texture2D(tDiffuse, vUv + step * 6.0) * 0.002;

            gl_FragColor = result;
          }
        `,
      };

      const PostFXShader = {
        uniforms: {
          tDiffuse: { value: null },
          uVignetteIntensity: { value: 0.0 },
          uChromaticAberration: { value: 0.0 },
          uScanlineIntensity: { value: 0.0 },
          uTime: { value: 0.0 },
        },
        vertexShader: `
          varying vec2 vUv;
          void main() {
            vUv = uv;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
          }
        `,
        fragmentShader: `
          uniform sampler2D tDiffuse;
          uniform float uVignetteIntensity;
          uniform float uChromaticAberration;
          uniform float uScanlineIntensity;
          uniform float uTime;
          varying vec2 vUv;

          void main() {
            vec2 uv = vUv;
            vec2 center = uv - 0.5;
            float dist = length(center);
            float dist2 = dot(center, center);

            float edgeFactor = smoothstep(0.2, 0.6, dist);
            float caStrength = uChromaticAberration * edgeFactor;
            vec2 caDir = normalize(center + 0.001) * caStrength;
            vec3 color;
            color.r = texture2D(tDiffuse, clamp(uv + caDir, 0.0, 1.0)).r;
            color.g = texture2D(tDiffuse, uv).g;
            color.b = texture2D(tDiffuse, clamp(uv - caDir, 0.0, 1.0)).b;

            float scanline = sin(uv.y * 800.0 + uTime * 2.0) * 0.5 + 0.5;
            scanline = pow(scanline, 8.0) * uScanlineIntensity;
            color -= scanline * 0.08;
            float noiseLine = fract(sin(floor(uv.y * 400.0 + uTime * 5.0) * 43758.5453)) * uScanlineIntensity * 0.03;
            color += noiseLine;

            float vignette = 1.0 - dist2 * uVignetteIntensity;
            vignette = smoothstep(0.0, 0.5, clamp(vignette, 0.0, 1.0));
            color *= vignette;

            gl_FragColor = vec4(color, texture2D(tDiffuse, uv).a);
          }
        `,
      };

      const GridLineShader = {
        uniforms: {
          uColor: { value: new THREE.Color(P.gridLineColor) },
          uOpacity: { value: P.gridOpacity },
          uFadeStart: { value: P.gridFadeStart },
          uFadeEnd: { value: P.gridFadeEnd },
          uCameraPos: { value: new THREE.Vector3() },
          uTime: { value: 0.0 },
          uGlowSpeed: { value: P.gridGlowSpeed },
          uGlowIntensity: { value: P.gridGlowIntensity },
        },
        vertexShader: `
          varying vec3 vWorldPos;
          void main() {
            vec4 worldPos = modelMatrix * vec4(position, 1.0);
            vWorldPos = worldPos.xyz;
            gl_Position = projectionMatrix * viewMatrix * worldPos;
          }
        `,
        fragmentShader: `
          uniform vec3 uColor;
          uniform float uOpacity;
          uniform float uFadeStart;
          uniform float uFadeEnd;
          uniform vec3 uCameraPos;
          uniform float uTime;
          uniform float uGlowSpeed;
          uniform float uGlowIntensity;
          varying vec3 vWorldPos;

          float hash(float n) { return fract(sin(n) * 43758.5453); }

          void main() {
            float dist = distance(vWorldPos, uCameraPos);
            float fade = 1.0 - smoothstep(uFadeStart, uFadeEnd, dist);
            if (fade < 0.01) discard;

            float dotGlow = 0.0;
            float dotRadius = 0.4;

            for (int d = 0; d < 5; d++) {
              float di = float(d);
              float seedX = hash(floor(vWorldPos.y * 10.0) * 137.0 + floor(vWorldPos.z * 10.0) * 311.0 + di * 73.0);
              float dotPosX = mod(uTime * uGlowSpeed * (0.5 + seedX * 2.0) + seedX * 200.0, 300.0) - 150.0;
              dotGlow += exp(-pow((vWorldPos.x - dotPosX) / dotRadius, 2.0)) * 0.5;

              float seedY = hash(floor(vWorldPos.x * 10.0) * 173.0 + floor(vWorldPos.z * 10.0) * 271.0 + di * 91.0);
              float dotPosY = mod(uTime * uGlowSpeed * (0.5 + seedY * 2.0) + seedY * 200.0, 300.0) - 150.0;
              dotGlow += exp(-pow((vWorldPos.y - dotPosY) / dotRadius, 2.0)) * 0.5;

              float seedZ = hash(floor(vWorldPos.x * 10.0) * 251.0 + floor(vWorldPos.y * 10.0) * 197.0 + di * 59.0);
              float dotPosZ = mod(uTime * uGlowSpeed * (0.5 + seedZ * 2.0) + seedZ * 200.0, 300.0) - 150.0;
              dotGlow += exp(-pow((vWorldPos.z - dotPosZ) / dotRadius, 2.0)) * 0.5;
            }

            dotGlow *= uGlowIntensity;

            float finalAlpha = (uOpacity + dotGlow) * fade;
            vec3 finalColor = uColor * (1.0 + dotGlow * 3.0);

            gl_FragColor = vec4(finalColor, min(finalAlpha, 1.0));
          }
        `,
      };

      // ---------- font: local Geist Bold TTF -> three Font (blocks the load event) ----------
      // Beside this file when mounted; a runtime without assetUrl keeps the plain path.
      const assetUrl = (path) =>
        typeof __hyperframes !== "undefined" && __hyperframes.assetUrl
          ? __hyperframes.assetUrl(path)
          : path;
      const ttfBuffer = await fetch(assetUrl("assets/fonts/Geist-Bold.ttf")).then((r) =>
        r.arrayBuffer(),
      );
      const font = new FontLoader().parse(new TTFLoader().parse(ttfBuffer));
      try {
        await document.fonts.ready; // DOM letter rects must be measured with the real font
      } catch (e) {}

      // Shrink the DOM headline if a long title overflows, BEFORE measuring spans
      for (const id of ["wpt-headline"]) {
        const el = document.getElementById(id);
        if (el.scrollWidth > 1760) {
          el.style.fontSize = Math.floor(176 * (1760 / el.scrollWidth)) + "px";
        }
      }

      // ---------- renderer / scenes / camera ----------
      const canvas = document.getElementById("wpt-three");
      const renderer = new THREE.WebGLRenderer({
        canvas,
        antialias: true,
        alpha: true,
        stencil: true,
        preserveDrawingBuffer: true, // audit pixel-hashing + deterministic capture read the buffer back
      });
      renderer.setSize(W, H, false);
      renderer.setPixelRatio(1);
      renderer.setClearColor(0x000000, 0);
      renderer.toneMapping = THREE.NoToneMapping;

      const scene = new THREE.Scene();
      const gridScene = new THREE.Scene();
      const camera = new THREE.PerspectiveCamera(P.initialFOV, W / H, 0.1, 500);
      camera.position.set(0, 0, P.cameraDistance);
      camera.lookAt(0, 0, 0);

      // Lighting (for the floating extruded shapes)
      scene.add(new THREE.AmbientLight(0xffffff, P.ambientIntensity));
      const dirLight = new THREE.DirectionalLight(0xffffff, P.keyLightIntensity);
      dirLight.position.set(P.keyLightX, P.keyLightY, P.keyLightZ);
      scene.add(dirLight);

      // ---------- background plane + volumetric glow-dot grid ----------
      const bgPlaneMat = new THREE.MeshBasicMaterial({
        color: P.portalStartColor,
        side: THREE.DoubleSide,
      });
      const bgPlane = new THREE.Mesh(new THREE.PlaneGeometry(500, 500), bgPlaneMat);
      bgPlane.position.z = -100;
      bgPlane.renderOrder = -10;
      gridScene.add(bgPlane);

      const gridPositions = [];
      {
        const count = Math.floor(P.gridSize / P.gridSpacing);
        const he = P.gridSize;
        for (let a = -count; a <= count; a++) {
          for (let b = -count; b <= count; b++) {
            const ca = a * P.gridSpacing;
            const cb = b * P.gridSpacing;
            gridPositions.push(-he, ca, cb, he, ca, cb);
            gridPositions.push(ca, -he, cb, ca, he, cb);
            gridPositions.push(ca, cb, -he, ca, cb, he);
          }
        }
      }
      const gridGeo = new THREE.BufferGeometry();
      gridGeo.setAttribute("position", new THREE.Float32BufferAttribute(gridPositions, 3));
      const gridMat = new THREE.ShaderMaterial({
        uniforms: THREE.UniformsUtils.clone(GridLineShader.uniforms),
        vertexShader: GridLineShader.vertexShader,
        fragmentShader: GridLineShader.fragmentShader,
        transparent: true,
        depthWrite: false,
      });
      const gridLines = new THREE.LineSegments(gridGeo, gridMat);
      gridLines.position.x = P.gridOffsetX;
      gridLines.visible = false;
      gridScene.add(gridLines);

      // ---------- floating extruded triangles (experiment's decorative shapes) ----------
      function triangleShape(size) {
        const shape = new THREE.Shape();
        const h = (size * Math.sqrt(3)) / 2;
        shape.moveTo(0, h * 0.66);
        shape.lineTo(-size / 2, -h * 0.33);
        shape.lineTo(size / 2, -h * 0.33);
        shape.closePath();
        return shape;
      }
      const floatingShapes = [];
      const shapeConfigs = [
        { size: 1.8, basePos: [-8, 5, 2], color: ACCENT },
        { size: 1.4, basePos: [7, -3, 1], color: ACCENT },
        { size: 1.0, basePos: [-6, -6, 3], color: "#1a1a1a" },
        { size: 1.2, basePos: [9, 6, 2], color: ACCENT },
      ];
      for (const cfg of shapeConfigs) {
        const geo = new THREE.ExtrudeGeometry(triangleShape(cfg.size), {
          depth: 0.3,
          bevelEnabled: true,
          bevelThickness: 0.02,
          bevelSize: 0.02,
          bevelSegments: 2,
        });
        geo.center();
        const mesh = new THREE.Mesh(
          geo,
          new THREE.MeshStandardMaterial({ color: cfg.color, roughness: 0.4, metalness: 0.1 }),
        );
        mesh.position.set(cfg.basePos[0], cfg.basePos[1], cfg.basePos[2]);
        mesh.userData.basePos = new THREE.Vector3(...cfg.basePos);
        scene.add(mesh);
        floatingShapes.push(mesh);
      }

      // ---------- 3D page content: extruded letters + flat outlined lines ----------
      const contentGroup = new THREE.Group();
      contentGroup.visible = false;
      scene.add(contentGroup);
      const textMeshes = [];

      // Screen px -> world units at the 2D camera (FOV 100, z 22)
      const fovRad = (P.initialFOV * Math.PI) / 180;
      const visH = 2 * P.cameraDistance * Math.tan(fovRad / 2);
      const visW = visH * (W / H);
      function rectToWorld(r) {
        return {
          x: ((r.left + r.width / 2) / W - 0.5) * visW,
          y: (0.5 - (r.top + r.height / 2) / H) * visH,
          w: (r.width / W) * visW,
          h: (r.height / H) * visH,
        };
      }
      // Transform-immune element rect (GSAP intro tweens offset elements at t=0)
      function offsetRect(el) {
        let left = el.offsetLeft;
        let top = el.offsetTop;
        let node = el.offsetParent;
        while (node && node.id !== "wpt-card" && node !== document.body) {
          left += node.offsetLeft;
          top += node.offsetTop;
          node = node.offsetParent;
        }
        return { left, top, width: el.offsetWidth, height: el.offsetHeight };
      }
      const lineResolution = new THREE.Vector2(W, H);

      // Geist glyphs contain self-overlapping junction notches (fine for nonzero-winding
      // browser fills, fatal for extrusion triangulation + EdgesGeometry). Resolve the
      // overlaps with a nonzero polygon union before extruding.
      function cleanShapes(shapes) {
        const SC = 1000;
        const cleaned = [];
        for (const shape of shapes) {
          const pts = shape.extractPoints(12);
          const paths = [pts.shape, ...pts.holes].map((ring) =>
            ring.map((p) => ({ X: Math.round(p.x * SC), Y: Math.round(p.y * SC) })),
          );
          let simp;
          try {
            simp = ClipperLib.Clipper.SimplifyPolygons(paths, ClipperLib.PolyFillType.pftNonZero);
          } catch (e) {
            cleaned.push(shape);
            continue;
          }
          const outerRings = [];
          const holeRings = [];
          for (const ring of simp) {
            if (ring.length < 3) continue;
            (ClipperLib.Clipper.Orientation(ring) ? outerRings : holeRings).push(ring);
          }
          const outShapes = outerRings.map(
            (r) => new THREE.Shape(r.map((p) => new THREE.Vector2(p.X / SC, p.Y / SC))),
          );
          for (const hr of holeRings) {
            let owner = outShapes[0];
            for (let oi = 0; oi < outerRings.length; oi++) {
              if (ClipperLib.Clipper.PointInPolygon(hr[0], outerRings[oi]) !== 0) {
                owner = outShapes[oi];
                break;
              }
            }
            if (owner)
              owner.holes.push(
                new THREE.Path(hr.map((p) => new THREE.Vector2(p.X / SC, p.Y / SC))),
              );
          }
          cleaned.push(...outShapes);
        }
        return cleaned;
      }

      function cleanTextGeometry(text, size, depth) {
        const shapes = cleanShapes(font.generateShapes(text, size));
        return new THREE.ExtrudeGeometry(shapes, { depth, bevelEnabled: false });
      }

      function createLetterAt(char, wr, random = rand) {
        const g = new THREE.Group();
        // A shared geometry size lets a reused glyph scale continuously between
        // source and target layouts, even when a long phrase needs smaller type.
        const size = 10;
        const geo = cleanTextGeometry(char, size, P.textDepthMax);
        geo.computeBoundingBox();
        const bb = geo.boundingBox;
        const geoW = bb.max.x - bb.min.x;
        const geoH = bb.max.y - bb.min.y;
        geo.center();
        const xyScale = Math.min(wr.w / (geoW || 1), wr.h / (geoH || 1));

        g.add(
          new THREE.Mesh(
            geo,
            new THREE.MeshBasicMaterial({
              color: "#050505",
              side: THREE.DoubleSide,
              transparent: true,
            }),
          ),
        );

        const edges = new THREE.EdgesGeometry(geo, 20);
        const lineGeo = new LineSegmentsGeometry();
        lineGeo.setPositions(edges.attributes.position.array);
        const lineMat = new LineMaterial({
          color: new THREE.Color(P.outlineColor).getHex(),
          linewidth: P.outlineWidth,
          transparent: true,
          resolution: lineResolution,
        });
        g.add(new LineSegments2(lineGeo, lineMat));
        edges.dispose();

        g.scale.set(xyScale, xyScale, 0.01);
        g.position.set(wr.x, wr.y, 0);
        g.userData.char = char;
        g.userData.baseX = wr.x;
        g.userData.baseY = wr.y;
        g.userData.xyScale = xyScale;
        g.userData.targetZ = (random() - 0.5) * P.letterZSpread;
        const ratio = P.textDepthMin / P.textDepthMax;
        g.userData.depthScale = ratio + random() * (1 - ratio);
        g.userData.isLetter = true;
        contentGroup.add(g);
        textMeshes.push(g);
        return g;
      }

      function createFlatLine(text, wr, widthMult, opacity) {
        const geo = cleanTextGeometry(text, 1, 0.02);
        geo.computeBoundingBox();
        const bb = geo.boundingBox;
        const geoW = bb.max.x - bb.min.x;
        const geoH = bb.max.y - bb.min.y;
        geo.center();
        const s = Math.min(wr.h / (geoH || 1), wr.w / (geoW || 1));

        const g = new THREE.Group();
        g.add(
          new THREE.Mesh(
            geo,
            new THREE.MeshBasicMaterial({
              color: "#050505",
              side: THREE.DoubleSide,
              transparent: true,
            }),
          ),
        );
        const edges = new THREE.EdgesGeometry(geo, 40);
        const lineGeo = new LineSegmentsGeometry();
        lineGeo.setPositions(edges.attributes.position.array);
        const lineMat = new LineMaterial({
          color: new THREE.Color(P.outlineColor).getHex(),
          linewidth: P.outlineWidth * widthMult,
          transparent: true,
          opacity: opacity,
          resolution: lineResolution,
        });
        g.add(new LineSegments2(lineGeo, lineMat));
        edges.dispose();

        g.scale.set(s, s, 1);
        g.position.set(wr.x, wr.y, 0);
        g.userData.baseY = wr.y;
        g.userData.isSupporting = true;
        g.userData.faceColor = g.children[0].material.color.clone();
        contentGroup.add(g);
        textMeshes.push(g);
      }

      // Headline letters from the DOM spans (2D card and 3D letters stay aligned)
      document.querySelectorAll("#wpt-headline [data-3d-char]").forEach((span) => {
        const ch = span.textContent;
        if (!ch || ch.trim() === "") return;
        const r = offsetRect(span);
        if (r.width < 1 || r.height < 1) return;
        createLetterAt(ch, rectToWorld(r));
      });
      const sourceLetters = textMeshes.slice();
      // Kicker + subtitle as flat outlined lines
      const kickEl = document.getElementById("wpt-kicker-text");
      if (kickEl && kickEl.textContent.trim()) {
        createFlatLine(kickEl.textContent, rectToWorld(offsetRect(kickEl)), 0.7, 0.6);
      }
      const subEl = document.getElementById("wpt-subtitle");
      if (subEl && subEl.textContent.trim()) {
        createFlatLine(subEl.textContent, rectToWorld(offsetRect(subEl)), 0.7, 0.6);
      }

      const supportingLines = textMeshes.filter((obj) => obj.userData.isSupporting);
      const replacementLetters = [];
      const transition = window.__wptTransition;
      const clamp01 = (v) => Math.max(0, Math.min(1, v));
      const smooth = (v) => {
        const x = clamp01(v);
        return x * x * (3 - 2 * x);
      };

      // Typeset from font metrics, independent of any final DOM card. Search
      // whole-word wraps, maximizing readable type size in a safe 1520 x 620 box.
      // A long unbroken word shrinks intact; no words or glyphs are discarded.
      function layoutPhrase(text) {
        const measure = document.createElement("canvas").getContext("2d");
        measure.font = "700 176px Geist";
        const tracking = 176 * 0.03;
        const advance = (char) => measure.measureText(char).width + tracking;
        const width = (line) =>
          Array.from(line).reduce((sum, char) => sum + advance(char), -tracking);
        const words = text.split(" ");
        const widest = Math.max(...words.map(width));
        const total = width(text);
        let best = null;
        // Trying each possible break-width gives several balanced candidates
        // without restricting the result to two words or a fixed line count.
        const candidates = new Set([total, widest]);
        for (let first = 0; first < words.length; first++) {
          let line = "";
          for (let last = first; last < words.length; last++) {
            line += (line ? " " : "") + words[last];
            candidates.add(width(line));
          }
        }
        for (const limit of candidates) {
          if (limit < widest) continue;
          const lines = [];
          let line = "";
          for (const word of words) {
            const next = line ? line + " " + word : word;
            if (line && width(next) > limit + 0.01) {
              lines.push(line);
              line = word;
            } else line = next;
          }
          if (line) lines.push(line);
          const widths = lines.map(width);
          const scale = Math.min(1, 1520 / Math.max(...widths), 620 / (lines.length * 176 * 1.18));
          const balance = Math.min(...widths) / Math.max(...widths);
          const score = scale * (0.94 + 0.06 * balance);
          if (!best || score > best.score) best = { lines, widths, scale, score };
        }
        const glyphs = [];
        const lineHeight = 176 * 1.18 * best.scale;
        best.lines.forEach((line, row) => {
          let left = (W - best.widths[row] * best.scale) / 2;
          for (const char of Array.from(line)) {
            const step = advance(char) * best.scale;
            if (char.trim())
              glyphs.push({
                char,
                row,
                rect: {
                  left,
                  top: H / 2 + (row - (best.lines.length - 1) / 2) * lineHeight - 88 * best.scale,
                  width: (advance(char) - tracking) * best.scale,
                  height: 176 * best.scale,
                },
              });
            left += step;
          }
        });
        return { ...best, glyphs };
      }
      const phraseLayout = layoutPhrase(window.__wptPhrase);
      for (const { char, rect, row } of phraseLayout.glyphs) {
        const letter = createLetterAt(char, rectToWorld(rect), phraseRand);
        // Keep spacing consistent at the hold camera while retaining real depth.
        const distance = P.cameraDistance3D - P.contentZOffset;
        const perspective = (distance - letter.userData.targetZ) / distance;
        letter.userData.baseX *= perspective;
        letter.userData.baseY *= perspective;
        letter.userData.xyScale *= perspective;
        letter.userData.row = row;
        replacementLetters.push(letter);
      }

      // Reuse matching letter identities first, then pair remaining glyphs by
      // spatial proximity. Extra target letters have their own distant Z origin;
      // unused source letters depart early instead of lingering until a swap.
      const slots = replacementLetters.map((to) => ({ from: null, to }));
      const remaining = new Set(sourceLetters);
      const distance = (a, b) => Math.hypot(a.baseX - b.baseX, a.baseY - b.baseY);
      for (const exactOnly of [true, false]) {
        for (const slot of slots) {
          if (slot.from) continue;
          let closest = null,
            cost = Infinity;
          for (const letter of remaining) {
            if (exactOnly && letter.userData.char !== slot.to.userData.char) continue;
            const candidateCost = distance(letter.userData, slot.to.userData);
            if (candidateCost < cost) {
              closest = letter;
              cost = candidateCost;
            }
          }
          if (closest) {
            slot.from = closest;
            remaining.delete(closest);
          }
        }
      }
      for (const from of remaining) slots.push({ from, to: null });
      slots.forEach((slot, i) => {
        const target = slot.to || slot.from;
        slot.start = slot.from
          ? slot.from.userData
          : {
              baseX: target.userData.baseX * 1.25,
              baseY: target.userData.baseY * 1.25,
              targetZ: -60 - phraseRand() * 65,
              xyScale: target.userData.xyScale,
            };
        slot.end = (slot.to || slot.from).userData;
        const rank = i / Math.max(1, replacementLetters.length - 1);
        slot.delay = clamp01(rank) * transition.duration * 0.26;
        // Keep the same object alive when the character itself does not change.
        slot.sameGlyph = !!(
          slot.from &&
          slot.to &&
          slot.from.userData.char === slot.to.userData.char
        );
        if (slot.sameGlyph) slot.to.visible = false;
      });

      // The adjustable power concentrates speed around the edge-on letter swap
      // at 62% of local time: slow preparation, fast handoff, then a soft settle.
      function phraseEase(progress) {
        const x = clamp01(progress);
        const a = Math.pow(x * 0.38, transition.easing);
        const b = Math.pow((1 - x) * 0.62, transition.easing);
        return a / (a + b);
      }
      function opacityFor(obj, opacity) {
        for (const child of obj.children) {
          child.material.opacity = opacity;
          child.material.depthWrite = opacity > 0.995;
        }
      }
      function depthFade(obj, amount, baseOpacity = 1) {
        const opacity = 1 - smooth((amount - 0.005) / 0.13);
        opacityFor(obj, opacity);
        const haze = transition.fog ? 1 - smooth(amount / 0.15) : 1;
        obj.children[1].material.color.set(P.outlineColor).lerp(endCol, 1 - haze);
        obj.children[1].material.opacity = baseOpacity * opacity;
        obj.visible = opacity > 0.001;
        return opacity;
      }

      function handoffLetters(t, portal) {
        let glitchEnvelope = 0;
        for (let i = 0; i < slots.length; i++) {
          const slot = slots[i];
          const progress = clamp01(
            (t - transition.start - slot.delay) / (transition.duration * 0.74),
          );
          const motion = phraseEase(progress);
          const incoming = motion >= 0.5;
          const start = slot.start,
            end = slot.end;
          // Spare source letters fog out during the first fraction of recession,
          // before the active letters turn. This path also runs with fog disabled.
          if (!slot.to) {
            const depart = smooth(
              (t - transition.start - (i % 3) * 0.025) / Math.min(0.75, transition.duration * 0.45),
            );
            const obj = slot.from;
            obj.position.set(start.baseX, start.baseY, start.targetZ * portal - depart * 125);
            obj.rotation.set(0, depart * 0.3, 0);
            obj.scale.set(
              start.xyScale,
              start.xyScale,
              THREE.MathUtils.lerp(0.01, start.depthScale, portal),
            );
            depthFade(obj, depart);
            continue;
          }
          const x = THREE.MathUtils.lerp(start.baseX, end.baseX, motion);
          const y =
            THREE.MathUtils.lerp(start.baseY, end.baseY, motion) +
            Math.sin(t * 0.6 + i * 0.7) * 0.12 * portal;
          // Keep the approved forward rush: the original camera pushes into
          // the wireframe world as the resolved letters accelerate toward it.
          // A small reading-order stagger carries each letter past the lens;
          // there is no backward Z travel or opacity dissolve on this exit.
          const exitDelay = (i / Math.max(1, replacementLetters.length - 1)) * 0.2;
          const flyPast = Math.pow(clamp01((t - 6.0 - exitDelay) / 1.25), 3);
          const z =
            THREE.MathUtils.lerp(start.targetZ, end.targetZ, motion) * portal -
            Math.sin(Math.PI * motion) * 1.8 +
            flyPast * 38;
          // Retire a glyph only after its full extrusion is behind the camera.
          // Before then, clipping and perspective carry the fly-through.
          const cameraSpace = new THREE.Vector3(x, y, z + P.contentZOffset * portal).applyMatrix4(
            camera.matrixWorldInverse,
          );
          const passedCamera = cameraSpace.z > P.textDepthMax * 2;
          const edge = Math.pow(Math.abs(2 * motion - 1), 0.65);
          const swapPulse = Math.exp(-Math.pow((progress - 0.62) / 0.045, 2));
          const settlePulse = Math.exp(-Math.pow((progress - 0.86) / 0.065, 2));
          glitchEnvelope = Math.max(glitchEnvelope, swapPulse * 0.65, settlePulse);
          const tick =
            Math.sin(Math.floor(t * 36) * 17.1 + i * 4.7) * settlePulse * transition.glitch;
          for (const [obj, isIncoming] of [
            [slot.from, false],
            [slot.to, true],
          ]) {
            if (!obj) continue;
            const isActive = slot.sameGlyph ? !isIncoming : incoming === isIncoming;
            // New glyphs fly up from deep Z throughout the move. They are not
            // duplicates split out of a source letter, and never disappear at the seam.
            const extra = !slot.from;
            const entrance = extra ? smooth(motion / 0.34) : 1;
            const opacity = entrance;
            obj.visible = (extra || isActive) && opacity > 0.001 && !passedCamera;
            opacityFor(obj, opacity);
            obj.position.set(x + tick * 0.12, y + tick * 0.04, z);
            obj.rotation.set(
              0,
              slot.sameGlyph
                ? Math.sin(Math.PI * motion) * 0.55
                : extra
                  ? -Math.PI * 0.75 * (1 - motion)
                  : incoming
                    ? -Math.PI * (1 - motion)
                    : Math.PI * motion,
              tick * 0.012,
            );
            const xyScale = THREE.MathUtils.lerp(start.xyScale, end.xyScale, motion);
            obj.scale.set(
              xyScale,
              xyScale,
              THREE.MathUtils.lerp(0.01, end.depthScale, portal) *
                (extra || slot.sameGlyph ? 1 : 0.015 + 0.985 * edge),
            );
          }
        }
        return glitchEnvelope;
      }

      function recedeSupportingLines(t) {
        supportingLines.forEach((obj, i) => {
          const travel = smooth((t - 2.65 - i * 0.09) / 1.3);
          obj.position.z = -145 * travel;
          depthFade(obj, travel, 0.6);
        });
      }

      // ---------- post-processing chain (experiment pass order + glitch pass) ----------
      const renderTarget = new THREE.WebGLRenderTarget(W, H, {
        format: THREE.RGBAFormat,
        type: THREE.HalfFloatType,
        depthBuffer: true,
        samples: 4,
      });
      const composer = new EffectComposer(renderer, renderTarget);
      composer.setSize(W, H);

      const gridRenderPass = new RenderPass(gridScene, camera);
      gridRenderPass.clearAlpha = 0;
      composer.addPass(gridRenderPass);

      const contentRenderPass = new RenderPass(scene, camera);
      contentRenderPass.clear = false;
      composer.addPass(contentRenderPass);

      const blurPassH = new ShaderPass(EdgeBlurShader);
      blurPassH.uniforms.uDirection.value.set(1, 0);
      composer.addPass(blurPassH);
      const blurPassV = new ShaderPass(EdgeBlurShader);
      blurPassV.uniforms.uDirection.value.set(0, 1);
      composer.addPass(blurPassV);

      const postfxPass = new ShaderPass(PostFXShader);
      composer.addPass(postfxPass);

      const glitchPass = new ShaderPass(GlitchShader);
      composer.addPass(glitchPass);

      const transFXPass = new ShaderPass(TransitionFXShader);
      composer.addPass(transFXPass);

      const portalPass = new ShaderPass(PortalCompositeShader);
      portalPass.material.transparent = true;
      composer.addPass(portalPass);

      composer.addPass(new OutputPass());

      // ---------- warp dots (2D overlay canvas, pure function of state) ----------
      const warpCanvas = document.getElementById("wpt-warp");
      // Keep warp-dot rasterization identical on fresh and repeated seeks.
      const wctx = warpCanvas.getContext("2d", { willReadFrequently: true });
      const warpDots = [];
      {
        const count = P.warpDotCount;
        const depthLayers = Math.ceil(Math.cbrt(count) * 2);
        const sideCount = Math.ceil(Math.sqrt(count / depthLayers));
        outer: for (let iz = 0; iz < depthLayers; iz++) {
          for (let iy = 0; iy < sideCount; iy++) {
            for (let ix = 0; ix < sideCount; ix++) {
              if (warpDots.length >= count) break outer;
              warpDots.push({
                x: (ix - (sideCount - 1) / 2) * 1.0,
                y: (iy - (sideCount - 1) / 2) * 1.0,
                z: iz * 1.0 + 0.5,
              });
            }
          }
        }
      }
      const WARP_MAX_Z = Math.max(10, Math.ceil(Math.cbrt(P.warpDotCount) * 2));

      function drawWarpDots(S) {
        wctx.clearRect(0, 0, W, H);
        const vis = S.warp;
        if (vis < 0.01) return;
        const cx = W / 2;
        const cy = H / 2;
        for (let i = 0; i < warpDots.length; i++) {
          const d = warpDots[i];
          let z = (((d.z - S.travel) % WARP_MAX_Z) + WARP_MAX_Z) % WARP_MAX_Z;
          if (z < 0.05) z += WARP_MAX_Z;

          const scale = 300 / z;
          const sx = cx + d.x * scale;
          const sy = cy + d.y * scale;
          const size = (P.warpDotSize / z) * 3;
          if (size < 0.3) continue;
          if (sx < -10 || sx > W + 10 || sy < -10 || sy > H + 10) continue;

          const depthAlpha = Math.min(1, 2 / z);
          const alpha = depthAlpha * vis * P.warpDotOpacity;

          wctx.globalAlpha = alpha;
          wctx.fillStyle = P.warpDotColor;
          wctx.beginPath();
          wctx.arc(sx, sy, size, 0, Math.PI * 2);
          wctx.fill();

          if (z < 2 && size > 1) {
            const streakLen = (2 - z) * 20 * vis;
            const dx = sx - cx;
            const dy = sy - cy;
            const dist = Math.sqrt(dx * dx + dy * dy) || 1;
            wctx.globalAlpha = alpha * 0.3;
            wctx.beginPath();
            wctx.moveTo(sx, sy);
            wctx.lineTo(sx + (dx / dist) * streakLen, sy + (dy / dist) * streakLen);
            wctx.strokeStyle = P.warpDotColor;
            wctx.lineWidth = size * 0.5;
            wctx.stroke();
          }
        }
        wctx.globalAlpha = 1;
      }

      // ---------- render: everything derived from timeline time + state proxy ----------
      const startCol = new THREE.Color(P.portalStartColor);
      const endCol = new THREE.Color(P.bgColor);

      function renderScene(t) {
        const S = window.__wptState;
        const portal = S.portal;
        const push = S.push;
        const transAmount = 1.0 - Math.abs(portal * 2 - 1);
        const effTrans = Math.min(1, Math.max(transAmount, S.exitFx * 0.85));

        // Camera: FOV/distance lerp (2D -> 3D) plus exit push through the letters
        camera.fov = THREE.MathUtils.lerp(P.initialFOV, P.finalFOV, portal) + push * 10;
        camera.position.z =
          THREE.MathUtils.lerp(P.cameraDistance, P.cameraDistance3D, portal) -
          push * 52 +
          Math.sin(t * 0.34) * 1.6 * portal * (1 - push);
        const driftAmp = portal * (1 - 0.7 * push);
        camera.rotation.x = Math.cos(t * 0.47) * 0.04 * driftAmp;
        camera.rotation.y = Math.sin(t * 0.62 + 1.2) * 0.06 * driftAmp;
        camera.position.x = Math.sin(t * 0.41) * 1.4 * driftAmp;
        camera.position.y = Math.cos(t * 0.29) * 1.0 * driftAmp;
        camera.updateProjectionMatrix();
        camera.updateMatrixWorld();

        // Portal composite
        portalPass.uniforms.uPortalRadius.value = portal * P.portalScale;
        portalPass.uniforms.uPortalProgress.value = portal;
        portalPass.uniforms.uTime.value = t;

        // Vignette / scanline / CA scale with portal
        postfxPass.uniforms.uVignetteIntensity.value = P.vignetteIntensity * portal;
        postfxPass.uniforms.uChromaticAberration.value = P.chromaticAberration * portal;
        postfxPass.uniforms.uScanlineIntensity.value = P.scanlineIntensity * portal;
        postfxPass.uniforms.uTime.value = t;

        const blurAmount = P.edgeBlur * portal;
        blurPassH.uniforms.uBlurAmount.value = blurAmount;
        blurPassV.uniforms.uBlurAmount.value = blurAmount;

        // Transition FX peak at portal 0.5 + on the exit push
        transFXPass.uniforms.uTransAmount.value = effTrans;

        // Block glitch ticks (the handoff envelope below follows duration/easing).
        glitchPass.uniforms.uTime.value = t;
        glitchPass.uniforms.uShakePower.value = P.glitchPower * S.glitch;
        glitchPass.uniforms.uShakeRate.value = S.glitch > 0.001 ? 1.0 : 0.0;
        glitchPass.uniforms.uShakeColorRate.value = P.glitchColorRate * Math.min(1, S.glitch * 1.8);

        // Grid + background plane
        const showContent = portal > 0.01;
        gridLines.visible = showContent;
        contentGroup.visible = showContent;
        bgPlane.visible = showContent;
        const colorT = Math.min(1, Math.max(0, (portal - 0.15) * P.portalColorSpeed));
        bgPlaneMat.color.copy(startCol).lerp(endCol, colorT);
        gridMat.uniforms.uCameraPos.value.copy(camera.position);
        gridMat.uniforms.uTime.value = t;

        // Floating shapes drift
        for (let i = 0; i < floatingShapes.length; i++) {
          const mesh = floatingShapes[i];
          const base = mesh.userData.basePos;
          const phase = i * 2.17;
          mesh.position.x =
            base.x + Math.sin(t * 0.15 + phase) * 1.2 + Math.sin(t * 0.07 + phase * 3) * 0.5;
          mesh.position.y =
            base.y + Math.sin(t * 0.12 + phase * 1.4) * 0.8 + Math.cos(t * 0.09 + phase * 2) * 0.3;
          mesh.position.z =
            base.z + Math.sin(t * 0.1 + phase * 0.7) * 0.6 + P.contentZOffset * portal;
          mesh.rotation.x = Math.sin(t * 0.08 + phase) * 0.4;
          mesh.rotation.y = t * 0.05 * (i % 2 === 0 ? 1 : -1) + phase;
          mesh.rotation.z = Math.sin(t * 0.06 + phase * 1.3) * 0.3;
        }

        // Content: flat page -> per-letter depth and z-spread
        contentGroup.position.z = P.contentZOffset * portal;
        for (let i = 0; i < textMeshes.length; i++) {
          const obj = textMeshes[i];
          if (obj.userData.isLetter) {
            obj.position.z = obj.userData.targetZ * portal;
            const zScale = THREE.MathUtils.lerp(0.01, obj.userData.depthScale, portal);
            obj.scale.z = zScale;
          }
          obj.position.y = obj.userData.baseY + Math.sin(t * 0.6 + i * 0.7) * 0.12 * portal;
        }
        const handoffGlitch = handoffLetters(t, portal);
        recedeSupportingLines(t);
        const glitch = Math.max(S.glitch * 0.5, handoffGlitch * 0.35) * transition.glitch;
        glitchPass.uniforms.uShakePower.value = P.glitchPower * glitch;
        glitchPass.uniforms.uShakeRate.value = glitch > 0.001 ? 1 : 0;
        glitchPass.uniforms.uShakeColorRate.value = P.glitchColorRate * glitch * 1.8;

        if (portal < 0.001) {
          renderer.clear();
          wctx.clearRect(0, 0, W, H);
          return;
        }
        composer.render();
        drawWarpDots(S);
      }

      window.__wptRender = renderScene;
      const tl = window.__timelines["wireframe-portal-title"];
      renderScene(tl ? tl.time() : 0);
      /* The portal is closed at t=0 and draws nothing, so the first real draw would
         link every shader mid-playback. Draw across the timeline and flush the GPU
         before ready instead. */
      window.__hf = window.__hf || {};
      window.__hf.buildReady = window.__hf.buildReady || {};
      window.__hf.buildReady["wireframe-portal-title"] = (async () => {
        const warmDur = tl ? tl.duration() : 0;
        for (const f of [0.3, 0.5, 0.7]) {
          await new Promise((resolve) => setTimeout(resolve, 0));
          renderScene(warmDur * f);
        }
        renderScene(tl ? tl.time() : 0);
        renderer.getContext().finish();
      })();
    </script>
  </body>
</html>
```

Copy npx hyperframes add wireframe-portal-title to the clipboard

That writes `compositions/wireframe-portal-title/wireframe-portal-title.html`, plus 5 supporting files under `compositions/wireframe-portal-title/assets/fonts/` and `compositions/wireframe-portal-title/`.

## Add it to your video

It runs for 8 seconds at 1920×1080. Paste this into your composition:

```html index.html theme={null}
<div
  data-composition-id="wireframe-portal-title"
  data-composition-src="compositions/wireframe-portal-title/wireframe-portal-title.html"
  data-start="0"
  data-duration="8"
  data-track-index="1"
  data-width="1920"
  data-height="1080"
></div>
```

Move it in time with `data-start`. Put it on a different timeline row with
`data-track-index`. See [data attributes](/concepts/data-attributes.md) for the rest.

Tagged `3d-motion` `title-card` `wireframe` `portal` `burst` `typography` `post-processing` `phrase-swap`.

## Related topics

* [Browse the complete Catalog](/catalog.md)
* [Add assets and Catalog items in the browser editor](/studio/assets-and-blocks.md)
* [Put blocks together into one video](/concepts/compositions.md)


## Related topics

- [Portal](/catalog/blocks/vfx-portal.md)
- [Handwritten Title](/catalog/blocks/hw-title.md)
- [Glass Shard Title](/catalog/blocks/glass-shard-title.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.