Zum Inhalt springen
Alle Templates

Templates/The Last Time

Fuer eine Ausstellung, eine Kampagne, ein Label oder ein Studio, das eine Seite will, die man ansieht statt liest. Es gibt keine Navigation und keine Hero-Ueberschrift: jede Szene beginnt als eine Aufnahme eines gewoehnlichen Raums, und eine kurze Zeile sagt, was es war. Nichts ist aus Freistellern zusammengesetzt, also kann bei Bewegung auch nichts auseinanderfallen: eine Szene faehrt ein bis vier Prozent hinein, driftet ein paar Pixel unter dem Zeiger, aendert die Belichtung und findet die Schaerfe, mehr Vokabular gibt es nicht. Der Zeiger ist Licht und kein Cursor, und in manchen Raeumen findet er das, worum es in der Szene geht: es erscheint nur, solange die Hand in der Naehe ist, mit einem eigenen Klang. Zehn Szenen mit eigenem Takt, neun Erinnerungen und das Zimmer von heute, von der Schaukel ueber drei Bildschirme bis zum Flur, der einfach dasteht. Durchgehend dunkel mit warmem Licht, und ein bewusst hellerer Raum am Schluss.

Editorial & KulturKomplette Seite

Die fertige Seite zum Durchscrollen, umschaltbar zwischen Desktop, Tablet und Handy.

Quellcode

Die Seite ist eine Datei, lädt aber 19 Bilder und 11 Tonspuren (7,0 MB) aus public/. Kopierst du nur den Code, fehlen sie. Das Paket enthält beides an den richtigen Pfaden.

import {
  createContext,
  Fragment,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
  type ReactNode,
} from "react";
/* THE LAST TIME. Nine memories of things that happened for the final time
   without anyone noticing, told as one scroll instead of nine stacked blocks.

   The whole page is one idea: you never know when it is the last time. So
   nothing here announces itself. There is no navigation, no hero headline, no
   chapter numbers. Every scene opens as an ordinary room, and only the
   movement and one short line say what it was.

   One picture per scene

   Each memory is a single photograph. Not a background with objects placed on
   it, not a stack of cut outs moving at different speeds: one frame that
   would already hold up as a still, and everything the scene does is done to
   that frame. This is the whole design decision and everything else follows
   from it.

   The reason is that the alternative was tried and it fails in a specific
   way. Assemble a room from eight separately produced pieces and each piece
   brings its own light, its own grain and its own lens. At rest you can
   almost get away with it. The moment they move against each other the eye
   reads the seams instantly, and what you see is not depth, it is a collage
   pretending to be a room. One photograph cannot come apart, because there is
   nothing in it to come apart.

   What a scene is allowed to do

   A slow push, one to four percent. A drift of a few pixels under the
   pointer. Exposure, contrast and warmth. Focus. A pool of light that goes
   where the hand goes. A vignette that closes. That is the whole vocabulary,
   and it is enough, because it is the vocabulary a camera has.

   Some scenes hold a second photograph of the same moment, taken from the
   same place: the drive without the bike, the path with only the child on
   it, the monitor switched off, the rug without the collar. The lift holds a
   second copy of its own frame, veiled and then cleared. Two exposures of
   one moment agree everywhere except in the one thing that changes, so the
   change can be let through where it happens and nowhere else. The bike and
   the collar stand where they were left, and when the hand goes near them
   they thin out and flicker, the photograph without them showing through a
   soft opening around them: a memory holds until you try to hold it.

   Nothing is painted onto a photograph. The conversation in the room with
   the monitor is set beside the screen, not on it; what the screen shows at
   the end is one line of type, sized against the photograph and moving with
   it, on glass that has just gone dark, because dark glass has no lens, no
   curve and no reflection that a line would have to match.

   Scroll is the timeline. Every scene is a tall section with a frame stuck to
   the top of it, and its scroll position becomes one value from 0 to 1. The
   scenes are not equally long: the hallway with the collar sits there longer
   than is comfortable, which is the point of it.

   One picture dissolves into the next, and you move while it happens. The
   scene that is ending stays where it is and grows by a few percent, so the
   scroll carries you into it rather than past it; the scene arriving fades
   up over it from slightly too close and settles. Both pictures are live and in the frame at
   once, which is what makes it a dissolve rather than a cut through a colour.

   It went through a colour band first, falling from the top, and that was
   wrong in a way worth keeping a note about: a band is a thing, and a thing
   travelling across a photograph is an effect. Two pictures occupying the
   same frame for a moment is not an effect, it is what happens when you turn
   your head.

   One loop drives the lot, and it asks the page rather than being told. Each
   scene's progress is its own section's distance past the top of the window,
   measured in the same animation frame that writes its transforms. That is
   one read and no library.

   It was GSAP's ScrollTrigger, and the reason it is not is worth writing
   down, because it went wrong twice in the same place. A scroll library
   reports a scene as active between two points and stops reporting at the
   second one, and the last scene's second point is the bottom of the page,
   which is exactly where somebody stands when they finish reading. Both
   times the symptom was the same: the page ends on a black screen with the
   last two lines missing, because the scene was frozen one frame short of
   them. Geometry cannot go stale that way. The section is where it is.

   The pointer is light, not a cursor. There is no drawn circle following the
   hand. Moving it warms one part of a room, clears the glass, opens the light
   under a door. Everything it does also happens without it, because the
   scroll position carries the same values.

   Sound is optional and waits to be asked. Drop the files named in
   audio/SOUND_ASSET_MAP.md next to the pictures and a small control appears.
   Until then the page is silent, and even with them it makes no sound until
   somebody has touched it. The long room tones and the piano are streamed
   rather than held in memory, and only the short details are decoded, so a
   visit through all ten rooms does not end with a minute of every one of
   them sitting in a phone's memory.

   The end offers to start again, and starting again means starting again.
   The page goes to black, jumps to the top while nobody can see it, and is
   built anew: every scene, every timer, the swing's momentum and every
   sound. Scrolling back up would have been the cheap version, and it plays
   the whole story backwards in two seconds with the sound of every room
   along the way, which is the opposite of the quiet it has just ended on.

   Every person, place and line of copy is invented. */

const BASIS = "/templates/the-last-time";

/* Two faces, and a rule about which line gets which.

   The first line of a memory states what happened, and it is set in a
   grotesque that does not colour it. The second line is the one that hurts,
   and it is set in an italic serif. Every scene is therefore read in the same
   two voices, and the turn from one to the other does the work that a second
   weight or a jump in size would otherwise have to do. */
const SCHRIFT = "Figtree, ui-sans-serif, system-ui, sans-serif";
const AKZENT = '"Instrument Serif", ui-serif, Georgia, serif';

/* ---------------------------------------------------------------------- */
/* Small arithmetic                                                       */
/* ---------------------------------------------------------------------- */

const klemme = (v: number, a = 0, b = 1) => (v < a ? a : v > b ? b : v);

/** 0 below `von`, 1 above `bis`, eased in between. */
function stufe(v: number, von: number, bis: number) {
  const t = klemme((v - von) / (bis - von || 1));
  return t * t * (3 - 2 * t);
}

/** A window: rises over `ein`, holds, falls over `aus`. */
function fenster(v: number, von: number, bis: number, ein = 0.16, aus = 0.16) {
  const s = bis - von;
  return stufe(v, von, von + s * ein) * (1 - stufe(v, bis - s * aus, bis));
}

const misch = (a: number, b: number, t: number) => a + (b - a) * t;

/** How much of a thing the hand is near is left, at a nearness from 0 to 1.
    At full nearness it is a little over half there, and it will not hold:
    three slow waves out of step with each other, around one to two a second,
    and now and then a short dip. Uneven on purpose, because an even pulse
    reads as a lamp with a fault rather than as something slipping. Never
    faster than that and never deeper, so it stays a flicker and not a
    strobe. */
function verblassen(naehe: number, t: number) {
  if (naehe <= 0) return 1;
  const welle =
    0.5 * Math.sin(t * 7.3) + 0.3 * Math.sin(t * 12.9 + 1.3) + 0.2 * Math.sin(t * 3.1 + 0.4);
  const einbruch = Math.pow(Math.max(0, Math.sin(t * 1.7 + Math.sin(t * 0.9) * 2)), 14);
  return klemme(1 - naehe * (0.4 + 0.12 * welle + 0.18 * einbruch), 0.3, 1);
}

/* Every style the loop writes goes through here, and is only written when it
   has changed.

   Sixty times a second, a dozen scenes' worth of pieces are told where they
   are. Most of them are told the same thing they were told a frame ago: a
   line that has not arrived yet, a picture the scroll has let go of, a scene
   that is being walked out of and has stopped moving. Assigning the same
   value again is not free. It invalidates style for that element and, on
   anything carrying a filter, asks for the filter to be run again, and at a
   handover there are two full scenes in the frame for it to be run on. That
   was the stutter at every cut. Opacity is rounded to a step nobody can see,
   so a value creeping along its last thousandth does not count as a change. */
/* Where the hand is, as far as the sound is concerned.

   Each scene has one thing in it the hand can find: the bike, a place at the
   table, the mirror, the screen, the collar. The scene knows how near the
   pointer is to it and writes that here, nought to one, under its own name.
   The sound layer reads it and lets that scene's one sound come up when the
   hand is there. `maus` says whether there is a hand at all: on a touch
   screen nothing hovers, and every sound simply plays at its own level. */
const HAND: { maus: number; naehe: Record<string, number> } = { maus: 0, naehe: {} };

type Eigenschaft = "transform" | "filter" | "opacity" | "visibility" | "willChange";
const GESCHRIEBEN = new WeakMap<HTMLElement, Partial<Record<Eigenschaft, string>>>();
function schreibe(el: HTMLElement, eigenschaft: Eigenschaft, wert: string) {
  if (eigenschaft === "opacity" && wert !== "") wert = String(Math.round(Number(wert) * 500) / 500);
  let alt = GESCHRIEBEN.get(el);
  if (!alt) GESCHRIEBEN.set(el, (alt = {}));
  if (alt[eigenschaft] === wert) return;
  alt[eigenschaft] = wert;
  el.style[eigenschaft] = wert;
}

/* ---------------------------------------------------------------------- */
/* The one loop                                                           */
/* ---------------------------------------------------------------------- */

/** A scene says what to do with its own progress and the pointer.

    `maus` rises from 0 to 1 the first time a mouse actually moves. Scenes
    that keep something hidden until it is found need it: on a phone there is
    no hand to find anything with, and a thing that can only be uncovered by
    hovering is a thing that does not exist there. */
type Regie = (p: number, zx: number, zy: number, maus: number) => void;

type Bahn = {
  anmelden: (el: HTMLElement, regie: Regie, laenge: number, letzte: boolean) => () => void;
  ruhig: boolean;
};

const BahnKontext = createContext<Bahn>({ anmelden: () => () => {}, ruhig: false });

type Eintrag = {
  el: HTMLElement;
  /** The frame stuck inside the section. */
  rahmen: HTMLElement | null;
  regie: Regie;
  /** How many viewport heights the scene plays over. */
  spanne: number;
  sichtbar: boolean;
  /** The first scene has nothing before it to come up over. */
  erste: boolean;
  /** The last scene has nothing after it, so it does not run on. */
  letzte: boolean;
};

/** How far the page moves while one picture dissolves into the next. */
const BLENDE = 0.55;

/** How far every scene but the last runs on past the point where its frame
    would come loose, as a share of a screen. It is longer than the handover
    (BLENDE plus the moment until the frame is hidden), so the frame that is
    leaving is held in place by the browser itself, stuck to the top like
    any other frame, for as long as anyone can see it. The next scene
    overlaps by the same amount more, so no scene starts a pixel later than
    before and the page is exactly as long.

    It used to be held by the loop: once the frame came loose, every frame
    moved it back down by exactly as far as the page had gone up. That only
    works while the page is scrolled by script in the same frame. Scrolled
    natively, the page moves first and the correction lands a frame later,
    and the picture that is leaving shivers at every handover. */
const NACHLAUF = 0.7;

/**
 * Collects the scenes and runs a single frame loop over the ones near the
 * viewport, giving each its progress and letting it write its own transforms.
 *
 * The loop also owns the handover, because a handover is the one thing a
 * scene cannot do on its own: it needs the picture before it and the picture
 * after it in the frame at the same time, and neither of them knows about the
 * other.
 */
function useBahn(ruhig: boolean): Bahn {
  const eintraege = useRef<Eintrag[]>([]);
  const zeiger = useRef({ x: 0, y: 0, zx: 0, zy: 0, maus: 0, gab: false });

  const anmelden = useCallback(
    (el: HTMLElement, regie: Regie, laenge: number, letzte: boolean) => {
      const eintrag: Eintrag = {
        el,
        rahmen: el.firstElementChild as HTMLElement | null,
        regie,
        spanne: laenge - 1,
        sichtbar: false,
        erste: !el.previousElementSibling?.classList.contains("tlt-szene"),
        letzte,
      };
      eintraege.current.push(eintrag);
      if (ruhig) {
        // Without motion a scene stands at its calm middle and stays there,
        // with everything in it simply present.
        regie(0.5, 0, 0, 0);
        return () => {
          eintraege.current = eintraege.current.filter((e) => e !== eintrag);
        };
      }
      /* A frame that is nowhere near the window is not drawn at all. */
      if (eintrag.rahmen) schreibe(eintrag.rahmen, "visibility", "hidden");
      /* The observer only answers one question, which is whether this scene
         is anywhere near being looked at. Everything else the loop works out
         for itself. Generous margins, because a scene that is composed only
         once it is on screen snaps into place.

         It also decides which frames get a layer of their own. A frame near
         the window keeps one for as long as it is near, so the handover
         only ever changes numbers on a layer that already exists. Given one
         at the moment the cut starts and taken away at the moment it ends,
         the browser builds and paints the whole scene again on exactly the
         frame where both pictures are moving, which is a dropped frame at
         every cut. The promotion happens a screen before anything is
         visible, where it costs nothing anyone can see. */
      const beobachter = new IntersectionObserver(
        ([e]) => {
          eintrag.sichtbar = e!.isIntersecting;
          const r = eintrag.rahmen;
          if (!r) return;
          schreibe(r, "willChange", eintrag.sichtbar ? "transform, opacity" : "");
          if (!eintrag.sichtbar) schreibe(r, "visibility", "hidden");
        },
        { rootMargin: "100% 0px" },
      );
      beobachter.observe(el);
      return () => {
        beobachter.disconnect();
        if (eintrag.rahmen) {
          schreibe(eintrag.rahmen, "visibility", "");
          schreibe(eintrag.rahmen, "opacity", "");
          schreibe(eintrag.rahmen, "transform", "");
          schreibe(eintrag.rahmen, "willChange", "");
        }
        eintraege.current = eintraege.current.filter((e) => e !== eintrag);
      };
    },
    [ruhig],
  );

  useEffect(() => {
    if (ruhig) return;
    const z = zeiger.current;
    const bewegen = (e: PointerEvent) => {
      if (e.pointerType !== "mouse") return;
      z.gab = true;
      z.x = (e.clientX / window.innerWidth) * 2 - 1;
      z.y = (e.clientY / window.innerHeight) * 2 - 1;
    };
    const zurueck = () => {
      z.x = 0;
      z.y = 0;
    };
    window.addEventListener("pointermove", bewegen, { passive: true });
    window.addEventListener("pointerleave", zurueck);

    let frame = 0;
    const takt = () => {
      frame = requestAnimationFrame(takt);
      // The pointer is eased, so a scene keeps moving for a moment after the
      // hand has stopped. A hard value reads as a slider, not as light.
      z.zx += (z.x - z.zx) * 0.06;
      z.zy += (z.y - z.zy) * 0.06;
      // Eased as well, so the first time a hand appears nothing snaps shut.
      z.maus += ((z.gab ? 1 : 0) - z.maus) * 0.04;
      HAND.maus = z.maus;
      const sicht = window.innerHeight;
      for (const e of eintraege.current) {
        if (!e.sichtbar || !e.rahmen) continue;
        const r = e.el.getBoundingClientRect();
        /* Everything is measured against the frame's own height, read off
           the section, and not against the window.

           On a phone the two are not the same. The frame is a small viewport
           tall, so it never changes size when the toolbar comes and goes;
           the window grows by the height of the toolbar once it has slid
           away. Measured against the window, every scene reached its end a
           little before its frame let go, the frame was pushed down to meet
           a window that was taller than it, and the last scene never got to
           its end at all: the page ran out at about five sixths of it, the
           last line only half there and the offer to start again never
           arriving. */
        const nach = e.letzte ? 0 : NACHLAUF;
        const hoch = r.height / (e.spanne + 1 + nach);
        // The section as the scene is timed, without its run-on.
        const eigen = r.height - nach * hoch;
        // Progress is how far the section has travelled past the top of the
        // window, up to the point where its bottom meets the bottom of the
        // window, which on the last scene is also the bottom of the page.
        const p = klemme(-r.top / Math.max(1, eigen - Math.max(hoch, sicht)));

        /* Once the bottom of a section comes up past the bottom of its
           frame, the frame has let go and would slide away with the page.
           That is the handover, and it is where the scene after this one is
           fading up over it.

           It is not allowed to slide away at the speed of the page. Its
           section runs on past this point (NACHLAUF), so it stays stuck
           exactly where it was and is only pushed in, and the scene
           arriving over it comes down out of a slightly larger frame at the
           same time. The two move against each other in depth rather
           than across the window, which is what reads as walking from one
           room into the next.

           Held exactly, and not almost. Held back to three quarters it
           drifts a little, and that little is a strip along the bottom of
           the window where the old frame no longer reaches and the new one
           is not yet opaque: a black band that appears at every handover for
           as long as it lasts. Parallax in the depth costs nothing and has
           no edge. */
        const rutsch = Math.max(0, hoch - (r.top + eigen));
        if (rutsch > 0) {
          const g = rutsch / hoch;
          /* The run-on keeps the frame stuck through all of this, so the
             loop only pushes it in. Past the run-on it would slide away,
             and by then it has long been hidden; the counter-move is kept
             only so that a frame that is still visible there, on a window
             resized mid-handover, does not jump. */
          const los = Math.max(0, rutsch - nach * hoch);
          schreibe(e.rahmen, "visibility", g > BLENDE + 0.08 ? "hidden" : "visible");
          schreibe(e.rahmen, "opacity", "1");
          schreibe(
            e.rahmen,
            "transform",
            `translate3d(0, ${los.toFixed(1)}px, 0) scale(${(1 + klemme(g / BLENDE) * 0.07).toFixed(4)})`,
          );
        } else if (r.top <= 0) {
          /* And this is the scene arriving. It comes up over the one before
             it from slightly too close, which is the other half of the same
             movement: one room is being walked out of and the other walked
             into, and for as long as both are in the frame you are between
             them. */
          /* Except the first, which has nothing to come up over and is
             simply there. Given the same dissolve it stood at nothing at the
             top of the page, where there is no scroll yet to bring it up:
             the opening line that arrives by itself arrived on a frame
             nobody could see. */
          const t = e.erste ? 1 : stufe(-r.top / hoch, 0, BLENDE);
          schreibe(e.rahmen, "visibility", "visible");
          schreibe(e.rahmen, "opacity", String(t));
          /* Written as an identity at rest rather than cleared. An empty
             transform takes the frame off its layer, and the next cut would
             have to put it back. */
          schreibe(
            e.rahmen,
            "transform",
            `translate3d(0, 0, 0) scale(${misch(1.06, 1, t).toFixed(4)})`,
          );
        } else {
          /* Still travelling up behind the scene before it. It is drawn,
             but at nothing, and already at the scale it will arrive from:
             everything in it is decoded, painted and composed while nobody
             can see it, so the first frame of the cut has nothing left to
             do but change a number. Hidden instead, all of that work landed
             on the first frame of every handover. */
          schreibe(e.rahmen, "visibility", r.top < hoch ? "visible" : "hidden");
          schreibe(e.rahmen, "opacity", "0");
          schreibe(e.rahmen, "transform", "translate3d(0, 0, 0) scale(1.06)");
        }
        e.regie(p, z.zx, z.zy, z.maus);
      }
    };
    frame = requestAnimationFrame(takt);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener("pointermove", bewegen);
      window.removeEventListener("pointerleave", zurueck);
    };
  }, [ruhig]);

  return useMemo(() => ({ anmelden, ruhig }), [anmelden, ruhig]);
}

/* ---------------------------------------------------------------------- */
/* Building blocks                                                        */
/* ---------------------------------------------------------------------- */

/**
 * A scene: a tall section with a frame stuck to the top of it.
 *
 * `laenge` is how many viewport heights the scene takes to play. It is the
 * pacing dial of the whole page and the reason the hallway feels longer than
 * the playground without anything in it moving more slowly.
 */
function Szene({
  laenge,
  regie,
  klasse,
  kinder,
  letzte = false,
}: {
  laenge: number;
  regie: Regie;
  klasse?: string;
  kinder: ReactNode;
  /** Set on the last scene only: it has nothing to hand over to. */
  letzte?: boolean;
}) {
  const bahn = useContext(BahnKontext);
  const wurzel = useRef<HTMLElement>(null);
  const halt = useRef(regie);
  halt.current = regie;

  useEffect(() => {
    const el = wurzel.current;
    if (!el) return;
    return bahn.anmelden(el, (p, x, y, m) => halt.current(p, x, y, m), laenge, letzte);
  }, [bahn, laenge, letzte]);

  return (
    <section
      ref={wurzel}
      className="tlt-szene"
      style={{ height: `${(laenge + (letzte ? 0 : NACHLAUF)) * 100}svh` }}
    >
      <div className={`tlt-rahmen ${klasse ?? ""}`}>{kinder}</div>
    </section>
  );
}

/**
 * The picture plate.
 *
 * Everything inside a scene is positioned in percentages of the photograph
 * rather than of the window, so a measurement taken off the picture once
 * stays true at every window size. The plate is the picture's own box, sized
 * to cover the frame whichever way round the window is, and the camera move
 * is written onto the plate rather than onto the picture, so anything else
 * placed inside travels with it.
 */
function Platte({
  kinder,
  ref: aussen,
  verhaeltnis,
}: {
  kinder: ReactNode;
  ref?: React.Ref<HTMLDivElement>;
  /** Width over height of the photograph. Only needed when it is not 16:9. */
  verhaeltnis?: number;
}) {
  return (
    <div
      ref={aussen}
      className="tlt-platte"
      style={verhaeltnis ? ({ "--tlt-ar": verhaeltnis } as React.CSSProperties) : undefined}
    >
      {kinder}
    </div>
  );
}

/** The photograph itself, or a second copy of it. */
function Bild({
  name,
  alt = "",
  klasse,
  stil,
  eilig,
  ref: aussen,
}: {
  name: string;
  alt?: string;
  klasse?: string;
  /** Placement, in percentages of the photograph. */
  stil?: React.CSSProperties;
  /** The first picture is fetched at once; the rest wait until they are near. */
  eilig?: boolean;
  ref?: React.Ref<HTMLImageElement>;
}) {
  return (
    <img
      ref={aussen}
      src={`${BASIS}/${name}`}
      alt={alt}
      aria-hidden={alt ? undefined : true}
      draggable={false}
      loading={eilig ? "eager" : "lazy"}
      fetchPriority={eilig ? "high" : "auto"}
      decoding="async"
      className={`tlt-bild ${klasse ?? ""}`}
      style={stil}
    />
  );
}

/** How the camera moves across a scene. Percentages are of the picture. */
type Fahrt = {
  /** Scale at the start and at the end. Never below 1.02: the drift needs room. */
  von: number;
  bis: number;
  /** Travel across the whole scene. */
  x?: number;
  y?: number;
  /** How far the pointer pushes the picture. Two to six pixels is the range. */
  zx?: number;
  zy?: number;
  /** How far the picture leans under the pointer, in degrees. */
  kipp?: number;
};

/* Half a degree, and not a tenth more.

   The picture leans away from the hand as if it were a plane in a room rather
   than a rectangle on a screen, which is what makes the drift read as depth
   instead of as a slider. It has to stay under the threshold where anyone
   could say what is happening: at half a degree the parallax across the frame
   is two or three pixels, and the picture still covers the frame at the
   smallest scale any scene uses. A whole degree is a tilting card. */
const KIPP = 0.5;

function fahre(el: HTMLElement | null, p: number, zx: number, zy: number, f: Fahrt) {
  if (!el) return;
  const s = misch(f.von, f.bis, p);
  const x = (f.x ?? 0) * p + zx * (f.zx ?? 0);
  const y = (f.y ?? 0) * p + zy * (f.zy ?? 0);
  const k = f.kipp ?? KIPP;
  /* The plate is centred on the frame, except where a scene says otherwise.

     On a phone the window is a tall slot and the photographs are wide, so
     the plate ends up about four times the width of the screen and what you
     see is a vertical strip out of the middle of each picture. The middle is
     rarely where the subject is: the swing hangs in the left third, the
     child asleep in the car is at the very edge. `--tlt-versatz` moves the
     plate sideways so the strip lands on the thing the line is about. It is
     set per scene and only on narrow screens, and it is written in the
     stylesheet rather than here because it is a fact about a photograph and
     not about the movement. */
  schreibe(
    el,
    "transform",
    `translate(calc(-50% + var(--tlt-versatz, 0%)), -50%) translate3d(${x.toFixed(3)}%, ${y.toFixed(3)}%, 0)` +
      ` rotateX(${(-zy * k).toFixed(3)}deg) rotateY(${(zx * k).toFixed(3)}deg) scale(${s.toFixed(4)})`,
  );
}

/** Exposure, contrast and warmth, written as one filter. */
function belichte(el: HTMLElement | null, hell: number, kontrast = 1, saettigung = 1, weich = 0) {
  if (!el) return;
  /* In steps of a quarter of a percent, which is under one level of an
     eight bit channel. Finer than that the value changes on every frame of
     every scroll, and each change runs the filter over the whole picture
     again for a difference no screen can show. */
  const q = (v: number) => (Math.round(v * 400) / 400).toFixed(4);
  schreibe(
    el,
    "filter",
    `brightness(${q(hell)}) contrast(${q(kontrast)}) saturate(${q(saettigung)})` +
      (weich > 0.01 ? ` blur(${weich.toFixed(1)}px)` : ""),
  );
}

type Zeile = {
  text: string;
  von: number;
  bis: number;
  klein?: boolean;
  /** Set in the serif even when it is the only line in its block. */
  akzent?: boolean;
  /** Stays once it has arrived. Only the last two lines on the page use it:
      everything else is over a photograph that is about to become another
      photograph, and a line that outlived its room would be read against the
      wrong one. These two have no room and nothing after them. */
  haelt?: boolean;
};

/**
 * A line, cut into pieces so it can arrive one piece at a time.
 *
 * The opening is cut into letters, because there is nothing else on the
 * screen there and a title can afford to be spelled out. Every other line is
 * cut into words: a memory is one short sentence over a photograph, and
 * spelling that out letter by letter would take longer to read than to think,
 * and would put a machine between the reader and the picture.
 *
 * The pieces are inline blocks, because that is the only way to give each of
 * them its own blur. For letters the cost of that is a hair of the kerning,
 * which at this size nobody sees; whole words keep their kerning entirely.
 *
 * The space between two pieces sits outside the piece, which looks like a
 * detail and is not: inside a span set not to break, a space stops being a
 * place the line can break at, and the sentence becomes one unbreakable word
 * that runs off both sides of its measure.
 */
function Stueckzeile({
  text,
  buchstaben,
  klasse,
  ref: aussen,
}: {
  text: string;
  /** Cut into letters rather than words. */
  buchstaben?: boolean;
  klasse?: string;
  ref?: React.Ref<HTMLParagraphElement>;
}) {
  const woerter = text.split(" ");
  return (
    <p ref={aussen} className={`tlt-zeile ${klasse ?? ""}`}>
      {woerter.map((w, i) => (
        <Fragment key={`${w}-${i}`}>
          {buchstaben ? (
            <span className="tlt-wort">
              {[...w].map((z, j) => (
                <span key={`${z}-${j}`} className="tlt-stueck">
                  {z}
                </span>
              ))}
            </span>
          ) : (
            <span className="tlt-stueck">{w}</span>
          )}
          {i < woerter.length - 1 ? " " : null}
        </Fragment>
      ))}
    </p>
  );
}

/* The pieces of a line, looked up once and kept.

   Asking the DOM sixty times a second for something that cannot change is
   waste, and the answer belongs to the element rather than to whichever
   scene is asking, so it is kept beside the element and not in a ref that
   every scene would have to declare. */
const STUECKE = new WeakMap<HTMLElement, HTMLSpanElement[]>();

function stuecke(el: HTMLElement) {
  let s = STUECKE.get(el);
  if (!s) {
    s = Array.from(el.querySelectorAll<HTMLSpanElement>(".tlt-stueck"));
    STUECKE.set(el, s);
  }
  return s;
}

/**
 * Brings one line in piece by piece and takes it away as a whole.
 *
 * In out of a haze, from the left, with the pieces overlapping by most of
 * their length so it reads as one movement rather than as a countdown. Out
 * softly and all at once: piece by piece on the way out as well would be a
 * second effect where the scene needs none, and would hold the line on the
 * screen long past the point where it has been read.
 */
function setzeStuecke(el: HTMLElement, t: number, haelt?: boolean) {
  const ss = stuecke(el);
  const weg = haelt ? 0 : stufe(t, 0.78, 1);
  const n = ss.length;
  for (let k = 0; k < n; k += 1) {
    const platz = n > 1 ? k / (n - 1) : 0;
    const auf = stufe(t, platz * 0.34, platz * 0.34 + 0.3);
    const st = ss[k]!;
    schreibe(st, "opacity", String(auf * (1 - weg)));
    /* Sharp means no filter at all rather than a blur of nothing. A zero
       blur is still a filter, and every word on the page carrying one keeps
       every word on its own offscreen surface for as long as it is there. */
    const unschaerfe = (1 - auf) * 7 + weg * 4;
    schreibe(st, "filter", unschaerfe < 0.05 ? "none" : `blur(${unschaerfe.toFixed(1)}px)`);
  }
}

/**
 * Types a message into a bubble.
 *
 * Hard on, no blur, one letter at a time. The lines over the photographs
 * drift in out of a haze because they are the page thinking; a message is
 * somebody at a keyboard, and the difference between the two has to be
 * visible without anybody being told about it.
 *
 * There was a caret standing against the last letter that had landed, drawn
 * as a box shadow so it cost no layout. It went, because a caret belongs to
 * the field a message is written in and not to the message once it has been
 * sent, and because drawn on a space it had nothing to stand against and
 * read as a stray mark floating between two words.
 */
function tippe(el: HTMLElement, t: number) {
  const ss = stuecke(el);
  const kopf = t * ss.length;
  for (let k = 0; k < ss.length; k += 1) {
    schreibe(ss[k]!, "opacity", k < kopf ? "1" : "0");
  }
}

function Zeilen({
  zeilen,
  refs,
  stelle = "unten",
  klasse,
}: {
  zeilen: Zeile[];
  refs: React.MutableRefObject<(HTMLParagraphElement | null)[]>;
  stelle?: "unten" | "mitte";
  klasse?: string;
}) {
  return (
    <div className={`tlt-text tlt-text-${stelle}${klasse ? ` ${klasse}` : ""}`}>
      {zeilen.map((z, i) => (
        <Stueckzeile
          key={z.text}
          text={z.text}
          /* Odd lines take the serif. The lines are never on screen together
             for long, so this is not two faces in one block of text: it is
             the same sentence answered in a different voice. */
          klasse={
            (z.klein ? "tlt-zeile-klein" : "") +
            (i % 2 === 1 || z.akzent ? " tlt-zeile-akzent" : "")
          }
          ref={(el: HTMLParagraphElement | null) => {
            refs.current[i] = el;
          }}
        />
      ))}
    </div>
  );
}

/** Writes the line windows. Called from every scene's director. */
function setzeZeilen(
  refs: React.MutableRefObject<(HTMLParagraphElement | null)[]>,
  zeilen: Zeile[],
  p: number,
) {
  for (let i = 0; i < zeilen.length; i += 1) {
    const el = refs.current[i];
    if (!el) continue;
    const z = zeilen[i]!;
    const t = klemme((p - z.von) / (z.bis - z.von || 1));
    setzeStuecke(el, t, z.haelt);
    schreibe(el, "transform", `translate3d(0, ${((1 - stufe(t, 0, 0.34)) * 10).toFixed(2)}px, 0)`);
  }
}

/* ---------------------------------------------------------------------- */
/* Styles                                                                 */
/* ---------------------------------------------------------------------- */

const STILE = `
.the-last-time {
  --tlt-schwarz: #08090b;
  --tlt-schrift: #efeae2;
  position: relative;
  width: 100%;
  background: var(--tlt-schwarz);
  color: var(--tlt-schrift);
  font-family: ${SCHRIFT};
  /* clip and not hidden: hidden would turn this box into a scroll container
     and every frame stuck to the top inside it would stick to the box rather
     than to the window. */
  overflow-x: clip;
}
.the-last-time *,
.the-last-time *::before,
.the-last-time *::after { box-sizing: border-box; }

.the-last-time .tlt-szene { position: relative; width: 100%; }
/* Every scene but the first starts one screen before the one in front of it
   ends, and that one screen is exactly the stretch in which a frame stuck to
   the top comes loose and travels away.

   Without the overlap you watch it go: the old scene slides up the screen,
   nothing has arrived to take its place, and for a full screen of scrolling
   the page is half a memory over a black band and then black. It reads as a
   page that has broken rather than as a cut.

   Overlapped, the next frame pins at the top at the exact moment the one
   before it would let go, covers it, and the old scene only slides away
   underneath once nobody can see it. It sits on top by tree order alone:
   both frames are stuck, neither is raised, and the later one paints last.

   The overlap is one screen plus the run-on (NACHLAUF, 0.7 of a screen),
   because every scene but the last is that much longer: its frame stays
   stuck through the whole handover instead of being held there by script. */
.the-last-time .tlt-szene + .tlt-szene { margin-top: -170svh; }
.the-last-time .tlt-rahmen {
  position: sticky;
  top: 0;
  height: 100svh;
  width: 100%;
  overflow: hidden;
  background: var(--tlt-schwarz);
  /* Deep enough that the lean under the pointer is a lean and not a fold.
     At this distance half a degree moves the far edge of the picture by two
     pixels, which is inside the margin every scene has from its own scale. */
  perspective: 2200px;
}

/* The picture's own box. Wide enough to cover the frame whichever way round
   the window is, so a position given in percent of the photograph lands on
   the same spot in the photograph at any size. */
.the-last-time .tlt-platte {
  position: absolute;
  left: 50%;
  top: 50%;
  width: max(100%, calc(100svh * var(--tlt-ar, 1.7855)));
  aspect-ratio: var(--tlt-ar, 1.7855);
  transform: translate(-50%, -50%);
  will-change: transform, filter;
  /* So the type drawn on the screen in scene six can be sized against the
     photograph rather than against the window. */
  container-type: inline-size;
}
.the-last-time .tlt-bild {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  pointer-events: none;
  user-select: none;
}

/* A pool of warm light that goes where the hand goes. Screen blending adds
   light to what is already there instead of laying a colour over it, which
   is the difference between a lamp and a filter. */
.the-last-time .tlt-licht {
  position: absolute;
  z-index: 14;
  width: 130%;
  height: 170%;
  transform: translate(-50%, -50%);
  pointer-events: none;
  mix-blend-mode: screen;
  opacity: 0;
  will-change: transform, opacity;
}
/* Closes the corners so the eye stays where the light is. */
.the-last-time .tlt-flor {
  position: absolute;
  inset: 0;
  z-index: 16;
  pointer-events: none;
  background: radial-gradient(ellipse 78% 74% at 50% 48%, transparent 46%, rgba(4, 5, 7, 0.55) 100%);
}

/* --- Type ------------------------------------------------------------- */

.the-last-time .tlt-text {
  position: absolute;
  z-index: 40;
  left: 6vw;
  right: 6vw;
  pointer-events: none;
}
/* Most lines on the page are centred and sit in the lower third rather than
   along the bottom edge. Down at the edge a line reads as a caption to the
   picture; a third of the way up it is part of it, and there is still enough
   photograph above it that it is not standing in the middle of the subject. */
.the-last-time .tlt-text-unten { bottom: 28vh; text-align: center; }
/* The hands fill the middle of their picture from the grip down to the
   knuckles, which is exactly the height the lines sit at everywhere else.
   Lower, on the bedding under them, the line is read after the hands and
   not across them. */
.the-last-time .tlt-hands .tlt-text-unten { bottom: 11vh; }
/* The two of them are small and in the middle of the frame, at exactly the
   height the lines usually sit at. Below them is out of focus grass in
   shadow, and nothing there needs to be seen. */
.the-last-time .tlt-walk .tlt-text-unten { bottom: 8vh; }

/* Three rooms are not centred.

   A line in the same place in every room stops being part of any of them
   and becomes the page's caption style. The drive, the bedroom and the
   hallway each have a large empty dark area beside their subject, and the
   line is set into it the way a title is set into a film still: left
   aligned, starting where the dark starts, at a height that belongs to the
   thing in the picture.

   The places are given in the photograph's own coordinates. The plate is
   as wide as the window or as wide as the window is tall times the
   photograph's proportions, whichever is more, and is centred; the same
   arithmetic here puts a line on the same spot in the picture at any
   window size. The line stays outside the plate so it is not dimmed with
   the room's exposure or softened by the camera's push. */
.the-last-time .tlt-text-frei { text-align: left; }
.the-last-time .tlt-text-frei .tlt-zeile { margin-left: 0; margin-right: 0; }
/* Right of the bike, level with the handlebars, on the hedge in shadow:
   the one part of the drive the low sun does not reach. It stays clear of
   the opening the bike comes back through. */
.the-last-time .tlt-bike .tlt-text-frei {
  --tlt-breit: min(28vw, 400px);
  width: var(--tlt-breit);
  right: auto;
  bottom: auto;
  left: min(calc(50% + 0.13 * max(100%, 178.44svh)), calc(95% - var(--tlt-breit)));
  top: calc(50% - 0.14 * max(100%, 56.04vw));
}
/* In the window, where the conversation was. The glass is the darkest and
   emptiest thing in the room, the messages have just left it, and the line
   takes their place rather than finding one of its own. */
.the-last-time .tlt-net .tlt-text-frei {
  --tlt-breit: min(26vw, 380px);
  width: var(--tlt-breit);
  right: auto;
  bottom: auto;
  left: min(calc(50% + 0.09 * max(100%, 178.55svh)), calc(95% - var(--tlt-breit)));
  top: calc(50% - 0.2 * max(100%, 56.01vw));
}
/* Low on the left, on the floorboards in shadow, at about the height of a
   dog lying by the door. The collar comes back to the right of it and
   below, on the rug, and the line never stands where it does. */
.the-last-time .tlt-hall .tlt-text-frei {
  --tlt-breit: min(26vw, 360px);
  width: var(--tlt-breit);
  right: auto;
  bottom: auto;
  left: max(6vw, calc(50% - 0.44 * max(100%, 178.44svh)));
  top: calc(50% + 0.09 * max(100%, 56.04vw));
}
/* The questions of the last room stand in one place in the middle of it,
   one replacing the next, the way the opening lines do. Lower down they
   were read against the table; here they are read against the room. */
.the-last-time .tlt-text-stapel { display: grid; }
.the-last-time .tlt-text-stapel > * { grid-area: 1 / 1; align-self: center; }
.the-last-time .tlt-text-mitte {
  top: 50%;
  transform: translateY(-50%);
  text-align: center;
}
.the-last-time .tlt-zeile {
  margin: 0 auto 0.55em;
  /* Wide enough that every line on the page stands on one line at any
     ordinary window width, and no wider: a sentence broken across two lines
     has to be read twice, once to see the shape and once for the sense, and
     these are short enough that they never need to be. */
  max-width: 56ch;
  font-size: clamp(17px, 1.55vw, 25px);
  font-weight: 400;
  line-height: 1.34;
  letter-spacing: -0.012em;
  text-wrap: balance;
  /* The type sits on photographs that are dark but not evenly dark. A very
     soft, very wide shadow keeps it readable without turning into a label. */
  text-shadow: 0 1px 26px rgba(0, 0, 0, 0.72);
}
/* The serif is set a size up from the grotesque and no more.

   It needs something, because its x height is much smaller and at the same
   size it would read as the quieter of the two, which is the wrong way
   round. But only something: far enough apart and the two lines stop being
   one voice answering another and become a headline with a subtitle. Two
   points of difference is the whole adjustment. It also has no business
   carrying the grotesque's negative tracking. */
.the-last-time .tlt-zeile-akzent {
  font-family: ${AKZENT};
  font-style: italic;
  font-size: clamp(19px, 1.74vw, 28px);
  line-height: 1.26;
  letter-spacing: 0;
}
.the-last-time .tlt-zeile-klein {
  font-size: clamp(14px, 1.1vw, 17px);
  color: rgba(239, 234, 226, 0.66);
}
.the-last-time .tlt-zeile-klein.tlt-zeile-akzent { font-size: clamp(16px, 1.26vw, 20px); }
/* The room goes out under the last question, not over it. Every line in
   the last scene sits above the black: below it, the question was being
   dimmed by the dark while it was still arriving, grey by the time it had
   been read, and the two lines after it stand on the black anyway. */
.the-last-time .tlt-text-stapel,
.the-last-time .tlt-text-schluss { z-index: 54; }
.the-last-time .tlt-hinweis {
  position: absolute;
  bottom: 9vh;
  left: 0;
  right: 0;
  margin: 0;
  text-align: center;
  font-size: 12px;
  letter-spacing: 0.34em;
  text-transform: uppercase;
  color: rgba(239, 234, 226, 0.4);
}
.the-last-time .tlt-intro { background: #050507; }
/* Whichever line is up sits in the middle of the frame. Stacked under each
   other, the first line would sit high and the last one low, and a title
   that wanders down the screen is a list. */
.the-last-time .tlt-intro .tlt-text-mitte { display: grid; }
.the-last-time .tlt-intro .tlt-text-mitte > * { grid-area: 1 / 1; align-self: center; }
.the-last-time .tlt-zeichenzeile { margin-bottom: 0; }
.the-last-time .tlt-wort { white-space: nowrap; }
/* A letter or a whole word, depending on which line it belongs to. Inline
   block so that each one can carry its own blur. */
.the-last-time .tlt-stueck {
  display: inline-block;
  opacity: 0;
  filter: blur(7px);
  will-change: opacity, filter;
}
/* The type is white everywhere, including over the two daylight rooms.

   Those two are exposed down about a stop, which is where the readability
   there actually comes from. It was tempting to put a veil behind the line
   instead and leave the rooms bright, and that is the one thing that must
   not happen: a soft dark patch sitting behind a line over a photograph is
   visible as a patch, it moves with the text rather than with the room, and
   the eye reads it as a mistake in the picture. Darkening the whole frame is
   a decision about light. A shape behind the words is a smudge.

   What is left for the shadow is holding the edge of the letterform, which
   matters most for the serif, whose thin strokes are a hair wide. */
.the-last-time .tlt-hands .tlt-zeile,
.the-last-time .tlt-now .tlt-zeile {
  text-shadow: 0 2px 30px rgba(22, 14, 6, 0.7), 0 1px 3px rgba(22, 14, 6, 0.62);
}

/* The black the last two lines are read on. The only flat colour left on
   the page: everywhere else one photograph dissolves straight into the next,
   and the end is the one place where there is no next photograph. */
.the-last-time .tlt-schwarz {
  position: absolute;
  inset: 0;
  z-index: 50;
  background: var(--tlt-schwarz);
  opacity: 0;
  pointer-events: none;
}
/* What is behind the page is the same black as the page.

   Pulled past its end, on a trackpad or a phone, a page springs back, and
   for that moment the browser shows whatever colour the document itself
   has. Left to the host page that was white: the story ended on black and
   then flashed a white band under it every time somebody reached the
   bottom and pulled once more, which is exactly what people do at the end
   of something. */
html:has(.the-last-time),
body:has(.the-last-time) { background: #08090b; }
/* The curtain for starting again. Above everything, the grain and the
   sound control included, because while it is closed the page underneath
   is jumping to its top and being built anew, and none of that is meant to
   be seen. */
.the-last-time .tlt-vorhang {
  position: fixed;
  inset: 0;
  z-index: 96;
  background: var(--tlt-schwarz);
  opacity: 0;
  pointer-events: none;
}

/* --- 01 swing / 02 bike ----------------------------------------------- */

/* Two scenes are not one photograph but two: the room without the thing in
   it, and the thing. That is a compromise and it is only worth it where the
   thing has to move on its own or decide whether it is there at all.

   What makes it hold is that the frame with the thing already in it is never
   shown. The plate and the layer together are their own picture, so there is
   nothing to cut back to and nothing that can pop. The swing hangs from
   above the top edge, so its chains are cut by the canopy rather than ending
   in the air. */
.the-last-time .tlt-teil {
  inset: auto;
  height: auto;
  object-fit: contain;
  z-index: 6;
  will-change: transform;
}
/* The pivot is the branch, which is above the frame. Turning about the top
   of the layer is close enough to it that a third of a degree reads as wind
   at the seat and as nothing at all at the chains. */
.the-last-time .tlt-swing-teil { transform-origin: 50% 0%; }
/* A second photograph of the same place with the thing taken out of it, or
   changed. Stretched rather than cropped to the frame: the two were made at
   very slightly different proportions, and filling the box exactly is what
   puts every edge of one on the same edge of the other. It starts out not
   there. */
.the-last-time .tlt-leer {
  object-fit: fill;
  opacity: 0;
  will-change: opacity;
}
/* The second photograph of the path covers the whole frame and starts
   out not there at all. */
.the-last-time .tlt-weg-allein { opacity: 0; }
/* The drive and the hallway: the photograph without the thing lies
   underneath, stretched into the same box as the one with it, and the one
   with it lies on top, whole around the thing and let go at a soft edge.
   The thing is there. Only where the hand comes near does the top one thin
   out, so the empty room shows through it. Both fill the box the same way,
   so the soft edge of the opening is two agreeing photographs meeting, not
   an edge. */
.the-last-time .tlt-gestreckt { object-fit: fill; }
.the-last-time .tlt-ding {
  object-fit: fill;
  opacity: 1;
  will-change: opacity;
}
/* Around the bike and the shadow it throws to the left, with the solid
   part of the mask wide enough to take both wheels whole. Any narrower and
   the rims stand at the soft edge, half there, as two faint rings. */
.the-last-time .tlt-rad-ding {
  -webkit-mask-image: radial-gradient(ellipse 19% 34% at 45.5% 59%, #000 72%, transparent 100%);
  mask-image: radial-gradient(ellipse 19% 34% at 45.5% 59%, #000 72%, transparent 100%);
}

/* --- 03 table --------------------------------------------------------- */

/* A memory that surfaces in the photograph rather than on it.

   These are separate exposures of the same room, and shown plainly they
   would be what this rebuild exists to avoid: a picture laid over a picture.
   Three things stop that. The edge never exists, because a wide feathered
   mask dissolves it long before it reaches the frame. The focus never
   matches, because a trace stays softer than the room it appears in. And it
   never gets above half strength, so the table underneath is visible through
   it the whole time. What is left is a double exposure, which is what a
   memory of a room looks like when it arrives in the room. */
.the-last-time .tlt-spur {
  position: absolute;
  transform: translate(-50%, -50%);
  opacity: 0;
  z-index: 12;
  height: auto;
  inset: auto;
  object-fit: contain;
  mask-image: radial-gradient(ellipse 48% 44% at 50% 50%, #000 34%, rgba(0, 0, 0, 0.6) 62%, transparent 92%);
  -webkit-mask-image: radial-gradient(ellipse 48% 44% at 50% 50%, #000 34%, rgba(0, 0, 0, 0.6) 62%, transparent 92%);
  will-change: opacity, filter;
}

/* --- 05 lift ---------------------------------------------------------- */

/* The same photograph a second time, blurred and darkened, sitting over the
   mirror. It is the glass between you and the people in it, and it clears as
   the scene goes on. Both copies are one file at one transform, so there is
   nothing that can drift apart. */
.the-last-time .tlt-lift-schleier {
  filter: blur(9px) brightness(0.72) saturate(0.7);
  mask-image: radial-gradient(ellipse 26% 27% at 59% 37%, #000 42%, rgba(0, 0, 0, 0.7) 72%, transparent 100%);
  -webkit-mask-image: radial-gradient(ellipse 26% 27% at 59% 37%, #000 42%, rgba(0, 0, 0, 0.7) 72%, transparent 100%);
  will-change: opacity;
}
/* The tube over the door, at the moment it gives up.

   A tube on its way out does not strobe. It sags, recovers, sags again, a
   little out of rhythm, and it never lights the room more than it already
   was. So the light it adds is faint and only ever falls back from there:
   something you notice in the second half of the line, not something that
   takes the frame away from it. */
.the-last-time .tlt-lift-flimmer {
  position: absolute;
  inset: 0;
  z-index: 20;
  pointer-events: none;
  background: linear-gradient(180deg, rgba(228, 240, 255, 0.22) 0%, rgba(228, 240, 255, 0) 30%);
  opacity: 0;
}

/* --- 06 the screen ---------------------------------------------------- */

/* The same room a moment later, with the screen off. The two photographs
   differ in nothing but the glass, so the whole frame can be laid over the
   first without anything else in the room moving. */
.the-last-time .tlt-netz-off { opacity: 0; }
/* The line on the glass. It sits inside the plate, so it moves with the
   photograph and is sized against it rather than against the window: the
   screen is fifteen percent of the picture wide at any window size, and so
   is the line. Two short lines rather than one long one, because the glass
   is small and square. Two classes deep so the narrow-screen placement of
   ordinary lines, which is measured against the window, never reaches it. */
.the-last-time .tlt-text.tlt-auf-schirm {
  left: 43.2%;
  right: auto;
  width: 12.6%;
  top: 52%;
  bottom: auto;
  z-index: 18;
}
.the-last-time .tlt-auf-schirm .tlt-zeile {
  max-width: none;
  font-size: 1.28cqw;
  line-height: 1.22;
  text-shadow: none;
}

/* Nothing is drawn onto the glass.

   The tube keeps whatever it was showing when the photograph was taken, and
   it keeps it to the end: it used to be put out with a patch of dark at the
   close, and a soft dark blob settling over a lit screen does not read as a
   monitor switching off, it reads as a stain on the photograph. The room
   loses its light the way every other room on the page does, through the
   exposure and then the band. All that is left here is the glow, which is
   real light in the picture and answers the hand. */
.the-last-time .tlt-schirm-schein {
  position: absolute;
  inset: 0;
  z-index: 17;
  pointer-events: none;
  background: radial-gradient(ellipse 12% 19% at 49.5% 52%, rgba(176, 208, 240, 0.4) 0%, rgba(140, 180, 220, 0.12) 44%, transparent 74%);
  mix-blend-mode: screen;
  will-change: opacity;
}

/* The last exchange in the thread, typed out where you can read it.

   It sits on the window, the darkest thing in the room, rather than on the
   monitor. Over a
   photograph that reads as the page remembering the conversation, not as a
   screenshot of it, which is the only honest thing it can be: put it on the
   glass and it has to match a lens, a curve and a reflection at once, and a
   pixel of miss turns the whole room into a composite.

   Each message has a slot of its own. The dots sit on top of the message
   that has not arrived yet, which is already holding its space, so nothing
   moves when one becomes the other. Chat windows have done this since chat
   windows existed and it is the only part of the convention worth keeping:
   no window chrome, no avatars, no timestamps, nothing that would date it to
   one product. A shape, a side, and a corner. */
.the-last-time .tlt-chat {
  position: absolute;
  z-index: 24;
  left: 75%;
  top: 29%;
  transform: translate(-50%, -50%);
  width: min(330px, 56vw);
  display: flex;
  flex-direction: column;
  gap: 10px;
  pointer-events: none;
}
.the-last-time .tlt-schlitz { position: relative; display: flex; }
.the-last-time .tlt-links { justify-content: flex-start; }
.the-last-time .tlt-rechts { justify-content: flex-end; }
/* The last slot holds nothing but the answer that never came, so it has to
   be given the height a message would have had. */
.the-last-time .tlt-schlitz-leer { min-height: 34px; }
.the-last-time .tlt-blase {
  max-width: 88%;
  padding: 9px 13px;
  border-radius: 12px;
  background: rgba(228, 234, 242, 0.94);
  color: #1a212a;
  font-size: clamp(14px, 1.02vw, 16px);
  line-height: 1.34;
  opacity: 0;
  will-change: opacity;
}
/* One square corner, on the side the message came from. It is the whole
   difference between a rounded box with text in it and something somebody
   said. */
.the-last-time .tlt-links .tlt-blase { border-bottom-left-radius: 3px; }
.the-last-time .tlt-rechts .tlt-blase { border-bottom-right-radius: 3px; }
.the-last-time .tlt-blase-eigen {
  background: rgba(58, 104, 158, 0.95);
  color: #f2f7fc;
}
/* Typed, not faded. The letters of a message do not drift in out of a haze
   the way the lines over the photographs do: somebody is at a keyboard, and
   the caret sitting against the last letter is the only thing that says so. */
.the-last-time .tlt-blase .tlt-stueck {
  filter: none;
  white-space: pre;
}
.the-last-time .tlt-tippt {
  position: absolute;
  top: 0;
  display: flex;
  align-items: center;
  gap: 5px;
  padding: 12px 13px;
  border-radius: 12px;
  background: rgba(228, 234, 242, 0.94);
  opacity: 0;
}
.the-last-time .tlt-links .tlt-tippt { left: 0; border-bottom-left-radius: 3px; }
.the-last-time .tlt-rechts .tlt-tippt { right: 0; border-bottom-right-radius: 3px; }
.the-last-time .tlt-tippt span {
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: rgba(26, 33, 42, 0.5);
  animation: tlt-tippen 1.3s ease-in-out infinite;
}
.the-last-time .tlt-tippt span:nth-child(2) { animation-delay: 0.16s; }
.the-last-time .tlt-tippt span:nth-child(3) { animation-delay: 0.32s; }
@keyframes tlt-tippen {
  0%, 60%, 100% { opacity: 0.32; }
  30% { opacity: 1; }
}

/* --- 07 hallway ------------------------------------------------------- */

/* The collar where it was left, with the shadow it throws to the left: the
   opening is wide enough to take both whole. A tight one left the collar
   standing in the soft part of the edge as a faint ghost. */
.the-last-time .tlt-flur-ding {
  -webkit-mask-image: radial-gradient(ellipse 27% 15% at 40% 77.5%, #000 74%, transparent 100%);
  mask-image: radial-gradient(ellipse 27% 15% at 40% 77.5%, #000 74%, transparent 100%);
}

/* --- 09 hands / 10 now ------------------------------------------------ */

/* Quieter than the two lines above it. No border and no box: a page that
   has just finished saying something should not end on a control with a
   frame round it. Underlined on approach, which is what a link looks like
   when it is asked to be a link and nothing more. */
.the-last-time .tlt-nochmal {
  position: absolute;
  z-index: 60;
  left: 50%;
  bottom: 7vh;
  transform: translateX(-50%);
  padding: 6px 4px;
  border: 0;
  background: transparent;
  color: rgba(239, 234, 226, 0.4);
  font: inherit;
  font-size: 11px;
  letter-spacing: 0.26em;
  text-transform: uppercase;
  cursor: pointer;
  opacity: 0;
  /* Not there until the scene says so. A scene only speaks while it is
     near the window, and until then an invisible link would still take a
     click. */
  pointer-events: none;
  transition: color 0.5s ease;
}
.the-last-time .tlt-nochmal::after {
  content: "";
  display: block;
  margin-top: 5px;
  height: 1px;
  background: currentColor;
  transform: scaleX(0.001);
  transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.the-last-time .tlt-nochmal:hover,
.the-last-time .tlt-nochmal:focus-visible { color: rgba(239, 234, 226, 0.78); }
.the-last-time .tlt-nochmal:hover::after,
.the-last-time .tlt-nochmal:focus-visible::after { transform: none; }

/* --- Frame furniture --------------------------------------------------- */

/* Grain as specks of light and dark on nothing, laid on normally.

   Not blended: a blend mode on a layer the size of the window makes the
   browser render everything underneath it into a buffer of its own on every
   frame, only to mix it with a texture that never changes, and at a
   handover that is two whole scenes. Not fractal noise either: that is
   built on a lattice, and on a flat dark surface looked at closely the
   lattice shows as a pattern of small squares. The texture is plain random
   noise made once in the page, see useKorn, in the screen's own pixels. */
.the-last-time .tlt-korn {
  position: fixed;
  inset: 0;
  z-index: 90;
  pointer-events: none;
}
.the-last-time .tlt-ton {
  position: fixed;
  z-index: 92;
  right: 22px;
  bottom: 20px;
  padding: 8px 14px;
  border: 1px solid rgba(239, 234, 226, 0.22);
  border-radius: 7px;
  background: rgba(8, 9, 11, 0.5);
  backdrop-filter: blur(6px);
  color: rgba(239, 234, 226, 0.66);
  font: inherit;
  font-size: 11px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  cursor: pointer;
}

@media (max-width: 820px) {
  /* Where the strip has to land in each photograph, as a share of the
     plate's own width. Positive moves the picture right, which brings
     something on its left half into the middle of the screen. Measured off
     each frame rather than guessed: the swing sits at about a third across,
     the bike at two thirds, the child in the car at a fifth. */
  .the-last-time .tlt-swing .tlt-platte { --tlt-versatz: 18%; }
  .the-last-time .tlt-bike .tlt-platte { --tlt-versatz: 4%; }
  .the-last-time .tlt-walk .tlt-platte { --tlt-versatz: 4%; }
  .the-last-time .tlt-lift .tlt-platte { --tlt-versatz: -9%; }
  .the-last-time .tlt-net .tlt-platte { --tlt-versatz: 1%; }
  .the-last-time .tlt-car .tlt-platte { --tlt-versatz: 8%; }
  .the-last-time .tlt-now .tlt-platte { --tlt-versatz: 10%; }

  /* Lower than on a wide screen, and for the opposite reason. There the
     line moves up out of the way of the photograph; here the photograph is
     a tall strip whose subject fills the middle, and the quiet part of it is
     the bottom.

     In small viewport units, like the frame itself. The frame is exactly
     one small viewport tall, so a line measured in the same unit keeps its
     place in the picture when a phone's toolbar comes and goes, and never
     ends up behind it. */
  .the-last-time .tlt-text { left: 7vw; right: 7vw; bottom: 17svh; }
  /* A line in the middle is placed by its top alone. Given a bottom as well
     it became a box a third of the screen tall, and the two last lines,
     which are not a grid, stood at the top of that box, a sixth of the
     screen above where the questions before them had been. */
  .the-last-time .tlt-text-mitte { bottom: auto; }
  /* Each room on its own, measured on phones from 360 to 430 wide. On a
     phone the strip of each photograph is mostly its subject, so the line
     goes to whichever end of the strip is dark and empty, and a little
     clear of the sound control in the corner. */
  /* The seat hangs at three quarters of the height, exactly where the
     line stood. Below it the ground is in shadow. */
  .the-last-time .tlt-swing .tlt-text-unten { bottom: 10svh; }
  /* The table is the lit part of the room. Under its edge the room is
     almost black. */
  .the-last-time .tlt-table .tlt-text-unten { bottom: 10svh; }
  /* Two handrails cross the strip as bright bars between three fifths and
     three quarters of the height, and under them is dark panelling with
     room for one line and no more. So the two lines take turns in the same
     place there, the way the opening's do, instead of standing one above
     the other with the first of them on the rail. */
  .the-last-time .tlt-lift .tlt-text-unten { bottom: 11svh; display: grid; }
  .the-last-time .tlt-lift .tlt-text-unten > * { grid-area: 1 / 1; align-self: end; }
  /* The strip of the bed is pale from top to bottom, palest in a band
     just under the hands. Both lines take turns at the foot of the frame,
     where the bedding falls into its own shade, rather than the first one
     standing in that band. */
  .the-last-time .tlt-hands .tlt-text-unten { bottom: 10svh; display: grid; }
  .the-last-time .tlt-hands .tlt-text-unten > * { grid-area: 1 / 1; align-self: end; }
  /* The drive keeps its composition on a phone: the strip is the bike and
     the garage door above it, and the door is dark. Left aligned there,
     with the bike coming back underneath. */
  .the-last-time .tlt-bike .tlt-text-frei {
    left: 8vw;
    right: 8vw;
    width: auto;
    top: 21svh;
  }
  /* The window is not in the strip on a phone, only the lamp and the
     monitor, so the line goes back to the middle, onto the wall above the
     lamp. */
  .the-last-time .tlt-net .tlt-text-frei {
    left: 7vw;
    right: 7vw;
    width: auto;
    top: 13svh;
    text-align: center;
  }
  .the-last-time .tlt-net .tlt-text-frei .tlt-zeile { margin-left: auto; margin-right: auto; }
  /* On a phone the hallway is the door frame, pale from top to bottom,
     with a band of dark floor under it just above the rug. The line lies on
     that band, left aligned as on a wide screen, and the collar comes back
     underneath it. */
  .the-last-time .tlt-hall .tlt-text-frei {
    left: 8vw;
    right: 8vw;
    width: auto;
    top: 63svh;
  }
  /* A strip of a daylight room is brighter than the whole of one, because
     the strip is mostly the lit part. The two bright scenes get their
     shadow back up here. */
  .the-last-time .tlt-hands .tlt-zeile,
  .the-last-time .tlt-now .tlt-zeile {
    text-shadow:
      0 2px 30px rgba(22, 14, 6, 0.8),
      0 2px 10px rgba(22, 14, 6, 0.7),
      0 1px 2px rgba(22, 14, 6, 0.8);
  }
  .the-last-time .tlt-zeile { font-size: clamp(17px, 4.6vw, 23px); max-width: 26ch; }
  .the-last-time .tlt-zeile-akzent { font-size: clamp(19px, 5.1vw, 26px); }
  .the-last-time .tlt-zeile-klein.tlt-zeile-akzent { font-size: clamp(16px, 4.2vw, 20px); }
  /* On a wide screen the messages sit in the dark half of the room beside
     the monitor. A narrow window has no half beside it: the monitor fills
     the strip, and left where they were the messages land on the glass,
     which is the one place on this page they must never be. So they go
     above it, into the dark part of the room over the desk. */
  .the-last-time .tlt-chat {
    left: 50%;
    top: 17%;
    width: min(330px, 78vw);
  }
}

/* --- Reduced motion ---------------------------------------------------- */

/* Nothing travels. Every scene stands at the moment it was built around,
   with its lines already there, so the whole thing can be read as a sequence
   of stills. */
.the-last-time.tlt-ruhig .tlt-rahmen { position: relative; height: 82svh; }
/* The opening has nothing in it but three lines and does not need a whole
   screen to hold them. */
.the-last-time.tlt-ruhig .tlt-intro { height: 48svh; }
.the-last-time.tlt-ruhig .tlt-szene { height: auto !important; }
/* The overlap exists to hide a frame coming loose, and here no frame is
   stuck in the first place. Left in, every scene would sit a screen inside
   the one before it. */
.the-last-time.tlt-ruhig .tlt-szene + .tlt-szene { margin-top: 0; }
.the-last-time.tlt-ruhig .tlt-zeile { transform: none !important; }
/* Without motion all three lines stand at once, so they go back under each
   other and the letters are simply there. */
.the-last-time.tlt-ruhig .tlt-intro .tlt-text-mitte { display: block; }
.the-last-time.tlt-ruhig .tlt-text-stapel,
.the-last-time.tlt-ruhig .tlt-lift .tlt-text-unten,
.the-last-time.tlt-ruhig .tlt-hands .tlt-text-unten { display: block; }
/* The same goes for the last room, where the three questions and the two
   lines after them are all there at once. Both blocks stood in the middle
   and were printed over each other. The questions go up into the room and
   the two lines stand below them, where the black would have been. */
.the-last-time.tlt-ruhig .tlt-now .tlt-text-stapel { top: 40%; }
.the-last-time.tlt-ruhig .tlt-now .tlt-text-schluss { top: 70%; }
.the-last-time.tlt-ruhig .tlt-zeichenzeile { margin-bottom: 0.55em; }
.the-last-time.tlt-ruhig .tlt-stueck { opacity: 1 !important; filter: none !important; }
.the-last-time.tlt-ruhig .tlt-schwarz,
.the-last-time.tlt-ruhig .tlt-lift-flimmer,
.the-last-time.tlt-ruhig .tlt-tippt { display: none; }
/* Everything has been said by the time a still is taken of it. */
.the-last-time.tlt-ruhig .tlt-blase { opacity: 1 !important; }
.the-last-time.tlt-ruhig .tlt-blase { transform: none !important; }
.the-last-time.tlt-ruhig .tlt-lift-schleier { opacity: 0 !important; }
/* Without a pointer there is nothing to pick a place at the table, so one
   of the four stands for all of them. Four at once is a double exposure
   four times over, which is not a room any more. */
/* Nothing is thinned out by hand here, because there is no hand. */
.the-last-time.tlt-ruhig .tlt-leer { opacity: 0 !important; }
/* The bike and the collar are the story of their rooms, and a still without
   them would be a drive and a rug. */
.the-last-time.tlt-ruhig .tlt-ding { opacity: 1 !important; }
/* The still of the bedroom is the moment it was built around: the screen
   has gone dark and says its line, and the conversation that ended there
   stands in the window above the line about logging off, not on top of it. */
.the-last-time.tlt-ruhig .tlt-netz-off { opacity: 1 !important; }
.the-last-time.tlt-ruhig .tlt-schirm-schein { opacity: 0 !important; }
.the-last-time.tlt-ruhig .tlt-net .tlt-text-frei { top: calc(50% - 0.09 * max(100%, 56.01vw)); }
@media (max-width: 820px) {
  .the-last-time.tlt-ruhig .tlt-net .tlt-text-frei { top: auto; bottom: 7svh; }
}
.the-last-time.tlt-ruhig .tlt-spur { opacity: 0 !important; }
.the-last-time.tlt-ruhig .tlt-spur-ruhe { opacity: 0.4 !important; filter: blur(2px) !important; }
.the-last-time.tlt-ruhig .tlt-nochmal { opacity: 1 !important; pointer-events: auto !important; }
`;

/* ---------------------------------------------------------------------- */
/* Sound                                                                  */
/* ---------------------------------------------------------------------- */

/**
 * The sound layer.
 *
 * It is on when the page opens, and it still cannot make a sound until
 * somebody has touched the page, because every browser refuses to start
 * audio before that and is right to. So it waits: the first click, key or
 * scroll wakes it, and from then on it is simply on. In practice that is the
 * same moment the first picture arrives, because nobody reads this page
 * without scrolling it.
 *
 * The control only appears once a file has actually been found: an offer to
 * turn off sound that was never going to play is worse than no offer.
 *
 * There are three tracks. The bed is the room: wind in a field, a lift
 * motor, a street at night. The second is the one thing in that room worth
 * hearing on its own: rope over a branch, a freewheel, voices in a lift.
 * Each fades separately, so a scene can change its room without losing its
 * detail and the other way round.
 *
 * The third is a piano, and it is the only one that does not belong to a
 * scene. It runs under the whole page at a level below the rooms, so the
 * three deliberately silent stretches are quiet rather than empty. It is
 * optional in the same way the rest is: no file, no track, and everything
 * else still works.
 *
 * All three crossfade rather than cut. The two room tracks start at a random
 * point in the file, so the loop seam never lands in the same place twice;
 * the piano does not, because it is a piece of music with a beginning.
 */
type Spur = "grund" | "detail" | "klavier";

/* The sound a message makes, built rather than recorded.

   It is four numbers and an envelope: a sine that climbs a fifth in forty
   milliseconds and is gone in a quarter of a second, through a bandpass so
   it has a body rather than a whistle. Two of them, a note apart, so the one
   arriving and the one being sent are not the same event.

   Built and not recorded because a file for this would be two kilobytes to
   fetch, a name to document and a thing to get wrong, for a sound that is
   completely described by those four numbers. Every other sound on the page
   is a recording of a place, and no place makes this noise. */
const PLOPP: { spiele: ((eigen: boolean) => void) | null } = { spiele: null };

function plopp(k: AudioContext, eigen: boolean) {
  const t = k.currentTime;
  const o = k.createOscillator();
  o.type = "sine";
  o.frequency.setValueAtTime(eigen ? 430 : 360, t);
  o.frequency.exponentialRampToValueAtTime(eigen ? 880 : 690, t + 0.042);
  const f = k.createBiquadFilter();
  f.type = "bandpass";
  f.frequency.value = eigen ? 820 : 660;
  f.Q.value = 1.1;
  const g = k.createGain();
  g.gain.setValueAtTime(0.0001, t);
  g.gain.exponentialRampToValueAtTime(0.06, t + 0.014);
  g.gain.exponentialRampToValueAtTime(0.0001, t + 0.24);
  o.connect(f).connect(g).connect(k.destination);
  o.start(t);
  o.stop(t + 0.26);
}

/* A bicycle bell, built the same way and for the same reason.

   A thumb bell is a small steel dome, and what it sounds like is a handful of
   partials that do not belong to one note, struck twice. Two of them sit a
   few hertz apart, which is the shimmer every real bell has, and each one
   dies away at its own rate, the high ones first. A few milliseconds of
   filtered noise on top is the striker hitting the dome. It rings once when
   the hand arrives on the bike and not again until the hand has left it. */
const KLINGEL: { spiele: (() => void) | null } = { spiele: null };

function klingel(k: AudioContext) {
  const t0 = k.currentTime;
  const aus = k.createGain();
  aus.gain.value = 0.028;
  aus.connect(k.destination);
  const teile: [number, number, number][] = [
    [2360, 1, 1.5],
    [2374, 0.7, 1.35],
    [3590, 0.42, 0.9],
    [5230, 0.22, 0.5],
    [6920, 0.1, 0.28],
  ];
  const rauschen = k.createBuffer(1, Math.round(k.sampleRate * 0.02), k.sampleRate);
  const kanal = rauschen.getChannelData(0);
  for (let i = 0; i < kanal.length; i += 1)
    kanal[i] = (Math.random() * 2 - 1) * (1 - i / kanal.length);
  for (const [versatz, staerke] of [
    [0, 1],
    [0.15, 0.78],
  ] as const) {
    const t = t0 + versatz;
    for (const [f, a, d] of teile) {
      const o = k.createOscillator();
      o.type = "sine";
      o.frequency.value = f;
      const g = k.createGain();
      g.gain.setValueAtTime(0.0001, t);
      g.gain.exponentialRampToValueAtTime(a * staerke, t + 0.003);
      g.gain.exponentialRampToValueAtTime(0.0001, t + d);
      o.connect(g).connect(aus);
      o.start(t);
      o.stop(t + d + 0.05);
    }
    const n = k.createBufferSource();
    n.buffer = rauschen;
    const bp = k.createBiquadFilter();
    bp.type = "bandpass";
    bp.frequency.value = 4200;
    bp.Q.value = 0.9;
    const ng = k.createGain();
    ng.gain.value = 0.5 * staerke;
    n.connect(bp).connect(ng).connect(aus);
    n.start(t);
  }
}

/* How loud each file is played.

   This belongs to the file and not to the scene, which is the whole reason
   it is a table of its own. The recordings arrived at wildly different
   levels: the field under the swing sits around a thousandth of full scale
   and the voices in the lift are a hundred and fifty times that, both of
   them normal for what they are. One number per scene cannot balance that,
   and the version that tried put the loudest recording on the page under the
   quietest room in it.

   The numbers are measured rather than guessed: each one is the gain that
   brings that file's average level onto the same line as the others, a
   little under a hundredth of full scale for a room, less for a detail, and
   the piano between the two so it is under the rooms and never in front of
   them. Replace a file and the number goes with it. */
const PEGEL: Record<string, number> = {
  "00_piano": 0.47,
  "01_swing_ambient": 5.5,
  "01_chain_creak": 0.15,
  "02_bike_ambient": 0.55,
  "02_freewheel": 0.58,
  "03_table_ambient": 0.083,
  "05_elevator_ambient": 0.44,
  "05_voices": 0.024,
  "06_internet_ambient": 0.54,
  "07_dog": 0.18,
  "08_car_ambient": 0.24,
};

/* Which tracks are streamed and which are decoded.

   A decoded recording is its length times its rate times its channels times
   four bytes, whatever the file weighs: the thirty seconds of the table
   come to eleven megabytes, and a visit through all ten rooms held about
   sixty of them for as long as the page was open. The room tones and the
   piano are the long files, forty seconds to four minutes once the missing
   ones are in, so they play from an audio element, which keeps a few
   seconds in memory rather than the whole file. The details are short, need
   to start the moment the hand arrives and are heard up close, where a loop
   seam would show, so they stay decoded, in a cache with a ceiling. */
const STROM: Record<Spur, boolean> = { grund: true, detail: false, klavier: true };
/** Elements the streamed tracks share: two tracks, each crossfading into the next. */
const STROEME = 4;
/** The most decoded sound kept at once, in bytes. */
const PUFFER_GRENZE = 24 * 1024 * 1024;
const SPUREN = ["grund", "detail", "klavier"] as const;
/** How long a track takes to fade out before it is stopped. */
const AUSKLANG = 2200;

/* Contexts on their way to being closed. Closed a moment after the page
   that owned them lets go rather than at once, so that a page which is only
   taken down and put straight back up again, as React does once with every
   new component while it is being developed, can take its own context back
   instead of finding it closed. A context cannot be reopened, and one made
   outside a click stays silent on a phone. */
const SCHLIESSEN = new Map<AudioContext, number>();

/** A sound device made inside a click, for the page that is built after it. */
type Ruestung = { kontext: AudioContext; elemente: HTMLAudioElement[] };

type Strom = {
  el: HTMLAudioElement;
  quelle: MediaElementAudioSourceNode | null;
  regler: GainNode | null;
  frei: boolean;
  /** When it started to fade, so the oldest fade is the one given up first. */
  seit: number;
  timer: number;
};

type Laufend = { regler: GainNode; ende: (schnell?: boolean) => void };

function neuerKontext(): AudioContext | null {
  const K =
    window.AudioContext ??
    (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
  return K ? new K() : null;
}

function neuesElement() {
  const el = new Audio();
  /* Nothing is fetched until a track is actually asked for. */
  el.preload = "none";
  el.loop = true;
  el.crossOrigin = "anonymous";
  el.setAttribute("playsinline", "");
  return el;
}

/**
 * Makes the sound device for the page that is about to be built, inside the
 * click that asked for it.
 *
 * A phone lets a page start sound only from something a person just did, and
 * the page after "Start again" is built half a second after the click, when
 * that moment has passed. So the context is made and resumed here, and the
 * elements for the long tracks are made and loaded here, which is what
 * clears them to play later. The new page picks them up instead of making
 * its own.
 */
function ruesteTon(): Ruestung | null {
  const kontext = neuerKontext();
  if (!kontext) return null;
  void kontext.resume().catch(() => {});
  const elemente = Array.from({ length: STROEME }, neuesElement);
  for (const el of elemente) el.load();
  return { kontext, elemente };
}

function useTon(
  an: boolean,
  setAn: (f: (v: boolean) => boolean) => void,
  ruestung: Ruestung | null,
) {
  const [bereit, setBereit] = useState(false);
  const [klavier, setKlavier] = useState(false);
  /* Whether the browser lets the page make a sound, which is a different
     thing from whether the person wants it. A page may not start sound on
     its own, only from a click, a tap or a key; a wheel or a trackpad
     scroll does not count. Until then the wish can be as much on as it
     likes and the page stays silent, so the button has to say so and offer
     the click, instead of claiming "sound on" over silence. */
  const [frei, setFrei] = useState(false);
  const kontext = useRef<AudioContext | null>(null);
  const stroeme = useRef<Strom[]>([]);
  const puffer = useRef(new Map<string, AudioBuffer>());
  const unterwegs = useRef(new Map<string, Promise<AudioBuffer | null>>());
  const pufferBytes = useRef(0);
  const quellen = useRef(new Set<AudioBufferSourceNode>());
  const uhren = useRef(new Set<number>());
  const laufend = useRef<Record<Spur, Laufend | null>>({
    grund: null,
    detail: null,
    klavier: null,
  });
  /* What the page wants on each track, and what is actually playing. The
     page says what it wants every frame; only a change does any work. */
  const gewuenscht = useRef<Record<Spur, string | null>>({
    grund: null,
    detail: null,
    klavier: null,
  });
  const laeuft = useRef<Record<Spur, string | null>>({ grund: null, detail: null, klavier: null });
  const anJetzt = useRef(an);
  anJetzt.current = an;
  /* What `betone` last asked each track for. Cleared whenever a new file
     starts on that track, or the first request for the new file would be
     compared against the old one's level, found equal, and never sent. */
  const zuletzt = useRef<Record<string, number>>({});
  const frisch = useRef<Record<string, boolean>>({});

  /* Set once the page is about to be built anew: from then on nothing
     starts and nothing leans, so the old page cannot make a sound while it
     is being taken down. */
  const stumm = useRef(false);

  useEffect(() => {
    // Two probes. If the first ambience is not there, none of the rooms are;
    // the piano is asked for separately because it is the one file somebody
    // is likely to drop in on its own.
    let lebt = true;
    fetch(`${BASIS}/audio/01_swing_ambient.mp3`, { method: "HEAD" })
      .then((r) => {
        if (lebt && r.ok) setBereit(true);
      })
      .catch(() => {});
    fetch(`${BASIS}/audio/00_piano.mp3`, { method: "HEAD" })
      .then((r) => {
        if (lebt && r.ok) {
          setKlavier(true);
          setBereit(true);
        }
      })
      .catch(() => {});
    return () => {
      lebt = false;
    };
  }, []);

  const spaeter = useCallback((f: () => void, ms: number) => {
    const id = window.setTimeout(() => {
      uhren.current.delete(id);
      f();
    }, ms);
    uhren.current.add(id);
    return id;
  }, []);

  /* A decoded recording, from the cache or fetched. The cache is kept in
     the order things were last used, and once it is over its ceiling the
     longest unused goes first. Something still playing keeps its own copy
     alive until it stops, so dropping it from the cache never cuts a sound
     off. */
  const hole = useCallback(async (k: AudioContext, name: string) => {
    const cache = puffer.current;
    const da = cache.get(name);
    if (da) {
      cache.delete(name);
      cache.set(name, da);
      return da;
    }
    const schon = unterwegs.current.get(name);
    if (schon) return schon;
    const auftrag = (async () => {
      try {
        const r = await fetch(`${BASIS}/audio/${name}.mp3`);
        if (!r.ok) return null;
        const b = await k.decodeAudioData(await r.arrayBuffer());
        if (k.state === "closed") return null;
        cache.set(name, b);
        pufferBytes.current += b.length * b.numberOfChannels * 4;
        for (const [alt, ab] of cache) {
          if (pufferBytes.current <= PUFFER_GRENZE || alt === name) break;
          cache.delete(alt);
          pufferBytes.current -= ab.length * ab.numberOfChannels * 4;
        }
        return b;
      } catch {
        return null;
      } finally {
        unterwegs.current.delete(name);
      }
    })();
    unterwegs.current.set(name, auftrag);
    return auftrag;
  }, []);

  /* A free element for a long track, wired into the mix the first time it
     is used and never again: an element can be given to the mix only once.
     If all of them are busy, the one that has been fading longest is cut
     short and taken. */
  const nimmStrom = useCallback((k: AudioContext): Strom | null => {
    const alle = stroeme.current;
    let s = alle.find((x) => x.frei);
    if (!s && alle.length < STROEME) {
      s = { el: neuesElement(), quelle: null, regler: null, frei: true, seit: 0, timer: 0 };
      alle.push(s);
    }
    if (!s) {
      s = alle.filter((x) => x.timer).sort((a, b) => a.seit - b.seit)[0];
      if (!s) return null;
      window.clearTimeout(s.timer);
      uhren.current.delete(s.timer);
      s.el.pause();
    }
    if (!s.quelle || !s.regler) {
      s.quelle = k.createMediaElementSource(s.el);
      s.regler = k.createGain();
      s.quelle.connect(s.regler).connect(k.destination);
    }
    s.frei = false;
    s.timer = 0;
    return s;
  }, []);

  /* Brings one track to what the page wants on it. */
  const umschalten = useCallback(
    async (spur: Spur) => {
      /* Only once the context is actually running. Before that nothing can
         be heard, and an element told to play then is refused for good; so
         nothing is noted as playing either, and the moment the context
         starts, every track is brought up to date at once. */
      const k = kontext.current;
      if (!k || k.state !== "running") return;
      const name = anJetzt.current && !stumm.current ? gewuenscht.current[spur] : null;
      if (laeuft.current[spur] === name) return;
      laeuft.current[spur] = name;
      laufend.current[spur]?.ende();
      laufend.current[spur] = null;
      if (!name) return;
      const pegel = PEGEL[name] ?? 0.3;
      /* A room can be entered anywhere and a piece of music cannot. The
         room tracks start at a random point so a short loop does not seam
         in the same place every time; the piano starts where it was
         written to start, and it is the only file here that has a first
         note. */
      const irgendwo = spur !== "klavier";

      if (STROM[spur]) {
        const s = nimmStrom(k);
        if (!s || !s.regler) return;
        const { el, regler } = s;
        regler.gain.cancelScheduledValues(k.currentTime);
        regler.gain.setValueAtTime(0, k.currentTime);
        regler.gain.setTargetAtTime(pegel, k.currentTime, 0.9);
        el.src = `${BASIS}/audio/${name}.mp3`;
        el.preload = "auto";
        if (irgendwo)
          el.addEventListener(
            "loadedmetadata",
            () => {
              if (Number.isFinite(el.duration)) el.currentTime = Math.random() * el.duration;
            },
            { once: true },
          );
        void el.play().catch(() => {});
        laufend.current[spur] = {
          regler,
          ende: (schnell) => {
            regler.gain.cancelScheduledValues(k.currentTime);
            regler.gain.setTargetAtTime(0, k.currentTime, schnell ? 0.1 : 0.5);
            s.seit = performance.now();
            s.timer = spaeter(
              () => {
                el.pause();
                el.removeAttribute("src");
                el.load();
                s.frei = true;
                s.timer = 0;
              },
              schnell ? 600 : AUSKLANG,
            );
          },
        };
      } else {
        const b = await hole(k, name);
        // A page taken down while the file was decoding clears the context.
        if (!b || laeuft.current[spur] !== name || kontext.current !== k) return;
        const quelle = k.createBufferSource();
        quelle.buffer = b;
        quelle.loop = true;
        const regler = k.createGain();
        regler.gain.value = 0;
        regler.gain.setTargetAtTime(pegel, k.currentTime, 0.9);
        quelle.connect(regler).connect(k.destination);
        quelle.start(0, irgendwo ? Math.random() * b.duration : 0);
        quellen.current.add(quelle);
        laufend.current[spur] = {
          regler,
          ende: (schnell) => {
            regler.gain.cancelScheduledValues(k.currentTime);
            regler.gain.setTargetAtTime(0, k.currentTime, schnell ? 0.1 : 0.5);
            spaeter(
              () => {
                try {
                  quelle.stop();
                } catch {
                  // Already stopped.
                }
                quelle.disconnect();
                regler.disconnect();
                quellen.current.delete(quelle);
              },
              schnell ? 600 : AUSKLANG,
            );
          },
        };
      }
      delete zuletzt.current[spur];
      frisch.current[spur] = true;
    },
    [hole, nimmStrom, spaeter],
  );

  /* The page says what it wants on a track, every frame. The same answer as
     last time costs a comparison and nothing else. */
  const wuensche = useCallback(
    (spur: Spur, name: string | null) => {
      if (gewuenscht.current[spur] === name) return;
      gewuenscht.current[spur] = name;
      void umschalten(spur);
    },
    [umschalten],
  );

  const anschliessen = useCallback(
    (k: AudioContext) => {
      kontext.current = k;
      /* Whenever it starts running, after the first touch or after a phone
         call took the audio away for a while, everything the page wants is
         started from where the page is now. */
      k.addEventListener("statechange", () => {
        if (kontext.current !== k) return;
        setFrei(k.state === "running");
        if (k.state === "running") for (const spur of SPUREN) void umschalten(spur);
      });
      setFrei(k.state === "running");
      if (k.state === "running") for (const spur of SPUREN) void umschalten(spur);
    },
    [umschalten],
  );

  const wecken = useCallback(() => {
    if (!kontext.current) {
      const k = neuerKontext();
      if (!k) return undefined;
      while (stroeme.current.length < STROEME)
        stroeme.current.push({
          el: neuesElement(),
          quelle: null,
          regler: null,
          frei: true,
          seit: 0,
          timer: 0,
        });
      anschliessen(k);
    }
    /* Loaded while a person is touching the page, each element is cleared
       to play from then on, which is the only way a phone lets an element
       make a sound later from a scroll. An element without a source loads
       nothing. */
    for (const s of stroeme.current) if (s.frei && !s.el.getAttribute("src")) s.el.load();
    return kontext.current?.resume();
  }, [anschliessen]);

  /* What was made inside the click that started the page again, taken over
     rather than made anew; and on the way out everything this page made is
     stopped, released and closed, so the page after it starts from nothing
     and nothing of this one is left playing underneath. */
  useEffect(() => {
    const r = ruestung;
    const offen = r ? SCHLIESSEN.get(r.kontext) : undefined;
    if (r && offen !== undefined) {
      window.clearTimeout(offen);
      SCHLIESSEN.delete(r.kontext);
    }
    if (r && r.kontext.state !== "closed") {
      stroeme.current = r.elemente.map((el) => ({
        el,
        quelle: null,
        regler: null,
        frei: true,
        seit: 0,
        timer: 0,
      }));
      anschliessen(r.kontext);
    }
    const alleUhren = uhren.current;
    const alleQuellen = quellen.current;
    const cache = puffer.current;
    return () => {
      for (const id of alleUhren) window.clearTimeout(id);
      alleUhren.clear();
      for (const q of alleQuellen) {
        try {
          q.stop();
        } catch {
          // Already stopped.
        }
        q.disconnect();
      }
      alleQuellen.clear();
      for (const s of stroeme.current) {
        s.el.pause();
        s.el.removeAttribute("src");
        s.el.load();
        s.quelle?.disconnect();
        s.regler?.disconnect();
      }
      stroeme.current = [];
      cache.clear();
      pufferBytes.current = 0;
      const k = kontext.current;
      kontext.current = null;
      if (k && k.state !== "closed")
        SCHLIESSEN.set(
          k,
          window.setTimeout(() => {
            SCHLIESSEN.delete(k);
            void k.close().catch(() => {});
          }, 0),
        );
    };
  }, [ruestung, anschliessen]);

  /* Wait for the first thing the person does and start there.

     Every one of these is tried, and the listeners stay on until the context
     is actually running, because a wheel or a scroll does not count as having
     touched the page in Chrome and a click does. Taken off after the first
     event, a visit that began with a scroll would be silent until it was
     reloaded. */
  useEffect(() => {
    if (!an || !bereit) return;
    let lebt = true;
    const arten = [
      "pointerdown",
      "pointerup",
      "keydown",
      "touchstart",
      "touchend",
      "wheel",
      "scroll",
    ] as const;
    const ab = () => {
      for (const art of arten) window.removeEventListener(art, los);
    };
    function los() {
      void wecken()?.then(() => {
        if (lebt && kontext.current?.state === "running") ab();
      });
    }
    for (const art of arten) window.addEventListener(art, los, { passive: true });
    return () => {
      lebt = false;
      ab();
    };
  }, [an, bereit, wecken]);

  /* Switched on or off: every track is brought to what that means now. */
  useEffect(() => {
    for (const spur of SPUREN) {
      if (!an) {
        laufend.current[spur]?.ende(true);
        laufend.current[spur] = null;
        laeuft.current[spur] = null;
      } else void umschalten(spur);
    }
  }, [an, umschalten]);

  /* Lean on a track that is already running.

     The rope over the branch is the one sound on the page that is not a
     property of where you are but of what you are doing: it belongs to the
     swing moving, not to the meadow. So its level is written from the scene
     rather than from the table, and this is how.

     Only when it has actually moved. `setTargetAtTime` schedules an event on
     the audio thread, and one of those every frame for the whole of a scene
     is a queue thousands long doing work nobody can hear. */
  const betone = useCallback((spur: Spur, faktor: number) => {
    const k = kontext.current;
    const l = laufend.current[spur];
    const name = laeuft.current[spur];
    if (!k || !l || !name || stumm.current) return;
    if (Math.abs((zuletzt.current[spur] ?? -1) - faktor) < 0.04) return;
    zuletzt.current[spur] = faktor;
    /* A track that has only just started keeps its slow way in. Everything
       after that answers the hand within a few tenths of a second, which is
       quick enough to feel caused and slow enough not to click. */
    const tau = frisch.current[spur] ? 0.9 : 0.14;
    frisch.current[spur] = false;
    l.regler.gain.setTargetAtTime((PEGEL[name] ?? 0.3) * faktor, k.currentTime, tau);
  }, []);

  /* The scene that has messages in it is nowhere near this hook and has no
     reason to be. It calls one function, and this is where that function
     comes from while the sound is on. */
  useEffect(() => {
    if (!an) return;
    PLOPP.spiele = (eigen: boolean) => {
      const k = kontext.current;
      if (k && k.state === "running" && !stumm.current) plopp(k, eigen);
    };
    KLINGEL.spiele = () => {
      const k = kontext.current;
      if (k && k.state === "running" && !stumm.current) klingel(k);
    };
    return () => {
      PLOPP.spiele = null;
      KLINGEL.spiele = null;
    };
  }, [an]);

  /* One press does one thing. While the browser has not let the page make a
     sound yet, the press is what lets it, and it turns the sound on, whatever
     the wish was: the person pressing it wants to hear something. Toggled
     as well, the same press that cleared the way would switch it off again,
     and it would take a second one to hear anything. Only once sound is
     possible does the button switch it on and off. */
  const schalten = useCallback(() => {
    const k = kontext.current;
    if (!k || k.state !== "running") {
      void wecken();
      setAn(() => true);
      return;
    }
    setAn((v) => !v);
  }, [wecken, setAn]);

  /* Everything down, quickly, and nothing new: the page is about to be
     built again and the next one brings its own sound. */
  const verstummen = useCallback(() => {
    stumm.current = true;
    PLOPP.spiele = null;
    KLINGEL.spiele = null;
    for (const spur of SPUREN) {
      laufend.current[spur]?.ende(true);
      laufend.current[spur] = null;
      laeuft.current[spur] = null;
    }
  }, []);

  return useMemo(
    () => ({ bereit, klavier, an, frei, schalten, wuensche, betone, verstummen }),
    [bereit, klavier, an, frei, schalten, wuensche, betone, verstummen],
  );
}

/* ---------------------------------------------------------------------- */
/* Intro                                                                  */
/* ---------------------------------------------------------------------- */

const INTRO: Zeile[] = [
  { text: "Think of something you used to do all the time.", von: 0.04, bis: 0.34 },
  { text: "You probably do not remember the last time you did it.", von: 0.32, bis: 0.62 },
  { text: "Neither did I.", von: 0.6, bis: 0.9 },
];

/**
 * Black, and three lines in it.
 *
 * No logo, no navigation, no picture. The first photograph arrives a screen
 * later and lands harder for having been made to wait.
 *
 * The lines do not simply appear. Each one comes out of a haze letter by
 * letter, holds while it is read, and is taken away again as a whole, softly,
 * the way a title in a film leaves. Three things make that work:
 *
 * The scroll is smoothed before it drives anything. A title that tracks the
 * wheel one to one flickers on a fast flick and snaps on a slow one; this one
 * lags the scroll by a tenth and plays at its own speed no matter how the
 * page is scrolled.
 *
 * The lines are stacked on top of each other rather than under each other, so
 * whichever one is up sits in the middle of the frame, where a title belongs.
 *
 * And the whole thing is given nearly three screens of scrolling for three
 * short lines, which is slow enough that reading it is the only thing there
 * is to do.
 */
function Intro({ ruhig }: { ruhig: boolean }) {
  const absaetze = useRef<(HTMLParagraphElement | null)[]>([]);
  const hinweis = useRef<HTMLParagraphElement>(null);
  const weich = useRef(0);
  const start = useRef(0);

  return (
    <Szene
      laenge={3.8}
      klasse="tlt-intro"
      regie={(p) => {
        weich.current += (p - weich.current) * 0.1;
        const q = weich.current;
        if (!start.current) start.current = performance.now();
        const zeit = (performance.now() - start.current) / 1000;

        /* The first line arrives on its own, without being asked for.

           At the top of the page there is nothing yet: no picture, no
           navigation, no scrollbar worth noticing. A black screen that only
           does something once you scroll does not read as restraint, it reads
           as a page that failed to load. So the first line comes up by
           itself, stops where it has been read, and hands over to the scroll
           from there. */
        const selbst = stufe(zeit, 0.5, 3.4) * 0.55;

        for (let i = 0; i < INTRO.length; i += 1) {
          const abs = absaetze.current[i];
          if (!abs) continue;
          const z = INTRO[i]!;
          setzeStuecke(
            abs,
            Math.max(klemme((q - z.von) / (z.bis - z.von || 1)), i === 0 ? selbst : 0),
          );
        }

        /* And if nothing happens after that, the page says what to do. Once,
           here, as soon as the first line has settled, and never again: the
           same hint returning at the end of the opening is an instruction
           repeated to somebody who has already followed it. It goes away the
           moment they scroll. */
        const h = hinweis.current;
        if (h) schreibe(h, "opacity", String(stufe(zeit, 2.4, 4) * (1 - stufe(q, 0.015, 0.09))));
      }}
      kinder={
        <>
          <div className="tlt-text tlt-text-mitte">
            {INTRO.map((z, i) => (
              <Stueckzeile
                key={z.text}
                text={z.text}
                buchstaben
                klasse={`tlt-zeichenzeile${i % 2 === 1 ? " tlt-zeile-akzent" : ""}`}
                ref={(el: HTMLParagraphElement | null) => {
                  absaetze.current[i] = el;
                }}
              />
            ))}
          </div>
          <p ref={hinweis} className="tlt-hinweis" style={{ opacity: ruhig ? 1 : 0 }}>
            scroll slowly
          </p>
        </>
      }
    />
  );
}

/* ---------------------------------------------------------------------- */
/* 01  Swing                                                              */
/* ---------------------------------------------------------------------- */

/* How hard the swing is moving, nought to one.

   The scene writes it and the sound layer reads it, which is why it is out
   here and not inside either of them. The two have no other reason to know
   about each other, and passing a value down through the page for the sake
   of one rope would mean every scene carrying a channel it never uses. */
const SCHWUNG = { wert: 0 };

const SCHAUKEL: Zeile[] = [
  { text: "The last time someone pushed you on a swing.", von: 0.24, bis: 0.58 },
  { text: "You probably did not know it was the last time.", von: 0.54, bis: 0.86 },
];

/**
 * A swing nobody is sitting on, in the last light of the day.
 *
 * The camera creeps in and the sun behind the tree breathes. The swing is the
 * one thing on the page that can be touched: it hangs there in the wind, and
 * moving the hand across it leans on it, slowly, with the weight and the
 * overshoot a swing has. The rope answers as it goes, loudest at the bottom
 * of the arc, and stops when it stops.
 */
function Schaukel() {
  const platte = useRef<HTMLDivElement>(null);
  const brett = useRef<HTMLImageElement>(null);
  const licht = useRef<HTMLSpanElement>(null);
  const zeilen = useRef<(HTMLParagraphElement | null)[]>([]);
  const winkel = useRef(0);
  const tempo = useRef(0);
  const ziel = useRef(0);

  return (
    <Szene
      laenge={3.4}
      klasse="tlt-swing"
      regie={(p, zx, zy) => {
        const t = performance.now() / 1000;
        fahre(platte.current, p, zx, zy, {
          von: 1.03,
          bis: 1.07,
          x: -0.5,
          y: 0.3,
          zx: 0.3,
          zy: 0.2,
        });
        /* The sun gains a little across the scene and gives a touch of it
           back at the end. It used to be taken away altogether, because a
           scene that went out was how the page got to the next one; now the
           next one arrives over the top of this one, and a meadow going dark
           underneath it would be a second thing happening at the same time. */
        belichte(
          platte.current,
          misch(0.94, 1.06, stufe(p, 0, 0.7)) * (1 - stufe(p, 0.8, 1) * 0.12) +
            Math.sin(t * 0.21) * 0.012,
          misch(1.02, 1.06, p),
          misch(1, 1.05, p),
        );
        /* The swing is a pendulum and the hand pushes it.

           Not a value the pointer sets: a weight with its own period that is
           pulled towards where the hand is and then overshoots, comes back,
           and takes a while to settle. That difference is the whole thing.
           Set directly, the swing sticks to the cursor and reads as a slider
           with a photograph behind it; given a spring and a little friction,
           it answers late, keeps going after the hand has stopped, and is
           the only object on the page that can be played with.

           Underneath it there is still weather: two slow sines that do not
           divide into each other, so an untouched swing never repeats on a
           beat and never quite stands still. */
        const b = brett.current;
        if (b) {
          /* The hand does not reach through to the swing. It moves a point
             the swing is pulled towards, and that point follows the hand
             slowly: sweep a mouse across the screen and the swing leans
             into it over about a second rather than being snatched sideways.
             Without that step every flick of the wrist is a shove, and a
             swing that answers instantly is not heavy, which is the one
             thing a swing is.

             A long period and very little friction underneath it. Three and
             a half degrees at the most, where it was seven: the arc reads
             from how slowly it turns around, not from how far it goes. */
          ziel.current += (zx * 3.6 - ziel.current) * 0.022;
          const feder = (ziel.current - winkel.current) * 0.0075;
          tempo.current = (tempo.current + feder) * 0.991;
          winkel.current += tempo.current;
          const wind = Math.sin(t * 0.52) * 0.26 + Math.sin(t * 0.83) * 0.1;
          schreibe(b, "transform", `rotate(${(winkel.current + wind).toFixed(3)}deg)`);
          // What the rope is asked to sound like. Squared, because a swing
          // is loudest at the bottom of its arc and silent at the top, and
          // the ear hears that shape rather than the average.
          SCHWUNG.wert = klemme(Math.abs(tempo.current) * 6) ** 0.8;
        }
        // The light sits where the sun is and answers the hand rather than
        // following it: it brightens when the pointer is near the beam.
        const l = licht.current;
        if (l) {
          const naehe = klemme(1 - Math.hypot(zx + 0.25, zy + 0.4) / 1.5);
          schreibe(l, "opacity", String((0.3 + naehe * 0.5) * (1 - stufe(p, 0.8, 1) * 0.3)));
          schreibe(
            l,
            "transform",
            `translate(-50%, -50%) translate3d(${zx * 2.4}%, ${zy * 1.8}%, 0)`,
          );
        }
        setzeZeilen(zeilen, SCHAUKEL, p);
      }}
      kinder={
        <>
          <Platte
            ref={platte}
            verhaeltnis={1.7768}
            kinder={
              <>
                <Bild
                  name="01-plate.webp"
                  alt="An empty swing under a tree in low evening sun"
                  eilig
                />
                <Bild
                  ref={brett}
                  name="01-swing.webp"
                  klasse="tlt-teil tlt-swing-teil"
                  stil={{ left: "12%", top: "-4%", width: "38%" }}
                  eilig
                />
              </>
            }
          />
          <span
            ref={licht}
            className="tlt-licht"
            style={{
              left: "34%",

              top: "24%",

              background:
                "radial-gradient(circle at 50% 50%, rgba(255, 214, 150, 0.5) 0%, rgba(255, 196, 126, 0.16) 30%, rgba(255, 190, 120, 0) 56%)",
            }}
            aria-hidden
          />
          <span className="tlt-flor" aria-hidden />
          <Zeilen zeilen={SCHAUKEL} refs={zeilen} />
        </>
      }
    />
  );
}

/* ---------------------------------------------------------------------- */
/* 02  Bike                                                               */
/* ---------------------------------------------------------------------- */

const RAD: Zeile[] = [
  { text: "The last time you rode your childhood bike.", von: 0.24, bis: 0.58 },
  { text: "There was no finish line.", von: 0.54, bis: 0.84 },
];

/**
 * A drive in front of the garage in the last of the sun, and a bike that is
 * only there while somebody looks for it.
 *
 * The camera pulls back rather than pushing in, because the scene before it
 * pushed in and two pushes in a row read as one long move. The bike does not
 * move at all. It is parked, or it is not there.
 */
function Rad() {
  const platte = useRef<HTMLDivElement>(null);
  const ding = useRef<HTMLImageElement>(null);
  const licht = useRef<HTMLSpanElement>(null);
  const zeilen = useRef<(HTMLParagraphElement | null)[]>([]);
  const da = useRef(0);

  return (
    <Szene
      laenge={3.2}
      klasse="tlt-bike"
      regie={(p, zx, zy, maus) => {
        fahre(platte.current, p, zx, zy, {
          von: 1.08,
          bis: 1.03,
          x: 0.6,
          y: -0.2,
          zx: 0.34,
          zy: 0.2,
        });
        const dunkel = stufe(p, 0.8, 1);
        belichte(
          platte.current,
          misch(0.9, 1.04, stufe(p, 0, 0.55)) * (1 - dunkel * 0.12),
          misch(1.06, 1.02, p),
          misch(1.06, 0.98, p),
        );
        /* The bike stands where it was left. Where the hand comes near it,
           it thins out and flickers, the empty drive showing through it, and
           when the hand moves on it settles back. It never goes: a memory
           you reach for does not disappear, it just will not hold still.

           Without a mouse there is no hand to reach with, so on a phone it
           happens once by itself in the middle of the scene, while the first
           line is read. Eased either way, so it is never switched. */
        const hand =
          klemme(1 - Math.hypot(zx + 0.07, zy - 0.17) / 0.6) * fenster(p, 0.1, 0.95, 0.24, 0.14);
        const ziel = misch(fenster(p, 0.3, 0.8, 0.3, 0.3), hand, stufe(maus, 0.05, 0.9));
        da.current += (ziel - da.current) * 0.07;
        if (da.current < 0.004) da.current = 0;
        const d0 = ding.current;
        if (d0) schreibe(d0, "opacity", String(verblassen(da.current, performance.now() / 1000)));
        // The freewheel ticks while the hand is near, and the bell rings
        // once when it gets there.
        HAND.naehe.rad = da.current;
        // The sun is low and behind everything. Moving the hand across it is
        // like moving your head: the flare opens and closes.
        const l = licht.current;
        if (l) {
          schreibe(l, "opacity", String((0.3 + klemme(0.6 - zy * 0.5) * 0.36) * (1 - dunkel)));
          schreibe(
            l,
            "transform",
            `translate(-50%, -50%) translate3d(${zx * 3}%, ${zy * 1.4}%, 0)`,
          );
        }
        setzeZeilen(zeilen, RAD, p);
      }}
      kinder={
        <>
          <Platte
            ref={platte}
            verhaeltnis={1.7844}
            kinder={
              <>
                <Bild
                  name="02-plate.webp"
                  klasse="tlt-gestreckt"
                  alt="An empty driveway in front of a garage in low evening sun"
                />
                <Bild ref={ding} name="02-master.webp" klasse="tlt-ding tlt-rad-ding" />
              </>
            }
          />
          <span
            ref={licht}
            className="tlt-licht"
            style={{
              left: "10%",
              top: "26%",

              background:
                "radial-gradient(circle at 50% 50%, rgba(255, 206, 136, 0.44) 0%, rgba(255, 186, 116, 0.14) 32%, rgba(255, 180, 110, 0) 58%)",
            }}
            aria-hidden
          />
          <span className="tlt-flor" aria-hidden />
          <Zeilen zeilen={RAD} refs={zeilen} klasse="tlt-text-frei" />
        </>
      }
    />
  );
}

/* ---------------------------------------------------------------------- */
/* 04  The walk                                                           */
/* ---------------------------------------------------------------------- */

const WEG: Zeile[] = [
  { text: "The last time you walked beside him, hand in hand.", von: 0.2, bis: 0.54 },
  { text: "You let go to run ahead.", von: 0.5, bis: 0.86 },
];

/**
 * An old man and a child walking into a field towards the low sun, and then
 * only the child.
 *
 * There are two photographs of this path, one with both of them and one
 * with the child alone, and the child is in the same place and the same size
 * in both. So nothing is cut out and nothing is animated: the first picture
 * dissolves into the second while the second line is being read, the child
 * keeps walking, and the man beside them is simply no longer there. The
 * bushes change a little in the dissolve, the way a place does when it is
 * remembered rather than seen.
 *
 * It comes after the bike on purpose, and long before the hands. The old
 * hand in the bed near the end is his, and nobody needs to be told.
 */
function Weg() {
  const platte = useRef<HTMLDivElement>(null);
  const allein = useRef<HTMLImageElement>(null);
  const licht = useRef<HTMLSpanElement>(null);
  const zeilen = useRef<(HTMLParagraphElement | null)[]>([]);

  return (
    <Szene
      laenge={3.6}
      klasse="tlt-walk"
      regie={(p, zx, zy) => {
        // Towards them, slowly, which is the only way to follow someone
        // walking away from the camera.
        fahre(platte.current, p, zx, zy, { von: 1.03, bis: 1.09, y: 0.4, zx: 0.2, zy: 0.14 });
        belichte(
          platte.current,
          misch(0.82, 0.96, stufe(p, 0, 0.4)) * (1 - stufe(p, 0.84, 1) * 0.3),
          1.04,
          misch(1.04, 0.94, stufe(p, 0.5, 1)),
        );
        // He is gone by the time the line about letting go has landed.
        const a = allein.current;
        if (a) schreibe(a, "opacity", String(stufe(p, 0.56, 0.78)));
        // The sun is behind the hill on the left. The hand moves the flare
        // the way turning your head would.
        const l = licht.current;
        if (l) {
          schreibe(
            l,
            "opacity",
            String(misch(0.5, 0.2, stufe(p, 0.5, 1)) * (0.8 + klemme(0.4 - zy * 0.4) * 0.3)),
          );
          schreibe(l, "transform", `translate(-50%, -50%) translate3d(${zx * 4}%, ${zy * 2}%, 0)`);
        }
        setzeZeilen(zeilen, WEG, p);
      }}
      kinder={
        <>
          <Platte
            ref={platte}
            verhaeltnis={1.7844}
            kinder={
              <>
                <Bild
                  name="04-walk.webp"
                  alt="An old man and a small child walking hand in hand through a field towards the low sun"
                />
                <Bild ref={allein} name="04-walk_alone.webp" klasse="tlt-leer tlt-weg-allein" />
              </>
            }
          />
          <span
            ref={licht}
            className="tlt-licht"
            style={{
              left: "13%",
              top: "8%",
              background:
                "radial-gradient(circle at 50% 50%, rgba(255, 220, 160, 0.46) 0%, rgba(255, 200, 130, 0.14) 30%, rgba(255, 190, 120, 0) 56%)",
            }}
            aria-hidden
          />
          <span className="tlt-flor" aria-hidden />
          <Zeilen zeilen={WEG} refs={zeilen} />
        </>
      }
    />
  );
}

/* ---------------------------------------------------------------------- */
/* 03  Table                                                              */
/* ---------------------------------------------------------------------- */

const TISCH: Zeile[] = [
  { text: "The last time everyone was at this table.", von: 0.26, bis: 0.6 },
  { text: "No one took a picture.", von: 0.56, bis: 0.86 },
];

/* Four places at the table and the memory that happened at each of them.
   `x` and `y` are where the hand has to be, in pointer space; `links`, `oben`
   and `breit` are where the trace surfaces, in percentages of the
   photograph. The regions are where you would actually sit, not a tidy grid,
   because a grid would be a menu and this is a room. */
const PLAETZE = [
  { bild: "03-trace_birthday.webp", x: -0.28, y: 0.12, links: 36, oben: 60, breit: 34 },
  { bild: "03-trace_homework.webp", x: 0.26, y: 0.08, links: 63, oben: 58, breit: 32 },
  { bild: "03-trace_hands.webp", x: -0.06, y: 0.24, links: 47, oben: 66, breit: 38 },
  { bild: "03-trace_spill.webp", x: 0.46, y: 0.16, links: 73, oben: 62, breit: 30 },
] as const;

/**
 * A table still laid, under a lamp that is about to be switched off.
 *
 * The memories here are not pictures placed on the room. They surface inside
 * it: the hand rests near a place at the table, the light there lifts, and a
 * second exposure of the same room comes up underneath it, soft, dissolved
 * at every edge and never more than half there. Up quickly and away slowly,
 * because that is how something half remembered behaves; the same speed both
 * ways would read as a hover state.
 */
function Tisch() {
  const platte = useRef<HTMLDivElement>(null);
  const licht = useRef<HTMLSpanElement>(null);
  const spuren = useRef<(HTMLImageElement | null)[]>([]);
  const zeilen = useRef<(HTMLParagraphElement | null)[]>([]);
  const staerke = useRef([0, 0, 0, 0]);

  return (
    <Szene
      laenge={3.8}
      klasse="tlt-table"
      regie={(p, zx, zy) => {
        const t = performance.now() / 1000;
        fahre(platte.current, p, zx, zy, { von: 1.02, bis: 1.06, y: 0.5, zx: 0.26, zy: 0.18 });
        /* The lamp goes out at the end and takes the room with it. That is
           the whole transition: the last warm thing in the house is switched
           off, and what is left is a line of light under a door somewhere
           else. */
        const aus = stufe(p, 0.72, 0.98);
        belichte(
          platte.current,
          (misch(0.9, 1.05, stufe(p, 0, 0.5)) + Math.sin(t * 1.7) * 0.008) * (1 - aus * 0.55),
          1.04,
          misch(1.02, 1.08, p),
        );

        // Whichever place the hand is nearest wakes up, the rest fade. Only
        // while the room itself is holding still, so the arrival and the exit
        // stay clean.
        const wach = fenster(p, 0.12, 0.9, 0.14, 0.12);
        let naechster = -1;
        let beste = 0.42;
        PLAETZE.forEach((pl, i) => {
          const d = Math.hypot(zx - pl.x, zy - pl.y);
          if (d < beste) {
            beste = d;
            naechster = i;
          }
        });
        // Somebody at the table comes back, and so does the room's laughter.
        HAND.naehe.tisch = Math.max(...staerke.current) * wach;
        PLAETZE.forEach((_, i) => {
          const ziel = i === naechster ? 1 : 0;
          const rate = ziel > staerke.current[i]! ? 0.14 : 0.045;
          staerke.current[i] = staerke.current[i]! + (ziel - staerke.current[i]!) * rate;
          const el = spuren.current[i];
          if (!el) return;
          const st = staerke.current[i]! * wach;
          schreibe(el, "opacity", String(st * 0.66));
          // It never comes fully into focus. A trace that is as sharp as the
          // room is a photograph lying on the table.
          schreibe(el, "filter", `blur(${(1 - st) * 9 + 1.2}px)`);
        });

        const l = licht.current;
        if (l) {
          schreibe(l, "opacity", String(misch(0.2, 0.62, stufe(p, 0.1, 0.5)) * (1 - aus)));
          schreibe(
            l,
            "transform",
            `translate(-50%, -50%) translate3d(${zx * 16}%, ${zy * 10}%, 0)`,
          );
        }
        setzeZeilen(zeilen, TISCH, p);
      }}
      kinder={
        <>
          <Platte
            ref={platte}
            kinder={
              <>
                <Bild name="03-master.webp" alt="A long table laid under a pendant lamp" />
                {PLAETZE.map((pl, i) => (
                  <Bild
                    key={pl.bild}
                    ref={(el: HTMLImageElement | null) => {
                      spuren.current[i] = el;
                    }}
                    name={pl.bild}
                    klasse={`tlt-spur${i === 0 ? " tlt-spur-ruhe" : ""}`}
                    stil={{ left: `${pl.links}%`, top: `${pl.oben}%`, width: `${pl.breit}%` }}
                  />
                ))}
              </>
            }
          />
          <span
            ref={licht}
            className="tlt-licht"
            style={{
              left: "50%",
              top: "54%",
              background:
                "radial-gradient(circle at 50% 50%, rgba(255, 198, 126, 0.4) 0%, rgba(255, 186, 116, 0.12) 22%, rgba(255, 180, 110, 0) 44%)",
            }}
            aria-hidden
          />
          <span className="tlt-flor" aria-hidden />
          <Zeilen zeilen={TISCH} refs={zeilen} />
        </>
      }
    />
  );
}

/* ---------------------------------------------------------------------- */
/* 05  Lift                                                               */
/* ---------------------------------------------------------------------- */

const AUFZUG: Zeile[] = [
  { text: "The last time everyone was in the same room.", von: 0.3, bis: 0.62 },
  { text: "You were already becoming different people.", von: 0.58, bis: 0.86 },
];

/**
 * A lift with five people in the mirror and nobody in the lift.
 *
 * They are already in the photograph, as a reflection, which is the right
 * place for them. The scene does not put people into a lift; it takes the
 * glass out from in front of them for a moment and then puts it back.
 */
function Aufzug() {
  const platte = useRef<HTMLDivElement>(null);
  const schleier = useRef<HTMLImageElement>(null);
  const flimmer = useRef<HTMLSpanElement>(null);
  const zeilen = useRef<(HTMLParagraphElement | null)[]>([]);

  return (
    <Szene
      laenge={3.4}
      klasse="tlt-lift"
      regie={(p, zx, zy) => {
        const t = performance.now() / 1000;
        fahre(platte.current, p, zx, zy, { von: 1.05, bis: 1.02, y: -0.3, zx: 0.24, zy: 0.16 });
        belichte(platte.current, misch(0.82, 1, stufe(p, 0, 0.22)), 1.05, 0.96);

        /* The glass clears in the middle of the scene and fogs again before
           the end, so the group is never simply on screen. The hand can
           clear a little more of it, which is the only thing to do here. */
        const klar = fenster(p, 0.18, 0.88, 0.34, 0.3);
        // Measured from the group in the mirror, a little right of centre
        // and above it. The glass clears there and their voices come
        // through, the same gesture seen and heard.
        const spiegel = klemme(1 - Math.hypot(zx - 0.18, zy + 0.26) / 0.75);
        const s = schleier.current;
        if (s) schreibe(s, "opacity", String(klemme(1 - klar * 0.92 - spiegel * 0.2)));
        HAND.naehe.lift = spiegel * fenster(p, 0.1, 0.95, 0.2, 0.2);

        /* The tube starts to go. Two slow waves out of step with each
           other, so the dips never land on a beat, and the light only ever
           falls back from where it was rather than jumping up. */
        const f = flimmer.current;
        if (f) {
          const z = stufe(p, 0.8, 1);
          const welle = Math.sin(t * 2.1) * Math.sin(t * 0.83 + 1.7);
          schreibe(f, "opacity", String(z * (0.78 + 0.22 * welle)));
        }
        setzeZeilen(zeilen, AUFZUG, p);
      }}
      kinder={
        <>
          <Platte
            ref={platte}
            kinder={
              <>
                <Bild name="05-master.webp" alt="A lift with a group reflected in its mirror" />
                <Bild ref={schleier} name="05-master.webp" klasse="tlt-lift-schleier" />
              </>
            }
          />
          <span className="tlt-flor" aria-hidden />
          <span ref={flimmer} className="tlt-lift-flimmer" aria-hidden />
          <Zeilen zeilen={AUFZUG} refs={zeilen} />
        </>
      }
    />
  );
}

/* ---------------------------------------------------------------------- */
/* 06  The screen                                                         */
/* ---------------------------------------------------------------------- */

const NETZ: Zeile[] = [
  { text: "The last time you logged off and never came back.", von: 0.68, bis: 0.86 },
];
/* The answer is written on the screen itself, in the moment it goes dark.
   Up on the wall it stood beside the lamp, the brightest thing in the room,
   and could hardly be read; on the black glass it is the last thing the
   monitor shows. */
const NETZ_SCHIRM: Zeile[] = [
  { text: "You did not know an era had ended.", von: 0.84, bis: 0.99, akzent: true, haelt: true },
];

/* The last exchange in the thread.

   `punkte` is when the dots come up, `von` when the message pops in and the
   first letter lands, `bis` when the last one does. Typing a message takes
   about as long as reading it, which is why the short one is given less of
   the scene than the long one rather than the same share.

   The whole conversation is over before either line of the scene appears.
   Read together they are two things at once, one of them moving, and the
   eye goes to the moving one and then has to come back; read in turn, the
   messages are a thing that happened and the lines are what it was. */
const CHAT = [
  { text: "same time tomorrow?", eigen: false, punkte: 0.05, von: 0.13, bis: 0.25 },
  { text: "always", eigen: true, punkte: 0.28, von: 0.35, bis: 0.43 },
] as const;

/** When the answer that never came starts being typed, and when it stops. */
const OHNE_ANTWORT = { von: 0.47, bis: 0.64 };
/** And when the thread itself goes, leaving the room to the two lines. */
const CHAT_WEG = { von: 0.6, bis: 0.7 };

/**
 * A room at night with the only light in it coming off a tube.
 *
 * The photograph is left exactly as it was taken, including whatever is on
 * the screen. The conversation is remembered next to it: the dots come up, a
 * message pops in and types itself out, the answer comes back, and then the
 * dots come up a third time and stop. Nothing is ever sent after that. Then
 * the thread fades and the room is left to the two lines.
 *
 * The whole scene is in that third set of dots. The end of a friendship is
 * almost never a goodbye, it is a reply somebody was halfway through.
 */
function Netz() {
  const platte = useRef<HTMLDivElement>(null);
  const schirmzeile = useRef<(HTMLParagraphElement | null)[]>([]);
  const aus = useRef<HTMLImageElement>(null);
  const schein = useRef<HTMLSpanElement>(null);
  const faden = useRef<HTMLDivElement>(null);
  const blasen = useRef<(HTMLDivElement | null)[]>([]);
  const punkte = useRef<(HTMLDivElement | null)[]>([]);
  const zeilen = useRef<(HTMLParagraphElement | null)[]>([]);
  const gehabt = useRef<boolean[]>([]);

  return (
    <Szene
      laenge={4.6}
      klasse="tlt-net"
      regie={(p, zx, zy) => {
        fahre(platte.current, p, zx, zy, { von: 1.02, bis: 1.06, x: -0.4, zx: 0.2, zy: 0.14 });
        /* The room goes down further once the thread has gone and the two
           lines are left. The screen keeps its own glow on top of the
           plate, so the monitor stays the brightest thing in the picture
           while the wall behind the type drops away from it. */
        belichte(
          platte.current,
          misch(0.84, 0.96, stufe(p, 0, 0.3)) - stufe(p, CHAT_WEG.von, CHAT_WEG.bis) * 0.16,
          1.04,
          0.94,
        );

        const weg = 1 - stufe(p, CHAT_WEG.von, CHAT_WEG.bis);
        const f = faden.current;
        if (f) schreibe(f, "opacity", String(weg));

        for (let i = 0; i < CHAT.length; i += 1) {
          const c = CHAT[i]!;
          // The dots hold the place of the message until it is there, and go
          // out the moment the first letter of it does.
          const d = punkte.current[i];
          if (d) schreibe(d, "opacity", String(fenster(p, c.punkte, c.von, 0.3, 0.2)));
          const b = blasen.current[i];
          if (!b) continue;
          /* It pops. A message does not fade up in any program anybody has
             ever used: it is not there and then it is, slightly too big for
             an instant. Sixty milliseconds of scroll and four percent is the
             whole gesture, and it is the difference between a chat and a
             caption that happens to be in a box. */
          const auf = stufe(p, c.von, c.von + 0.022);
          schreibe(b, "opacity", String(auf * weg));
          schreibe(b, "transform", `scale(${(0.9 + auf * 0.14 - auf * auf * 0.04).toFixed(4)})`);
          tippe(b, klemme((p - c.von) / (c.bis - c.von)));
          // And it makes the noise, once, when it arrives. Scrolled back
          // past, it is allowed to arrive again.
          const da = p >= c.von;
          if (da !== gehabt.current[i]) {
            gehabt.current[i] = da;
            if (da) PLOPP.spiele?.(c.eigen);
          }
        }
        /* And then somebody starts to answer and does not finish. The dots
           are given longer than either message took, because a pause is only
           a pause once it has gone on too long, and then they simply stop. */
        const d = punkte.current[CHAT.length];
        if (d)
          schreibe(d, "opacity", String(fenster(p, OHNE_ANTWORT.von, OHNE_ANTWORT.bis, 0.16, 0.3)));

        /* Logged off. The screen goes out under the second line, and its
           light goes with it: the one lit thing in the room is the thing
           that has just stopped. */
        const dunkel = stufe(p, 0.82, 0.94);
        const a0 = aus.current;
        if (a0) schreibe(a0, "opacity", String(dunkel));
        const s = schein.current;
        if (s) schreibe(s, "opacity", String((0.7 + klemme(0.5 - zx * 0.4) * 0.3) * (1 - dunkel)));
        // The keyboard and the screen, right of centre. The typing in the
        // room gets closer when the hand goes to them.
        HAND.naehe.netz =
          klemme(1 - Math.hypot(zx - 0.22, zy - 0.12) / 0.7) * fenster(p, 0.04, 0.96, 0.1, 0.1);
        setzeZeilen(zeilen, NETZ, p);
        setzeZeilen(schirmzeile, NETZ_SCHIRM, p);
      }}
      kinder={
        <>
          <Platte
            ref={platte}
            kinder={
              <>
                <Bild
                  name="06-master.webp"
                  alt="A teenager's bedroom at night, lit by a desk lamp and an old monitor, rain on the window"
                />
                <Bild ref={aus} name="06-off.webp" klasse="tlt-leer tlt-netz-off" />
                <span ref={schein} className="tlt-schirm-schein" aria-hidden />
                <Zeilen
                  zeilen={NETZ_SCHIRM}
                  refs={schirmzeile}
                  stelle="mitte"
                  klasse="tlt-auf-schirm"
                />
              </>
            }
          />
          <div ref={faden} className="tlt-chat" aria-hidden>
            {CHAT.map((c, i) => (
              <div key={c.text} className={`tlt-schlitz ${c.eigen ? "tlt-rechts" : "tlt-links"}`}>
                <div
                  ref={(el) => {
                    blasen.current[i] = el;
                  }}
                  className={`tlt-blase${c.eigen ? " tlt-blase-eigen" : ""}`}
                >
                  {[...c.text].map((z, j) => (
                    <span key={`${z}-${j}`} className="tlt-stueck">
                      {z}
                    </span>
                  ))}
                </div>
                <Punkte
                  ref={(el: HTMLDivElement | null) => {
                    punkte.current[i] = el;
                  }}
                />
              </div>
            ))}
            <div className="tlt-schlitz tlt-links tlt-schlitz-leer">
              <Punkte
                ref={(el: HTMLDivElement | null) => {
                  punkte.current[CHAT.length] = el;
                }}
              />
            </div>
          </div>
          <span className="tlt-flor" aria-hidden />
          <Zeilen zeilen={NETZ} refs={zeilen} klasse="tlt-text-frei" />
        </>
      }
    />
  );
}

function Punkte({ ref: aussen }: { ref?: React.Ref<HTMLDivElement> }) {
  return (
    <div ref={aussen} className="tlt-tippt">
      <span />
      <span />
      <span />
    </div>
  );
}

/* ---------------------------------------------------------------------- */
/* 07  Hallway                                                            */
/* ---------------------------------------------------------------------- */

const FLUR: Zeile[] = [
  { text: "The last time they waited for you at the door.", von: 0.24, bis: 0.8 },
];

/**
 * A hallway in the evening: an empty dog bed by the wall, the lead still on
 * its hook, and a rug where a collar was left.
 *
 * The quietest scene on the page and the longest for what is in it. One line,
 * held far past the point where a page would normally move on. Nobody is in
 * the room and nothing in it is out of place, which is the whole point: every
 * object is where the dog left it, and the collar only comes back while
 * somebody is looking for it. The camera sinks a couple of pixels towards
 * the rug and that is all that happens.
 */
function Flur() {
  const platte = useRef<HTMLDivElement>(null);
  const ding = useRef<HTMLImageElement>(null);
  const zeilen = useRef<(HTMLParagraphElement | null)[]>([]);
  const da = useRef(0);

  return (
    <Szene
      laenge={3.2}
      klasse="tlt-hall"
      regie={(p, zx, zy, maus) => {
        const t = performance.now() / 1000;
        fahre(platte.current, p, zx, zy, { von: 1.04, bis: 1.07, y: 0.5, zx: 0.14, zy: 0.1 });
        /* The collar lies where it was left. Near the hand it thins and
           flickers the way the bike does, the empty rug showing through it,
           and settles back when the hand moves on.

           On a phone, with no hand, it happens once by itself in the middle
           of the long line. */
        const hand =
          klemme(1 - Math.hypot(zx + 0.06, zy - 0.56) / 0.55) * fenster(p, 0.06, 0.96, 0.12, 0.12);
        const ziel = misch(fenster(p, 0.28, 0.82, 0.3, 0.35), hand, stufe(maus, 0.05, 0.9));
        da.current += (ziel - da.current) * 0.07;
        if (da.current < 0.004) da.current = 0;
        const d0 = ding.current;
        if (d0) schreibe(d0, "opacity", String(verblassen(da.current, t)));
        // And with it the dog it belonged to walks down the hall.
        HAND.naehe.flur = da.current;
        // The evening light through the door is the only living thing in
        // the picture, and it breathes about as much as a lamp in another
        // room does.
        belichte(platte.current, 0.8 + Math.sin(t * 0.19) * 0.02, 1.04, 0.98);
        setzeZeilen(zeilen, FLUR, p);
      }}
      kinder={
        <>
          <Platte
            ref={platte}
            verhaeltnis={1.7844}
            kinder={
              <>
                <Bild
                  name="07-plate.webp"
                  klasse="tlt-gestreckt"
                  alt="A hallway in evening light, an empty dog bed by the wall and a lead on a hook"
                />
                <Bild ref={ding} name="07-master.webp" klasse="tlt-ding tlt-flur-ding" />
              </>
            }
          />
          <span className="tlt-flor" aria-hidden />
          <Zeilen zeilen={FLUR} refs={zeilen} klasse="tlt-text-frei" />
        </>
      }
    />
  );
}

/* ---------------------------------------------------------------------- */
/* 08  Car ride                                                           */
/* ---------------------------------------------------------------------- */

const AUTO: Zeile[] = [
  { text: "The last time you fell asleep in the car.", von: 0.18, bis: 0.46 },
  { text: "Someone carried you inside.", von: 0.42, bis: 0.68 },
  { text: "One day you became too heavy.", von: 0.62, bis: 0.86 },
];

/**
 * The back seat at night, seen from under the blanket: rain on the window,
 * the town going past as lights, and the back of somebody driving.
 *
 * The camera is where the child is lying. Nobody in the picture is asleep,
 * because the one asleep is the one looking, which is what the first line
 * says without having to show it.
 *
 * Everything that moves here is light. The car holds still except for a
 * vibration too small to name, the kind a road puts into a body, and the
 * street goes past as a wipe of warm light across the glass.
 */
function Auto() {
  const platte = useRef<HTMLDivElement>(null);
  const zeilen = useRef<(HTMLParagraphElement | null)[]>([]);

  return (
    <Szene
      laenge={4}
      klasse="tlt-car"
      regie={(p, zx, zy) => {
        const t = performance.now() / 1000;
        // Sub pixel, and never on a beat. A regular shake is a machine.
        const zittern = Math.sin(t * 7.3) * 0.045 + Math.sin(t * 11.9) * 0.028;
        fahre(platte.current, p, zx, zy, {
          von: 1.04,
          bis: 1.07,
          x: 0.4 + zittern,
          y: zittern * 0.6,
          zx: 0.2,
          zy: 0.14,
        });
        /* The street lights thin out towards the end and stop. It used to
           come up into a warm morning here, and that was wrong twice over:
           the brightest moment of the page landed on its saddest line, and
           the two scenes after it had to climb back out of a mood they had
           not earned. It goes dark instead, and the room after it opens out
           of that dark. */
        const nacht = stufe(p, 0.6, 0.96);
        const takt = (t * 0.26) % 1;
        const vorbei = Math.pow(Math.sin(takt * Math.PI), 6) * (1 - nacht);
        belichte(
          platte.current,
          (misch(0.88, 1, stufe(p, 0, 0.3)) + vorbei * 0.12) * (1 - nacht * 0.4),
          1.06,
          1.04,
        );
        setzeZeilen(zeilen, AUTO, p);
      }}
      kinder={
        <>
          <Platte
            ref={platte}
            kinder={
              <Bild
                name="08-master.webp"
                alt="The view from the back seat of a car at night, rain on the window and street lights outside"
              />
            }
          />
          <span className="tlt-flor" aria-hidden />
          <Zeilen zeilen={AUTO} refs={zeilen} />
        </>
      }
    />
  );
}

/* ---------------------------------------------------------------------- */
/* 09  The hand                                                           */
/* ---------------------------------------------------------------------- */

const HAENDE: Zeile[] = [
  { text: "The last time you held someone's hand.", von: 0.24, bis: 0.56 },
  { text: "You did not know it was goodbye.", von: 0.52, bis: 0.86 },
];

/**
 * A younger hand over an old one, on a bed in the morning.
 *
 * The line says held, and the picture has to be holding. Two hands a finger
 * apart said something else, a near miss, and the second line is not about
 * a near miss: it is about having been there, hand in hand, and not knowing
 * what the morning was. The age of the one underneath does the rest of the
 * work without a word about it.
 *
 * One photograph, like every other scene, and nothing in it moves. The scene
 * does what a camera would do: it arrives out of the dark and out of focus,
 * finds them, and holds; the light on them cools across the scene and the
 * frame closes in by five percent.
 */
function Haende() {
  const platte = useRef<HTMLDivElement>(null);
  const licht = useRef<HTMLSpanElement>(null);
  const zeilen = useRef<(HTMLParagraphElement | null)[]>([]);

  return (
    <Szene
      laenge={3.8}
      klasse="tlt-hands"
      regie={(p, zx, zy) => {
        fahre(platte.current, p, zx, zy, { von: 1.09, bis: 1.04, x: 0.3, zx: 0.18, zy: 0.12 });
        // Out of focus on arrival, sharp by the time the line lands, and it
        // never goes soft again.
        const scharf = stufe(p, 0.02, 0.34);
        /* Exposed down about a stop from what the photograph was given.

           Left as it came, the bedding is the brightest thing on the page by
           a distance, and white type over it has to be rescued with a veil
           behind the words. Taken down, the room is still daylight, the line
           simply sits on it, and the morning stops shouting over the quietest
           moment in the piece. */
        belichte(
          platte.current,
          misch(0.66, 0.78, scharf) * (1 - stufe(p, 0.72, 1) * 0.16),
          misch(0.98, 1.08, scharf),
          misch(1.04, 0.92, stufe(p, 0.5, 1)),
          (1 - scharf) * 7,
        );
        /* The sun leaves them. It is the last thing that happens in this
           scene and the only thing that happens to the hands at all: the
           warmth goes off the bed, the colour goes with it, and what is left
           is two hands in a room that is no longer morning. */
        const l = licht.current;
        if (l) {
          schreibe(l, "opacity", String(misch(0.55, 0.08, stufe(p, 0.3, 0.94))));
          schreibe(l, "transform", `translate(-50%, -50%) translate3d(${zx * 3}%, ${zy * 2}%, 0)`);
        }
        setzeZeilen(zeilen, HAENDE, p);
      }}
      kinder={
        <>
          <Platte
            ref={platte}
            verhaeltnis={1.7768}
            kinder={
              <Bild
                name="09-master.webp"
                alt="A younger hand holding an old hand on pale bedding in morning light"
              />
            }
          />
          <span
            ref={licht}
            className="tlt-licht"
            style={{
              left: "62%",
              top: "22%",
              background:
                "radial-gradient(circle at 50% 50%, rgba(255, 226, 188, 0.42) 0%, rgba(255, 216, 176, 0.13) 34%, rgba(255, 216, 176, 0) 62%)",
            }}
            aria-hidden
          />
          <Zeilen zeilen={HAENDE} refs={zeilen} />
        </>
      }
    />
  );
}

/* ---------------------------------------------------------------------- */
/* 10  Now                                                                */
/* ---------------------------------------------------------------------- */

/* One after another in the same place, so the windows meet end to end:
   each line is gone at the moment the next one begins. */
const JETZT: Zeile[] = [
  { text: "Some last times have not happened yet.", von: 0.1, bis: 0.3 },
  { text: "You just do not know which ones.", von: 0.3, bis: 0.47 },
  { text: "What would you do differently if you knew?", von: 0.47, bis: 0.64 },
];

/* The two lines the page ends on. They arrive on the black the room went out
   into and they do not leave again, because there is nothing after them to
   leave for: the scroll runs out a moment later and somebody is standing
   there reading. */
const SCHLUSS: Zeile[] = [
  { text: "Maybe nothing.", von: 0.66, bis: 0.86, klein: true, haelt: true },
  { text: "Maybe that is the point.", von: 0.8, bis: 1, klein: true, haelt: true },
];

/**
 * A flat in the middle of an ordinary afternoon.
 *
 * Every room before this one was dark, warm and half hidden, and had to be
 * found. This one is just a room, in daylight: two cups on the table, a
 * jacket over a chair, the door to the next room open. Somebody lives here
 * and has just stepped out of the picture, which is where every one of the
 * earlier rooms began. That difference is the argument the whole page has
 * been making, and it has to be visible before the text says anything.
 *
 * The things in it are still discovered, but by daylight rather than by a
 * torch: a band of brighter exposure crosses the room once, and whatever it
 * passes over comes up as it goes by. Nothing moves, nothing floats and
 * nothing is added. It is the same photograph twice, one copy a stop
 * brighter, with a soft band deciding where the brighter one shows.
 *
 * Then the room goes out, and the last two lines are read on nothing. They
 * are the only ones on the page that are not about a room, and leaving them
 * over the furniture of one made them look like a caption to it.
 *
 * It is the longest scene on the page by half a screen, and it has to be.
 * Five lines land here where every other scene has two, and given the same
 * room as the others each one gets three fifths of the time a memory gets.
 * The end then arrives faster than everything that led up to it, which is
 * the wrong way round for a question somebody is meant to sit with.
 */
function Jetzt({ zurueck }: { zurueck: () => void }) {
  const platte = useRef<HTMLDivElement>(null);
  const licht = useRef<HTMLSpanElement>(null);
  const schwarz = useRef<HTMLSpanElement>(null);
  const zeilen = useRef<(HTMLParagraphElement | null)[]>([]);
  const schluss = useRef<(HTMLParagraphElement | null)[]>([]);
  const nochmal = useRef<HTMLButtonElement>(null);

  return (
    <Szene
      letzte
      laenge={5.4}
      klasse="tlt-now"
      regie={(p, zx, zy) => {
        fahre(platte.current, p, zx, zy, { von: 1.05, bis: 1.02, x: -0.4, zx: 0.16, zy: 0.1 });
        /* It comes up out of the dark the night before it ended on, settles,
           and then simply stays where it is. Like the bedroom before it, it
           settles well below what the photograph was given: this is the one
           room on the page in daylight, and it only has to be brighter than
           the eight rooms before it, not bright. A little more contrast on
           the way down keeps it from going grey instead of going dim. */
        belichte(
          platte.current,
          misch(0.4, 0.53, stufe(p, 0.02, 0.26)),
          misch(0.98, 1.08, stufe(p, 0, 0.26)),
          1.02,
        );

        // Daylight, which does not have to be found. It only warms whatever
        // it passes over, and barely: any stronger and it lies over the
        // middle of the room as a pale haze, right where the lines stand.
        const l = licht.current;
        if (l) {
          schreibe(l, "opacity", String(stufe(p, 0.04, 0.22) * 0.2));
          schreibe(l, "transform", `translate(-50%, -50%) translate3d(${zx * 8}%, ${zy * 5}%, 0)`);
        }
        // The room goes out under the last question and the two lines after
        // it stand on nothing.
        const sw = schwarz.current;
        if (sw) schreibe(sw, "opacity", String(stufe(p, 0.54, 0.72)));
        setzeZeilen(zeilen, JETZT, p);
        setzeZeilen(schluss, SCHLUSS, p);
        /* Late, and quietly. It is an offer and not an instruction: it
           belongs after the last line has been read rather than beside it,
           and anything brighter than the two sentences above it would be the
           loudest thing on a page that has just gone quiet. */
        const n = nochmal.current;
        if (n) {
          const a = stufe(p, 0.955, 0.995);
          schreibe(n, "opacity", String(a));
          n.style.pointerEvents = a > 0.6 ? "auto" : "none";
        }
      }}
      kinder={
        <>
          <Platte
            ref={platte}
            kinder={
              <Bild
                name="10-master.webp"
                alt="A lived-in flat on an ordinary afternoon, two cups on the table and a jacket over a chair"
              />
            }
          />
          <span
            ref={licht}
            className="tlt-licht"
            style={{
              left: "40%",
              top: "34%",
              background:
                "radial-gradient(circle at 50% 50%, rgba(255, 244, 222, 0.42) 0%, rgba(255, 238, 210, 0.12) 34%, rgba(255, 238, 210, 0) 64%)",
            }}
            aria-hidden
          />
          <span ref={schwarz} className="tlt-schwarz" aria-hidden />
          <Zeilen zeilen={JETZT} refs={zeilen} stelle="mitte" klasse="tlt-text-stapel" />
          <Zeilen zeilen={SCHLUSS} refs={schluss} stelle="mitte" klasse="tlt-text-schluss" />
          <button ref={nochmal} type="button" className="tlt-nochmal" onClick={zurueck}>
            Start again
          </button>
        </>
      }
    />
  );
}

/* ---------------------------------------------------------------------- */
/* The page                                                               */
/* ---------------------------------------------------------------------- */

/* Which room sound belongs to which stretch of the page, as a share of the
   whole scroll. The boundaries are set a little off the scene boundaries on
   purpose: a crossfade that lands exactly on a cut announces the cut.

   Two stretches have no room in them, and that is a decision rather than a
   gap. The opening is black with three lines on it and has no room to be in,
   and the hallway with the collar is the one scene about an empty house: a
   room tone under it would fill exactly the emptiness it is about. The piano
   keeps running underneath both, so they are quiet rather than dead.

   The hallway does get one sound, and only one: a dog walking through it.
   The collar is on the floorboards and nobody is in the corridor, so the one
   thing you can hear there is the one thing that is not in the picture. It
   runs as a detail over no room at all, which is what leaves it standing on
   its own the way it has to.

   The field is heard three times: under the swing, under the walk through
   the long grass, which is the same evening in another meadow, and under the
   last two scenes, a bedroom in the morning and a flat with a window onto
   green. Those last two are the one place on the page where a sound is doing
   a job it was not recorded for, and it holds because both rooms are open
   to the outside; a morning room tone and a garden would be better and are
   the two files still missing. */
/* Every scene with something to find in it also has something to hear when
   the hand finds it. `hand` names the scene's entry in HAND, `auf` says
   which of the two tracks answers.

   Where the scene has a detail of its own, the detail is what answers: it
   sits low while the hand is elsewhere and comes up past its own level on
   the thing it belongs to. The freewheel ticks when the hand is on the
   bike, the voices come through the glass when it is on the mirror, the dog
   walks down the hall when it is on the collar. Where the scene has only a
   room, the room itself leans in: the laughter at the table rises when
   somebody's place is found, the typing gets closer at the keyboard. The
   room never drops out for that, it only comes forward. */
type Ton = {
  bis: number;
  grund: string | null;
  detail?: string;
  hand?: string;
  auf?: "grund" | "detail";
};
/* Where each stretch ends, in screens of scroll from the top. A scene is
   held for its length less one screen, so the page is 31.2 screens of
   scroll long; each boundary sits about a third of a screen after the next
   scene has taken the frame. Change a scene's length and these move with
   it. They are compared against the scroll as a share of the whole, so a
   phone whose window grows when its address bar hides still lands every
   room in its own scene. */
const TONFOLGE_LAENGE = 31.2;
const TONFOLGE: Ton[] = [
  { bis: 2.35, grund: null },
  { bis: 5.58, grund: "01_swing_ambient", detail: "01_chain_creak" },
  { bis: 7.78, grund: "02_bike_ambient", detail: "02_freewheel", hand: "rad", auf: "detail" },
  { bis: 10.38, grund: "01_swing_ambient" },
  { bis: 13.18, grund: "03_table_ambient", hand: "tisch", auf: "grund" },
  { bis: 15.6, grund: "05_elevator_ambient", detail: "05_voices", hand: "lift", auf: "detail" },
  { bis: 19.2, grund: "06_internet_ambient", hand: "netz", auf: "grund" },
  { bis: 21.48, grund: null, detail: "07_dog", hand: "flur", auf: "detail" },
  { bis: 24.4, grund: "08_car_ambient" },
  { bis: 27.05, grund: "01_swing_ambient" },
  { bis: 99, grund: "01_swing_ambient" },
];

/* How far the hand moves a track, as a factor on its measured level. A
   detail can go almost silent and come up well past its own level, because
   it is a single thing being found. A room only leans: it stays most of the
   way there when nothing is touched, or the scene would sound empty to
   anyone who has not found its one thing yet. */
const REICHWEITE = { detail: [0.28, 1.7], grund: [0.72, 1.45] } as const;

/**
 * Makes the grain tile once and hands it to the element: 256 pixels square,
 * each on its own a little lighter or darker, most of them close to nothing,
 * shown at 256 device pixels so one grain is one pixel on any display.
 */
function useKorn(staerke: number) {
  const korn = useRef<HTMLSpanElement>(null);
  useEffect(() => {
    const el = korn.current;
    if (!el) return;
    const n = 256;
    const leinwand = document.createElement("canvas");
    leinwand.width = n;
    leinwand.height = n;
    const g = leinwand.getContext("2d");
    if (!g) return;
    const bild = g.createImageData(n, n);
    const d = bild.data;
    for (let i = 0; i < n * n; i += 1) {
      const v = Math.random() * 2 - 1;
      const hell = v > 0 ? 255 : 0;
      d[i * 4] = hell;
      d[i * 4 + 1] = hell;
      d[i * 4 + 2] = hell;
      d[i * 4 + 3] = Math.round(v * v * staerke);
    }
    g.putImageData(bild, 0, 0);
    const pixel = Math.max(1, window.devicePixelRatio || 1);
    el.style.backgroundImage = `url(${leinwand.toDataURL("image/png")})`;
    el.style.backgroundSize = `${n / pixel}px ${n / pixel}px`;
  }, [staerke]);
  return korn;
}

/** How long the curtain takes to close before the page starts again, and to open after. */
const VORHANG_ZU = 600;
const VORHANG_AUF = 900;

/**
 * One visit: every scene, the loop that drives them, the grain and the
 * sound. Built anew, under a new key, each time somebody starts again, so
 * nothing from the last visit is carried into the next one: not a timer, not
 * the swing's momentum, not a half faded track or a decoded file.
 */
function Erlebnis({
  ruhig,
  tonAn,
  setTonAn,
  ruestung,
  neustart,
}: {
  ruhig: boolean;
  tonAn: boolean;
  setTonAn: (f: (v: boolean) => boolean) => void;
  ruestung: Ruestung | null;
  neustart: (r: Ruestung | null) => void;
}) {
  const bahn = useBahn(ruhig);
  const ton = useTon(tonAn, setTonAn, ruestung);
  const korn = useKorn(22);
  const einmal = useRef(false);

  /* The few things the scenes and the sound share outside React start from
     nothing as well, or the bell would think the hand was still on the bike
     from the last visit. */
  useEffect(() => {
    HAND.maus = 0;
    HAND.naehe = {};
    SCHWUNG.wert = 0;
  }, []);

  // The ambience follows the position on the page, not the scene component,
  // so it keeps playing across a transition instead of stopping at a seam.
  const { bereit, klavier, wuensche, betone } = ton;
  useEffect(() => {
    if (!bereit) return;
    let frame = 0;
    let klingelBereit = true;
    let klingelZuletzt = 0;
    const takt = () => {
      frame = requestAnimationFrame(takt);
      const h = document.documentElement;
      const p = h.scrollTop / Math.max(1, h.scrollHeight - window.innerHeight);
      const s = p * TONFOLGE_LAENGE;
      const treffer = TONFOLGE.find((t) => s < t.bis) ?? TONFOLGE[TONFOLGE.length - 1]!;
      /* The bell. Once when the bike arrives, and not again until it has
         gone and a couple of seconds have passed: a bell that rings on every
         twitch of the mouse is a toy. */
      const rad = treffer.hand === "rad" ? (HAND.naehe.rad ?? 0) : 0;
      const jetzt = performance.now();
      if (rad > 0.55 && klingelBereit && jetzt - klingelZuletzt > 2600) {
        klingelBereit = false;
        klingelZuletzt = jetzt;
        KLINGEL.spiele?.();
      } else if (rad < 0.2) {
        klingelBereit = true;
      }
      /* Said every frame, acted on only when it changes. */
      wuensche("grund", treffer.grund);
      wuensche("detail", treffer.detail ?? null);
      /* The rope only sounds when the swing is moving. Under it a little of
         it stays, because a swing in the wind is never completely quiet and
         a rope that switches off would be a light switch. */
      if (treffer.detail === "01_chain_creak") {
        betone("detail", 0.1 + SCHWUNG.wert * 1.7);
        betone("grund", 1);
      } else if (treffer.hand && treffer.auf) {
        const [unten, oben] = REICHWEITE[treffer.auf];
        const nah = HAND.naehe[treffer.hand] ?? 0;
        // Without a mouse there is nothing to find, so nothing is held back.
        betone(treffer.auf, misch(1, misch(unten, oben, nah), HAND.maus));
        betone(treffer.auf === "grund" ? "detail" : "grund", 1);
      } else {
        betone("grund", 1);
        betone("detail", 1);
      }
      // The piano belongs to the page rather than to a scene, so it is asked
      // for once and never changes. Its level lives in PEGEL with the rest.
      wuensche("klavier", klavier ? "00_piano" : null);
    };
    frame = requestAnimationFrame(takt);
    return () => cancelAnimationFrame(frame);
  }, [bereit, klavier, wuensche, betone]);

  /* Once per visit, however often it is clicked. The sound goes down at
     once, and if it was on, the device for the next visit is made now,
     inside the click, because later a phone would no longer allow it. */
  const zurueck = useCallback(() => {
    if (einmal.current) return;
    einmal.current = true;
    ton.verstummen();
    neustart(ton.an && ton.bereit ? ruesteTon() : null);
  }, [ton, neustart]);

  return (
    <BahnKontext.Provider value={bahn}>
      <Intro ruhig={ruhig} />
      <Schaukel />
      <Rad />
      <Weg />
      <Tisch />
      <Aufzug />
      <Netz />
      <Flur />
      <Auto />
      <Haende />
      <Jetzt zurueck={zurueck} />

      {/* Grain over everything. Not scaled, so one grain sits on one pixel:
          stretched it goes soft and reads as dirt on the screen rather than
          as film. */}
      <span ref={korn} className="tlt-korn" aria-hidden />

      {ton.bereit && (
        <button
          type="button"
          className="tlt-ton"
          onClick={ton.schalten}
          aria-pressed={ton.frei && ton.an}
        >
          {ton.frei ? (ton.an ? "sound on" : "sound off") : ton.an ? "enable sound" : "sound off"}
        </button>
      )}
    </BahnKontext.Provider>
  );
}

export function TheLastTimePage() {
  const [ruhig, setRuhig] = useState(false);
  /* Whether the person wants sound, which outlives a visit: it is the one
     thing they chose, and starting again should not undo it. On by default,
     so the first click anywhere on the page starts it; whether the browser
     lets it play yet is kept apart from this, in the sound hook. */
  const [tonAn, setTonAn] = useState(true);
  const [lauf, setLauf] = useState<{ n: number; ruestung: Ruestung | null }>({
    n: 0,
    ruestung: null,
  });
  const vorhang = useRef<HTMLSpanElement>(null);
  const unterwegs = useRef(false);

  useEffect(() => {
    const abfrage = window.matchMedia("(prefers-reduced-motion: reduce)");
    const setzen = () => setRuhig(abfrage.matches);
    setzen();
    abfrage.addEventListener("change", setzen);
    return () => abfrage.removeEventListener("change", setzen);
  }, []);

  useEffect(() => {
    // Without a hash the page starts at the beginning. A memory experience
    // that opens in the middle because the browser restored a position is a
    // different experience.
    if (!window.location.hash) window.scrollTo(0, 0);
  }, []);

  /* Starting again.

     The curtain closes over everything, the page jumps to its top while
     nobody can see it and is built anew under a new key, and the curtain
     opens on the black of the opening, where the first line then arrives
     by itself exactly as it did the first time.

     The jump is instant, whatever the host page does with scrolling. Smooth
     scrolling in a stylesheet is switched off for the moment of the jump,
     and because a script that eases the scroll itself can still be carrying
     the page towards where it was, the curtain only opens once the page has
     been at the top for three frames in a row. Without motion there is no
     curtain: the page simply starts again at the top. */
  const neustart = useCallback(
    (ruestung: Ruestung | null) => {
      if (unterwegs.current) {
        void ruestung?.kontext.close().catch(() => {});
        return;
      }
      unterwegs.current = true;
      const v = vorhang.current;
      const springen = () => {
        const html = document.documentElement;
        const vorher = html.style.scrollBehavior;
        html.style.scrollBehavior = "auto";
        window.scrollTo({ top: 0, left: 0, behavior: "instant" });
        setLauf((l) => ({ n: l.n + 1, ruestung }));
        let oben = 0;
        let runden = 0;
        const pruefen = () => {
          runden += 1;
          if (window.scrollY > 0) {
            window.scrollTo({ top: 0, left: 0, behavior: "instant" });
            oben = 0;
          } else oben += 1;
          if (oben < 3 && runden < 120) {
            requestAnimationFrame(pruefen);
            return;
          }
          html.style.scrollBehavior = vorher;
          if (v) {
            v.style.transition = `opacity ${VORHANG_AUF}ms ease`;
            v.style.opacity = "0";
          }
          window.setTimeout(() => {
            if (v) v.style.pointerEvents = "";
            unterwegs.current = false;
          }, VORHANG_AUF);
        };
        requestAnimationFrame(pruefen);
      };
      if (ruhig || !v) {
        springen();
        return;
      }
      v.style.pointerEvents = "auto";
      v.style.transition = `opacity ${VORHANG_ZU}ms ease`;
      v.style.opacity = "1";
      window.setTimeout(springen, VORHANG_ZU + 30);
    },
    [ruhig],
  );

  return (
    <div className={`the-last-time${ruhig ? " tlt-ruhig" : ""}`}>
      <style>{STILE}</style>
      <Erlebnis
        key={lauf.n}
        ruhig={ruhig}
        tonAn={tonAn}
        setTonAn={setTonAn}
        ruestung={lauf.ruestung}
        neustart={neustart}
      />
      <span ref={vorhang} className="tlt-vorhang" aria-hidden />
    </div>
  );
}

export default TheLastTimePage;
FAQ

Häufige Fragen

Eine kuratierte Landingpage aus aufeinander abgestimmten Sections der Library, in sinnvoller Reihenfolge von Hero bis Footer. Du kopierst die Sections einzeln und setzt sie mit dem Kompositions-Snippet zusammen.

Deine Frage war nicht dabei? Wir helfen gern weiter.

Kontakt aufnehmen