# Device Push-in Focus

A line-art 3D laptop turns upright while closed, opens, pushes in on the keyboard, then pans from key to key in step with the option selected in the UI beside it.

- Position in a launch film: Product reveal
- Slug: device-push-in-focus
- Preview video: https://media.motionboard.app/device-focus.mp4
- Recipe page: https://motionboard.app/recipes/device-push-in-focus/
- License: MIT. Free to use in your own projects, including commercial launch films. https://github.com/krisyangdev/motionboard/blob/main/LICENSE

## How to use this recipe

You are a coding agent. The user wants this shot for their own product. Follow these steps.

1. **Know the product.** If the product is clear from the user's repo or conversation, ask nothing and start. Otherwise ask once for: product name, logo file, brand colors.
2. **Pick the output.** If the user's message says a video or their website, do that. Otherwise ask once which one they want.
   - **Video (HyperFrames).** Create a folder named `device-focus`. Download every file under "Source files" below with the curl commands, keeping relative paths. Adapt `index.html` (see "What to adapt"). Run `npx --yes hyperframes@0.8.209 check` on the untouched copy first and note its result. Preview with `npx --yes hyperframes@0.8.209 preview`, render an MP4 with `npx --yes hyperframes@0.8.209 render` (into `renders/`, named with a timestamp). The same commands are in `package.json` as `dev`, `check`, `render`; pass flags after `--`, as in `npm run render -- --quality draft`. Rendering needs network access for web fonts and GSAP.
   - **Website motion.** Port the GSAP timeline in `index.html` into the user's site, using their stack (React, Vue, Svelte, plain HTML). Build the same DOM structure, keep the timeline's durations, delays and easings exactly, and start it when the element scrolls into view (IntersectionObserver or ScrollTrigger). Respect `prefers-reduced-motion: reduce`: show the final frame as a static state and skip the movement.
3. **What to adapt.**
   - **Brand layer** (product name, logo, colors, fonts, copy): replace directly with the user's.
   - **UI layer** (the product screens, cards, windows, controls shown in the shot): rebuild the user's real UI as clean DOM, from their codebase (components, design tokens) or from a screenshot used as reference only. Never paste a screenshot into the shot.
   - **Motion** (timing, easing, camera moves, layering, stagger, order of events): keep exactly as in the source. This is what the recipe is.
   - The timeline targets UI elements by id and class, and camera moves land on fixed coordinates. Keep the ids the timeline uses, keep row counts and text lengths close to the source, and when the layout moves, update the target coordinates without changing the times.
4. **Fonts.** HyperFrames fetches Google Fonts such as Inter on its own. Some sources use macOS local fonts (PingFang, Songti via `local()`). Use the user's brand font when it can be bundled; for a system font such as SF Pro, use Inter. Check that text still fits its boxes.
5. **Verify.** Compare the render with the preview video above at the beat times in the Timing row. The beats, timing and camera should match; only brand and UI content differ.

## Spec

| Item | Value |
| --- | --- |
| Timing | Film 10s. Intro: laptop rises 0–0.5s, turns upright while closed 0.5–1.2s (0.7s, 21 frames), lid opens 1.2–1.75s (0.55s, 17 frames), push-in on keyboard 1.6–3.2s (1.6s, 48 frames). Then three key groups: ⌥+Space highlight 3.3s, press 3.8s, off 5.0s; pan 5.0–5.8s (0.8s, 24 frames), ⌘+⇧+K highlight 5.6s, press 6.0s, off 7.2s; pan 7.2–8.0s (0.8s), fn highlight 7.8s, press 8.2s |
| Easing | Push-in and every pan `cubic-bezier(0.16,1,0.3,1)` (expo.out); yaw, lid opening and camera retarget easeInOutCubic; rise easeOutCubic; highlight fade 0.2s easeInOutCubic |
| Camera | Perspective camera, 22° fov telephoto, fixed viewing direction (0.62, 0.78, 0.9); yaw turns from 35° to 0°, the screen lid opens 0°→112°; the push-in dollies 150→60 units onto the ⌥+Space span, then pans to the ⌘+⇧+K span (dolly 70) and to fn (dolly 30) |
| Subject | Each key sinks 0.22 units in a 0.25s press (0.08s down, 0.17s easeOutBack return); highlighted keys go from white to yellow, pressed keys darken to `#e5b92f`; the pan target is the key group of the option just selected |
| Sync | The dropdown selects at 4.95s and 7.15s and the pan starts 0.05s later; key highlight lands 0.6s into the pan (5.6s, 7.8s), the press 0.4s after that (first group 0.5s) |
| Style | White material with gray outlines (`#a9a9a9` body 2.2px, `#b8b8b8` keys 1.8px at 2x pixel ratio), yellow accent `#f6c83e` (highlight `#f6d45a`); 3D on the left (760px), UI on the right |

## Why it works

The key highlight and the dropdown selection on the right happen on the same frame, so viewers link each key to its setting at a glance. A long telephoto lens flattens perspective, so the keys stay flat and readable after the push-in. The material is white with a 1.2px dark gray outline, and yellow is the only color in the frame.

## Prompt

> Build an input-method onboarding demo called Device Push-in Focus. Split the page: 3D on the left (760px), UI on the right. The left side is a line-art 3D laptop with white material, gray outlines (2.2px body, 1.8px keys at 2x pixel ratio), accent color yellow #f6c83e, and a Three.js perspective camera with a 22° fov (telephoto) looking along a fixed direction (0.62, 0.78, 0.9). Sequence: the laptop rises in over 0.5s, turns yaw from 35° to 0° while closed over 0.7s, opens the screen lid from 0° to 112° over 0.55s (easeInOutCubic for both), then the camera pushes in on the keyboard over 1.6s (expo.out, dolly 150→60 units) and the keys for the current option light up in yellow (0.2s). After a 0.5s hold the key sinks 0.22 units and springs back (0.25s). When the dropdown on the right selects a new option, pan the camera to the matching key group over 0.8s (expo.out) starting right after the selection; the new keys highlight 0.6s into the pan and press 0.4s after that. Pan targets are ⌥+Space, then ⌘+⇧+K, then fn.

## Often confused with

- [Macro Pull-out Reveal](https://motionboard.app/r/macro-pull-out-reveal.md): Device Push-in Focus serves one UI choice after another, each pan finishing within 0.8s; Macro Pull-out Reveal is one long shot that ends by pulling out to the whole device.

## Source files

Runnable HyperFrames project, 3 files.

- https://motionboard.app/source/device-focus/hyperframes.json (353 B)
- https://motionboard.app/source/device-focus/index.html (25.1 KB)
- https://motionboard.app/source/device-focus/package.json (301 B)

Download all of them:

```sh
curl -fsSL --create-dirs -o "device-focus/hyperframes.json" "https://motionboard.app/source/device-focus/hyperframes.json"
curl -fsSL --create-dirs -o "device-focus/index.html" "https://motionboard.app/source/device-focus/index.html"
curl -fsSL --create-dirs -o "device-focus/package.json" "https://motionboard.app/source/device-focus/package.json"
```

## index.html

```html
<!doctype html>
<html lang="zh-CN" data-resolution="landscape">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <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>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
    <style>
      @font-face { font-family: "PF"; font-weight: 400; src: local("PingFang SC"), local("PingFangSC-Regular"); }
      @font-face { font-family: "PF"; font-weight: 500; src: local("PingFangSC-Medium"), local("PingFang SC Medium"), local("PingFang SC"); }
      @font-face { font-family: "PF"; font-weight: 600; src: local("PingFangSC-Semibold"), local("PingFang SC Semibold"), local("PingFang SC"); }
      @font-face { font-family: "PF"; font-weight: 700; src: local("PingFangSC-Semibold"), local("PingFang SC Semibold"), local("PingFang SC"); }

      :root {
        --page: #0e0e0f;
        --card: #f6f4ef;
        --ink: #161616;
        --muted: #6d6a64;
        --line: #e4e0d8;
        --yellow: #f6c83e;
        --green: #2b7a47;
        --font-ui: "PF", sans-serif;
      }
      * { margin: 0; padding: 0; box-sizing: border-box; }
      html, body { width: 1920px; height: 1080px; overflow: hidden; background: var(--page); }
      #root {
        position: relative;
        width: 1920px;
        height: 1080px;
        background: var(--page);
        font-family: var(--font-ui);
        color: var(--ink);
        -webkit-font-smoothing: antialiased;
      }
      #card {
        position: absolute;
        left: 120px;
        top: 70px;
        width: 1680px;
        height: 940px;
        border-radius: 28px;
        overflow: hidden;
        background: var(--card);
        box-shadow: 0 40px 100px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.04);
      }

      /* left panel */
      #left { position: absolute; left: 0; top: 0; width: 760px; height: 940px; background: #ffffff; overflow: hidden; }
      #stage { position: absolute; left: 0; top: 0; width: 760px; height: 940px; }
      #gl { display: block; width: 760px; height: 940px; }
      #traffic {
        position: absolute; left: 28px; top: 24px; z-index: 5;
        display: flex; align-items: center; gap: 8px;
        height: 22px; padding: 0 10px; border-radius: 999px;
        background: rgba(0, 0, 0, 0.05);
      }
      #traffic i { display: block; width: 9px; height: 9px; border-radius: 50%; background: #c9c6bf; }

      /* right panel */
      #right { position: absolute; left: 760px; top: 0; width: 920px; height: 940px; }
      #dots { position: absolute; left: 96px; top: 64px; height: 8px; display: flex; align-items: center; gap: 10px; }
      #dots i { display: block; width: 8px; height: 8px; border-radius: 4px; background: #ddd9d0; }
      #dots i.done { background: #b9b4a8; }
      #dots i.cur { width: 28px; background: #161616; }
      #lang {
        position: absolute; right: 56px; top: 50px; height: 36px; padding: 0 16px;
        display: flex; align-items: center; gap: 6px;
        border: 1px solid var(--line); border-radius: 999px; background: #ffffff;
        font-size: 16px; color: var(--muted);
      }
      #center {
        position: absolute; left: 96px; top: 0; width: 728px; height: 940px;
        display: flex; flex-direction: column; justify-content: center;
        padding-bottom: 20px;
      }
      #title { font-size: 44px; font-weight: 700; line-height: 56px; color: #161616; letter-spacing: 0; }
      #helper { margin-top: 18px; font-size: 24px; line-height: 36px; color: var(--muted); font-weight: 400; }
      #dd { position: relative; margin-top: 44px; width: 360px; height: 64px; }
      #ddbox {
        position: absolute; left: 0; top: 0; width: 360px; height: 64px;
        display: flex; align-items: center; padding: 0 22px 0 22px;
        border: 1px solid var(--line); border-radius: 12px; background: #ffffff;
      }
      #vals { position: relative; flex: 1; height: 62px; }
      .val { position: absolute; left: 0; top: 0; line-height: 62px; font-size: 22px; font-weight: 500; color: #161616; white-space: nowrap; opacity: 0; }
      .val.v0 { opacity: 1; }
      #chev { display: inline-block; font-size: 20px; line-height: 20px; color: #7a766f; }
      #menu {
        position: absolute; left: 0; top: 72px; width: 360px; padding: 6px; z-index: 10;
        border: 1px solid var(--line); border-radius: 14px; background: #ffffff;
        box-shadow: 0 18px 40px rgba(40, 32, 10, 0.12);
        opacity: 0; pointer-events: none;
      }
      #menu .row { position: relative; height: 52px; border-radius: 9px; display: flex; align-items: center; padding-left: 16px; font-size: 20px; font-weight: 500; color: #161616; white-space: nowrap; }
      #menu .tint { position: absolute; inset: 0; border-radius: 9px; background: #fbe9a6; opacity: 0; }
      #menu .r1 .tint { opacity: 1; }
      #menu .row span { position: relative; z-index: 1; }
      #status { margin-top: 22px; height: 28px; line-height: 28px; font-size: 20px; font-weight: 500; color: var(--green); opacity: 0; }
      #back { position: absolute; left: 96px; bottom: 56px; height: 56px; line-height: 56px; font-size: 22px; color: var(--muted); }
      #cont {
        position: absolute; right: 96px; bottom: 56px; width: 120px; height: 56px; border-radius: 12px;
        background: var(--yellow); color: #1d1a12; font-size: 22px; font-weight: 600;
        display: flex; align-items: center; justify-content: center;
      }
    </style>
  </head>
  <body>
    <div id="root" data-composition-id="main" data-start="0" data-duration="10" data-width="1920" data-height="1080">
      <div id="card" class="clip" data-start="0" data-duration="10" data-track-index="0">
        <div id="left">
          <div id="stage"><canvas id="gl"></canvas></div>
        </div>
        <div id="traffic"><i></i><i></i><i></i></div>

        <div id="right">
          <div id="dots"><i class="done"></i><i class="done"></i><i class="done"></i><i class="done"></i><i class="done"></i><i class="done"></i><i class="cur"></i><i></i><i></i></div>
          <div id="lang"><span>简体中文</span><span>⌄</span></div>

          <div id="center">
            <h1 id="title">设置唤醒快捷键</h1>
            <p id="helper">按下快捷键即可唤醒语音输入，再按一次结束。</p>
            <div id="dd">
              <div id="ddbox">
                <div id="vals">
                  <span class="val v0">⌥ Option + 空格</span>
                  <span class="val v1">⌘ Command + ⇧ Shift + K</span>
                  <span class="val v2">fn</span>
                </div>
                <span id="chev">⌄</span>
              </div>
              <div id="menu">
                <div class="row r1"><i class="tint"></i><span>⌥ Option + 空格</span></div>
                <div class="row r2"><i class="tint"></i><span>⌘ Command + ⇧ Shift + K</span></div>
                <div class="row r3"><i class="tint"></i><span>fn</span></div>
              </div>
            </div>
            <div id="status">✓ 快捷键可用</div>
          </div>

          <div id="back">返回</div>
          <div id="cont">继续</div>
        </div>
      </div>
    </div>

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

      // entrance: 0.2 - 0.72, 0.06 s apart
      function enter(sel, at) {
        tl.fromTo(sel, { opacity: 0, y: 14, filter: "blur(6px)" }, { opacity: 1, y: 0, filter: "blur(0px)", duration: 0.34, ease: "power3.out" }, at);
      }
      tl.fromTo("#stage", { opacity: 0 }, { opacity: 1, duration: 0.5, ease: "power2.out" }, 0);
      enter("#dots", 0.2);
      enter("#lang", 0.2);
      enter("#title", 0.2);
      enter("#helper", 0.26);
      enter("#dd", 0.32);
      enter("#cont", 0.38);
      enter("#back", 0.38);

      // dropdown menu open / close
      let firstMenu = true;
      function openMenu(at) {
        const ir = firstMenu; firstMenu = false;
        tl.fromTo("#menu", { opacity: 0, y: -8 }, { opacity: 1, y: 0, duration: 0.25, ease: "power3.out", immediateRender: ir }, at);
        tl.fromTo("#ddbox", { borderColor: "#e4e0d8" }, { borderColor: "#d9ab2b", duration: 0.2, ease: "power2.out", immediateRender: ir }, at);
        tl.fromTo("#chev", { rotation: 0 }, { rotation: 180, duration: 0.25, ease: "power3.out", immediateRender: ir }, at);
      }
      function closeMenu(at) {
        tl.fromTo("#menu", { opacity: 1, y: 0 }, { opacity: 0, y: -8, duration: 0.15, ease: "power2.in", immediateRender: false }, at);
        tl.fromTo("#ddbox", { borderColor: "#d9ab2b" }, { borderColor: "#e4e0d8", duration: 0.15, ease: "power2.in", immediateRender: false }, at);
        tl.fromTo("#chev", { rotation: 180 }, { rotation: 0, duration: 0.2, ease: "power2.out", immediateRender: false }, at);
      }
      function tint(from, to, at) {
        tl.fromTo(".r" + from + " .tint", { opacity: 1 }, { opacity: 0, duration: 0.2, ease: "power2.out", immediateRender: false }, at);
        tl.fromTo(".r" + to + " .tint", { opacity: 0 }, { opacity: 1, duration: 0.2, ease: "power2.out", immediateRender: false }, at);
      }
      function setValue(from, to, at) {
        tl.fromTo(".val.v" + from, { opacity: 1 }, { opacity: 0, duration: 0.15, ease: "power1.out", immediateRender: false }, at);
        tl.fromTo(".val.v" + to, { opacity: 0 }, { opacity: 1, duration: 0.15, ease: "power1.out", immediateRender: false }, at);
      }
      let firstStatus = true;
      function showStatus(at) {
        const ir = firstStatus; firstStatus = false;
        tl.fromTo("#status", { opacity: 0, y: 6 }, { opacity: 1, y: 0, duration: 0.3, ease: "power3.out", immediateRender: ir }, at);
      }
      function hideStatus(at) {
        tl.fromTo("#status", { opacity: 1, y: 0 }, { opacity: 0, y: -4, duration: 0.15, ease: "power2.in", immediateRender: false }, at);
      }

      showStatus(3.9);
      hideStatus(4.25);
      openMenu(4.4);
      tint(1, 2, 4.75);
      closeMenu(4.95);
      setValue(0, 1, 4.95);

      showStatus(6.1);
      hideStatus(6.45);
      openMenu(6.6);
      tint(2, 3, 6.95);
      closeMenu(7.15);
      setValue(1, 2, 7.15);

      showStatus(8.3);

      window.__timelines = window.__timelines || {};
      window.__timelines["main"] = tl;
    </script>

    <script type="module">
      import * as THREE from "three";
      import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.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";

      /* ---------- time helpers ---------- */
      const clamp01 = (x) => Math.min(1, Math.max(0, x));
      const seg = (t, a, b) => clamp01((t - a) / (b - a));
      const mix = (a, b, k) => a + (b - a) * k;
      const easeInOutCubic = (k) => (k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2);
      const easeOutCubic = (k) => 1 - Math.pow(1 - k, 3);
      const easeOutExpo = (k) => (k >= 1 ? 1 : 1 - Math.pow(2, -10 * k));
      const easeOutBack = (k, s = 1.70158) => 1 + (s + 1) * Math.pow(k - 1, 3) + s * Math.pow(k - 1, 2);
      const DEG = Math.PI / 180;

      /* ---------- renderer ---------- */
      const W = 760, H = 940, PR = 2;
      const canvas = document.getElementById("gl");
      const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, preserveDrawingBuffer: true });
      renderer.setPixelRatio(PR);
      renderer.setSize(W, H, false);
      renderer.setClearColor(0x000000, 0);

      const scene = new THREE.Scene();
      const camera = new THREE.PerspectiveCamera(22, W / H, 1, 800);

      scene.add(new THREE.HemisphereLight(0xffffff, 0xdcdcdc, 2.5));
      const sun = new THREE.DirectionalLight(0xffffff, 0.9);
      sun.position.set(-18, 40, 26);
      scene.add(sun);

      /* ---------- materials ---------- */
      function faceMat(hex) {
        return new THREE.MeshLambertMaterial({ color: hex, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 });
      }
      function lineMat(hex, width) {
        const m = new LineMaterial({ color: hex, linewidth: width, worldUnits: false });
        m.resolution.set(W * PR, H * PR);
        return m;
      }
      const BODY_LW = 2.2, KEY_LW = 1.8;
      const bodyLine = lineMat(0xa9a9a9, BODY_LW);
      const softLine = lineMat(0xd2d2d2, 1.6);
      const keyLine = lineMat(0xb8b8b8, KEY_LW);

      // outline of a rounded box: edges of a plain box pulled in to the surface of the rounded corners
      const olCache = new Map();
      function outlineBox(w, h, d, r, mat) {
        const k = 0.5 * r;
        const id = [w, h, d, k].map((v) => v.toFixed(3)).join("_");
        let lg = olCache.get(id);
        if (!lg) {
          lg = new LineSegmentsGeometry().fromEdgesGeometry(new THREE.EdgesGeometry(new THREE.BoxGeometry(w - k, h - k, d - k), 30));
          olCache.set(id, lg);
        }
        return new LineSegments2(lg, mat);
      }

      function rrShape(w, d, r) {
        const s = new THREE.Shape();
        const x = -w / 2, y = -d / 2, P = Math.PI;
        s.moveTo(x + r, y);
        s.lineTo(x + w - r, y);
        s.absarc(x + w - r, y + r, r, -P / 2, 0, false);
        s.lineTo(x + w, y + d - r);
        s.absarc(x + w - r, y + d - r, r, 0, P / 2, false);
        s.lineTo(x + r, y + d);
        s.absarc(x + r, y + d - r, r, P / 2, P, false);
        s.lineTo(x, y + r);
        s.absarc(x + r, y + r, r, P, P * 1.5, false);
        return s;
      }
      // flat rounded-rectangle plate, bottom at y0, thickness h
      function plate(w, d, r, h, y0, x, z, color, lmat) {
        const geo = new THREE.ExtrudeGeometry(rrShape(w, d, r), { depth: h, bevelEnabled: false, curveSegments: 10 });
        geo.rotateX(-Math.PI / 2);
        const mesh = new THREE.Mesh(geo, faceMat(color));
        mesh.position.set(x, y0, z);
        const lg = new LineSegmentsGeometry().fromEdgesGeometry(new THREE.EdgesGeometry(geo, 30));
        mesh.add(new LineSegments2(lg, lmat));
        return mesh;
      }

      /* ---------- laptop ---------- */
      const laptop = new THREE.Group();
      scene.add(laptop);

      // contact shadow
      {
        const sw = 30.4 + 16, sd = 21.2 + 16;
        const sc = document.createElement("canvas");
        sc.width = 1024; sc.height = Math.round((1024 * sd) / sw);
        const g = sc.getContext("2d");
        const grad = g.createRadialGradient(sc.width / 2, sc.height / 2, 0, sc.width / 2, sc.height / 2, sc.width / 2);
        grad.addColorStop(0, "rgba(0,0,0,0.07)");
        grad.addColorStop(1, "rgba(0,0,0,0)");
        g.fillStyle = grad;
        g.fillRect(0, 0, sc.width, sc.height);
        const rw = (30.4 / sw) * sc.width, rh = (21.2 / sd) * sc.height, OFF = 4000;
        g.shadowColor = "rgba(0,0,0,0.20)";
        g.shadowBlur = 80;
        g.shadowOffsetX = OFF;
        g.fillStyle = "#000";
        g.fillRect((sc.width - rw) / 2 - OFF, (sc.height - rh) / 2 + 6, rw, rh);
        const tex = new THREE.CanvasTexture(sc);
        tex.colorSpace = THREE.SRGBColorSpace;
        const shadow = new THREE.Mesh(
          new THREE.PlaneGeometry(sw, sd),
          new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false })
        );
        shadow.rotation.x = -Math.PI / 2;
        shadow.position.y = -0.02;
        shadow.renderOrder = -1;
        laptop.add(shadow);
      }

      // base
      const base = new THREE.Mesh(new RoundedBoxGeometry(30.4, 1.1, 21.2, 4, 0.5), faceMat(0xffffff));
      base.position.y = 0.55;
      base.add(outlineBox(30.4, 1.1, 21.2, 0.5, bodyLine));
      laptop.add(base);

      // keyboard well and trackpad
      const U = 1.9, KB_U = 14.5, KB_W = KB_U * U, KB_L = -KB_W / 2;
      const ROW_Z = [-8.7, -7.2, -5.3, -3.4, -1.5, 0.4];
      const WELL_Z0 = -9.55, WELL_Z1 = 1.675;
      laptop.add(plate(KB_W + 1.0, WELL_Z1 - WELL_Z0, 0.9, 0.12, 1.04, 0, (WELL_Z0 + WELL_Z1) / 2, 0xececec, softLine));
      laptop.add(plate(13, 8, 0.7, 0.1, 1.07, 0, 6.1, 0xf1f1f1, softLine));

      // lid
      const pivot = new THREE.Group();
      pivot.position.set(0, 1.1, -10.6);
      laptop.add(pivot);
      {
        const lid = new THREE.Mesh(new RoundedBoxGeometry(30.4, 0.6, 21.2, 3, 0.28), faceMat(0xffffff));
        lid.position.set(0, 0.3, 10.6);
        lid.add(outlineBox(30.4, 0.6, 21.2, 0.28, bodyLine));
        pivot.add(lid);

        const down = Math.PI / 2; // plane normal -> local -y (the inner face)
        const bezel = new THREE.Mesh(
          new THREE.PlaneGeometry(29.4, 20.2),
          new THREE.MeshBasicMaterial({ color: 0x050505, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 })
        );
        bezel.rotation.x = down;
        bezel.position.set(0, -0.008, 10.6);
        pivot.add(bezel);

        const screen = new THREE.Mesh(
          new THREE.PlaneGeometry(28.4, 19.2),
          new THREE.MeshBasicMaterial({ color: 0x111111, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 })
        );
        screen.rotation.x = down;
        screen.position.set(0, -0.016, 10.6);
        pivot.add(screen);
      }

      /* ---------- keys ---------- */
      const KEY_Y = 1.16 + 0.175, CAP_H = 0.35, CAP_R = 0.17, GAP = 0.25;
      const keys = {};
      const keyPos = {};
      const labelDraws = [];
      const texCache = {};
      const matCache = {};
      const capGeoCache = {};
      const baseCapMat = faceMat(0xfbfbfb);

      const TRI = {
        left: [[96, 128], [160, 90], [160, 166]],
        right: [[160, 128], [96, 90], [96, 166]],
        up: [[128, 90], [88, 164], [168, 164]],
        down: [[128, 166], [88, 92], [168, 92]],
      };
      const FONT = '"PF","Apple Symbols","Helvetica Neue","Segoe UI Symbol",sans-serif';
      function labelMaterial(glyph) {
        if (matCache[glyph]) return matCache[glyph];
        const c = document.createElement("canvas");
        c.width = c.height = 256;
        const tex = new THREE.CanvasTexture(c);
        tex.colorSpace = THREE.SRGBColorSpace;
        tex.anisotropy = 8;
        const draw = () => {
          const g = c.getContext("2d");
          g.clearRect(0, 0, 256, 256);
          g.fillStyle = "#8a8a8a";
          if (TRI[glyph]) {
            g.beginPath();
            TRI[glyph].forEach((p, i) => (i ? g.lineTo(p[0], p[1]) : g.moveTo(p[0], p[1])));
            g.closePath();
            g.fill();
          } else {
            const long = glyph.length > 1;
            g.textAlign = "center";
            g.textBaseline = "middle";
            g.font = (long ? "600 104px " : "500 124px ") + FONT;
            g.fillText(glyph, 128, long ? 138 : 134);
          }
          tex.needsUpdate = true;
        };
        draw();
        labelDraws.push(draw);
        texCache[glyph] = tex;
        matCache[glyph] = new THREE.MeshBasicMaterial({
          map: tex, transparent: true, depthWrite: false,
          polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1,
        });
        return matCache[glyph];
      }

      const ANIMATED = new Set(["optL", "space", "cmdL", "shiftL", "K", "fn"]);
      function addKey(id, glyph, xc, zc, wCap, dCap) {
        const gk = wCap.toFixed(3) + "x" + dCap.toFixed(3);
        const geo = capGeoCache[gk] || (capGeoCache[gk] = new RoundedBoxGeometry(wCap, CAP_H, dCap, 3, CAP_R));
        const anim = ANIMATED.has(id);
        const mat = anim ? baseCapMat.clone() : baseCapMat;
        const lm = anim ? lineMat(0xb8b8b8, KEY_LW) : keyLine;
        const cap = new THREE.Mesh(geo, mat);
        cap.position.set(xc, KEY_Y, zc);
        cap.add(outlineBox(wCap, CAP_H, dCap, CAP_R, lm));
        if (glyph) {
          const s = Math.min(1.3, dCap, wCap);
          const lab = new THREE.Mesh(new THREE.PlaneGeometry(s, s), labelMaterial(glyph));
          lab.rotation.x = -Math.PI / 2;
          lab.position.y = CAP_H / 2 + 0.004;
          cap.add(lab);
        }
        laptop.add(cap);
        cap.userData = { lineMat: lm };
        keys[id] = cap;
        keyPos[id] = new THREE.Vector3(xc, KEY_Y + CAP_H / 2, zc);
      }

      function addRow(zc, depth, items) {
        let cum = 0;
        items.forEach(([id, glyph, u]) => {
          const xc = KB_L + (cum + u / 2) * U;
          addKey(id, glyph, xc, zc, u * U - GAP, depth);
          cum += u;
        });
      }
      const L = (c) => [c, c === "K" ? "K" : null, 1];

      // row 0: esc, F1-F12, Touch ID (14 equal half-height caps over 14.5u)
      {
        const u0 = KB_U / 14;
        const r0 = [["esc", "esc", u0]];
        for (let i = 1; i <= 12; i++) r0.push(["F" + i, null, u0]);
        r0.push(["touchid", null, u0]);
        addRow(ROW_Z[0], 0.9, r0);
      }
      addRow(ROW_Z[1], 1.65, [
        ["grave", null, 1], ..."1234567890".split("").map(L), ["minus", null, 1], ["equal", null, 1], ["delete", null, 1.5],
      ]);
      addRow(ROW_Z[2], 1.65, [
        ["tab", null, 1.5], ..."QWERTYUIOP".split("").map(L), ["lbrack", null, 1], ["rbrack", null, 1], ["bslash", null, 1],
      ]);
      addRow(ROW_Z[3], 1.65, [
        ["caps", null, 1.75], ..."ASDFGHJKL".split("").map(L), ["semi", null, 1], ["quote", null, 1], ["return", null, 1.75],
      ]);
      addRow(ROW_Z[4], 1.65, [
        ["shiftL", "⇧", 2.25], ..."ZXCVBNM".split("").map(L), ["comma", null, 1], ["period", null, 1], ["slash", null, 1], ["shiftR", "⇧", 2.25],
      ]);
      addRow(ROW_Z[5], 1.65, [
        ["fn", "fn", 1], ["ctrl", "⌃", 1], ["optL", "⌥", 1], ["cmdL", "⌘", 1.25],
        ["space", null, 5],
        ["cmdR", "⌘", 1.25], ["optR", "⌥", 1],
      ]);
      // inverted-T arrows: x 11.5u..14.5u
      {
        const hd = 0.79, z5 = ROW_Z[5];
        const ax = (u) => KB_L + u * U;
        addKey("left", "left", ax(12), z5 + 0.43, U - GAP, hd);
        addKey("up", "up", ax(13), z5 - 0.43, U - GAP, hd);
        addKey("down", "down", ax(13), z5 + 0.43, U - GAP, hd);
        addKey("right", "right", ax(14), z5 + 0.43, U - GAP, hd);
      }

      /* ---------- key highlight schedule ---------- */
      const SCHED = [
        { id: "optL", on: 3.3, press: 3.8, off: 5.0 },
        { id: "space", on: 3.3, press: 3.8, off: 5.0 },
        { id: "cmdL", on: 5.6, press: 6.0, off: 7.2 },
        { id: "shiftL", on: 5.6, press: 6.0, off: 7.2 },
        { id: "K", on: 5.6, press: 6.0, off: 7.2 },
        { id: "fn", on: 7.8, press: 8.2, off: null },
      ];
      const C_WHITE = new THREE.Color(0xfbfbfb), C_YEL = new THREE.Color(0xf6d45a), C_DARK = new THREE.Color(0xe5b92f);
      const L_GREY = new THREE.Color(0xb8b8b8), L_YEL = new THREE.Color(0xc9a227);
      const tmp = new THREE.Color();

      function pressEnv(dt) {
        if (dt <= 0 || dt >= 0.25) return 0;
        if (dt < 0.08) return easeOutCubic(dt / 0.08);
        return 1 - easeOutBack((dt - 0.08) / 0.17, 1.2);
      }
      function updateKeys(t) {
        for (const s of SCHED) {
          const cap = keys[s.id];
          let h = easeInOutCubic(seg(t, s.on, s.on + 0.2));
          if (s.off !== null) h *= 1 - easeInOutCubic(seg(t, s.off, s.off + 0.2));
          const p = pressEnv(t - s.press);
          tmp.copy(C_WHITE).lerp(C_YEL, h).lerp(C_DARK, Math.max(0, p));
          cap.material.color.copy(tmp);
          cap.userData.lineMat.color.copy(L_GREY).lerp(L_YEL, h);
          cap.position.y = KEY_Y - 0.22 * p;
        }
      }

      /* ---------- camera ---------- */
      const DIR = new THREE.Vector3(0.62, 0.78, 0.9).normalize();
      const T_CLOSED = new THREE.Vector3(0, 1.2, 0);
      const T_OPEN = new THREE.Vector3(0, 8.5, -5);
      const P2 = new THREE.Vector3(-3.2, keyPos.space.y, 0.4); // centre of the ⌥ … space span
      const P3 = new THREE.Vector3(-4.0, keyPos.K.y, -1.2); // centre of the ⇧ … K span
      const P4 = keyPos.fn.clone();
      const tgt = new THREE.Vector3();

      let lastT = 0;
      function renderAt(t) {
        lastT = t;

        // laptop pose
        laptop.position.y = -1.5 * (1 - easeOutCubic(seg(t, 0, 0.5)));
        laptop.rotation.y = 35 * DEG * (1 - easeInOutCubic(seg(t, 0.5, 1.2)));
        pivot.rotation.x = -112 * DEG * easeInOutCubic(seg(t, 1.2, 1.75));
        updateKeys(t);

        // camera
        const ek = easeInOutCubic(seg(t, 1.2, 1.75));
        const e1 = easeOutExpo(seg(t, 1.6, 3.2));
        const e2 = easeOutExpo(seg(t, 5.0, 5.8));
        const e3 = easeOutExpo(seg(t, 7.2, 8.0));
        tgt.lerpVectors(T_CLOSED, T_OPEN, ek).lerp(P2, e1).lerp(P3, e2).lerp(P4, e3);
        let dist = 150;
        dist = mix(dist, 60, e1); dist = mix(dist, 70, e2); dist = mix(dist, 30, e3);
        let ox = 0, oy = 0;
        ox = mix(ox, 0.0, e1); ox = mix(ox, 0.0, e2); ox = mix(ox, 0.06, e3);
        oy = mix(oy, -0.05, e1); oy = mix(oy, -0.03, e2); oy = mix(oy, -0.05, e3);

        camera.position.copy(tgt).addScaledVector(DIR, dist);
        camera.lookAt(tgt);
        camera.setViewOffset(W, H, ox * W, oy * H, W, H);
        renderer.render(scene, camera);
      }

      window.addEventListener("hf-seek", (e) => renderAt(e.detail.time));
      renderAt(window.__hfThreeTime || 0);

      if (document.fonts && document.fonts.load) {
        document.fonts.load('600 100px "PF"').then(() => {
          labelDraws.forEach((f) => f());
          renderAt(lastT);
        }).catch(() => {});
      }
    </script>
  </body>
</html>
```
