/*
 * THE ENVIRONMENT.
 *
 * Reading order here is also stacking order: bed, plate, prop depth layers,
 * shadow, objects, shadow again, atmosphere.
 *
 * The one idea worth explaining: the photographic plate is a square, and screens
 * are not, so on any wide monitor the square cannot fill the frame without
 * throwing away the corners of the composition — which is where every prop in
 * the reference lives. Instead the square is framed at full height and the empty
 * remainder is filled with a *procedural* surface (.bed) tuned to the plate's own
 * edge colour, with the plate's edges feathered into it. A wide screen therefore
 * reads as more tabletop, not as letterboxing.
 */

/* ─────────────────────────────────────────────────────────────────────── bed */

/*
 * The bed is not a CSS approximation of the surface — it *is* the surface. A
 * clean patch of the plate's empty centre is mirror-tiled into a seamless
 * texture at build time (tools/build-assets.mjs), so the plaster continuing past
 * the edge of the composition is the same plaster, photographed under the same
 * light. Only the lighting is added in CSS, on top, so it can be re-aimed per
 * breakpoint.
 */
/*
 * The bed is the floor of last resort: it is only visible if the camera pulls
 * back far enough that even the bleed runs out. Flat colour plus tooth.
 */
/*
 * The surface-extension layers are always on now.
 *
 * They used to be switched off wherever the plate covered the frame. Since the
 * camera travels down the letter — well past the bottom edge of the photograph —
 * there is no longer any framing in which the surface is guaranteed to reach.
 */

.bed {
  position: absolute;
  inset: 0;
  z-index: 0;
  background-color: var(--c-surface-deep);
  background-image: url('../../assets/surface.webp');
  background-size: 64vmax 64vmax;
  background-position: 32% 28%;
}

/*
 * THE BLEED — the surface continuing past the photograph.
 *
 * Twice the stage, centred on it. The texture is the plate itself, mirrored
 * outward from its own edges at build time and blurred, so the colour, the
 * lighting and the direction of the falloff all continue correctly without
 * anyone having to match them by hand. Its middle half is hidden behind the
 * plate; only the border is ever seen.
 *
 * It lives inside the camera because it is part of the world — it has to move
 * and scale with everything else, or the illusion breaks the moment the camera
 * pushes in.
 */
.bleed {
  position: absolute;
  left: 50%;
  top: 50%;
  width: calc(var(--stage) * 2);
  height: calc(var(--stage) * 2);
  transform: translate(-50%, -50%);
  background-image: url('../../assets/bleed.webp');
  background-size: 100% 100%;
  background-position: center;
}

/*
 * Falloff. The margin is not pretending to be more photograph — it is the same
 * surface going out of focus and out of the light, which is what actually
 * happens past the edge of a lit set. Anchored to the stage, so the darkening
 * begins exactly where the photograph ends however the frame is shaped.
 */
.bleed::before {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(
    closest-side at 50% 50%,
    transparent 46%,
    hsl(var(--shadow-hue) / 0.12) 58%,
    hsl(var(--shadow-hue) / 0.3) 78%,
    hsl(var(--shadow-hue) / 0.46) 100%
  );
}

/*
 * Beyond the photograph, the surface settles to plain plaster.
 *
 * The bleed is built by extending the plate's edge pixels outward, and along the
 * bottom edge those pixels are lace and rose — so travelling far enough past the
 * plate turned the surround into a dark brown band. Fading it to the surface's
 * own colour past the plate's boundary keeps it reading as more table.
 */
.bleed::before {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(
    closest-side at 50% 50%,
    transparent 34%,
    var(--c-surface-deep) 92%
  );
}

/* The leaf shadow, continuing off the plate and across the extended surface. */
.bleed::after {
  content: '';
  position: absolute;
  inset: 0;
  background-image: url('../../assets/dapple.webp');
  background-size: 100% 100%;
  background-repeat: no-repeat;
  mix-blend-mode: multiply;
  opacity: calc(var(--dapple-opacity) * 0.8 * var(--dapple-amount, 1));
  animation: dapple-drift 54s cubic-bezier(0.45, 0, 0.55, 1) infinite alternate;
}

/*
 * A whisper of extra light. The bleed already carries the photograph's own
 * lighting, so this only has to keep the far edges of a very wide monitor from
 * going flat — anything stronger fights the plate and reintroduces the seam.
 */
.bed::before {
  content: '';
  position: absolute;
  inset: 0;
  background:
    radial-gradient(110% 90% at 18% 4%, hsl(30 80% 90% / 0.22) 0%, transparent 62%),
    radial-gradient(150% 130% at 55% 44%, transparent 46%, hsl(12 38% 50% / 0.16) 100%);
}


/* ─────────────────────────────────────────────────────────────────── camera */

/*
 * THE CAMERA.
 *
 * Note what is *not* here: `will-change: transform`.
 *
 * Promoting this element would hand the compositor one big cached texture of the
 * entire scene, and every push-in would then magnify that texture rather than
 * redraw it — which is cheap, and which is why the envelope and the handwriting
 * looked soft. Leaving it unpromoted costs a re-raster per frame, and the price
 * of that is exactly why the prop duplicates, the SVG filters, the blend layers
 * and the full-stage masks all had to go. Sharp is worth paying for; it is not
 * worth paying twice.
 */
.camera {
  position: absolute;
  inset: 0;
  z-index: 1;
  transform: translate3d(var(--cam-x), var(--cam-y), 0) scale(var(--cam-scale-step))
    rotate(var(--cam-roll));
  /* No will-change here — but see the .is-page override below. */
  /*
   * A pure 2D transform. No perspective, no preserve-3d.
   *
   * Those two used to live here, on the theory that one shared vanishing point
   * is more physically honest. It is — and it also forces the browser to flatten
   * the entire stage into a single render surface and re-rasterise all 2.2
   * million pixels of it on every frame the camera moves. That was the
   * difference between 30fps and 60fps while scrolling.
   *
   * The envelope and the sheet each own their own perspective now (see
   * objects.css). Two vanishing points a few degrees apart, which no one can
   * see, in exchange for a frame rate everyone can feel.
   */
}

/*
 * A plain wrapper.
 *
 * This briefly carried the fractional half of a split zoom: the camera above
 * would take a quantised scale and this element the leftover, on a promoted
 * layer, in the hope that the scene would rasterise at the coarse scale and the
 * compositor would apply the remainder for free.
 *
 * It does not work, and the reason is worth keeping. A composited layer is
 * rasterised at its *screen* scale, and `will-change: transform` does not pin
 * that — the moment the residual moved every frame, the screen scale moved every
 * frame and the re-rasterisation came straight back. Measured across two runs,
 * quantising the screen scale helps and any continuous residual cancels the
 * benefit exactly. There is no free lunch here; the zoom rate itself is the cost.
 */
/*
 * The exception to the no-promotion rule above, and it is the same measurement
 * read forward: a promoted layer pays off exactly when its screen scale is
 * constant, and in the page act the camera's scale is a constant 1. Scrolling
 * the letter becomes a compositor translate of one cached texture instead of a
 * full-viewport repaint per scrolled frame — and reading is nothing but
 * scrolling, so this is where the piece now spends its life. The translate is
 * snapped to whole pixels in main.js so the cache is never resampled.
 */
.is-page .camera {
  will-change: transform;
}

.camera__fine {
  position: absolute;
  inset: 0;
}

/*
 * Centred with a transform rather than by grid or flex alignment. Both of those
 * silently fall back to start-alignment when the item is larger than its
 * container — a safety rule to stop content becoming unreachable — and the stage
 * is deliberately larger than the viewport almost always. The symptom is subtle
 * and easy to misread: the composition just sits slightly wrong.
 */
.stage {
  position: absolute;
  left: 50%;
  top: 50%;
  width: var(--stage);
  height: var(--stage);
  transform: translate(-50%, -50%);
}

/* ────────────────────────────────────────────────────────────────────  plate */

/*
 * TERRAIN — the plate and its depth layers, feathered as one.
 *
 * The feather is per-axis, and only wide on the axis where a margin actually
 * exists: vertical in portrait, horizontal on ultrawide, neither on an ordinary
 * landscape screen where the stage covers the frame.
 *
 * This matters because the props in this photograph run right up to its edges.
 * Feathering all four sides equally would dissolve the roses and the gypsophila
 * to hide a seam that, on most screens, is not even on screen. Fade only the
 * edge you can actually see, and let it read as focus falling off.
 */
/*
 * Deliberately NOT promoted — and this reverses an earlier decision, so it is
 * worth saying why.
 *
 * The theory was that a promoted layer is rasterised once and then cheaply
 * magnified by the compositor. That holds when a layer's scale is *constant*.
 * Here the camera's zoom changes on every single frame, and a composited layer
 * whose screen scale keeps changing is re-rasterised at each new scale — the
 * cache never gets reused, and you pay for the promotion without ever collecting
 * on it.
 *
 * Measured over the same scripted scroll: promoted, 33.2ms median with a 433ms
 * worst frame; unpromoted, 17.1ms median with a 149ms worst. Faster and sharper,
 * because now it rasterises at the scale it is actually displayed at.
 */
.terrain {
  position: absolute;
  inset: 0;
}

/*
 * The mask exists only where a margin does. Masking a layer this size is a
 * separate compositing pass on every frame the camera moves, so on an ordinary
 * landscape screen — where the stage covers the viewport and the feather is
 * off-frame anyway — there is simply no mask at all.
 */
/* Portrait is now the only shape with a visible plate edge — landscape covers
   at every aspect (see viewport.js) — so it is the only shape that pays for a
   mask. */
@media (orientation: portrait) {
  .terrain {
    --feather-x: 2.6%;
    --feather-y: 11%;
    -webkit-mask-image: linear-gradient(
        to right,
        transparent 0,
        #000 var(--feather-x),
        #000 calc(100% - var(--feather-x)),
        transparent 100%
      ),
      linear-gradient(
        to bottom,
        transparent 0,
        #000 var(--feather-y),
        #000 calc(100% - var(--feather-y)),
        transparent 100%
      );
    mask-image: linear-gradient(
        to right,
        transparent 0,
        #000 var(--feather-x),
        #000 calc(100% - var(--feather-x)),
        transparent 100%
      ),
      linear-gradient(
        to bottom,
        transparent 0,
        #000 var(--feather-y),
        #000 calc(100% - var(--feather-y)),
        transparent 100%
      );
    -webkit-mask-composite: source-in;
    mask-composite: intersect;
  }
}

.plate {
  position: absolute;
  inset: 0;
  background-image: var(--plate-url);
  background-size: 100% 100%;
  background-repeat: no-repeat;
}

/* ─────────────────────────────────────────────────────────────────── dapple */

/*
 * A second layer of leaf shadow, drifting over the one baked into the plate.
 * This is what makes the room feel like it has a window and a breeze in it.
 *
 * Critically: the texture was generated once at build time. Nothing here runs an
 * SVG filter at runtime — the whole effect is one texture being translated on
 * the compositor, which costs essentially nothing.
 */
/*
 * The falloff that used to be a mask-image is baked into the texture's alpha at
 * build time instead. A mask on a layer this large is a separate compositing
 * pass on every frame the camera moves; a texture that already fades out is free.
 */
.dapple {
  position: absolute;
  inset: -5%;
  /* Cached: it only ever drifts, so the compositor can keep one texture and move
     it. Without this the browser repaints a stage-sized blend layer per frame. */
  will-change: transform;
  background-image: url('../../assets/dapple.webp');
  /* One stretched copy, not a repeat: the falloff is baked into the texture, and
     a baked falloff cannot tile without showing a grid. */
  background-size: 100% 100%;
  background-repeat: no-repeat;
  /*
   * Plain alpha, not multiply — and this one line is 30fps.
   *
   * A stage-sized mix-blend-mode layer has to be re-blended against everything
   * beneath it every time the camera moves, and cumulative ablation put the
   * entire frame deficit here: 33.5ms with it, 17.1ms without, while every other
   * layer in the scene measured free.
   *
   * The visual difference is nil. The texture is a dark violet-grey with its
   * coverage in the alpha channel, at 12% layer opacity. Over the plate's
   * (223,179,163) that is 206 under multiply and 207 under normal — a single
   * value out of 255, in a shadow.
   */
  pointer-events: none;
}

.dapple--under {
  z-index: 3;
  opacity: calc(var(--dapple-opacity) * var(--dapple-amount, 1));
  animation: dapple-drift 54s cubic-bezier(0.45, 0, 0.55, 1) infinite alternate;
}

/*
 * There used to be a second dapple layer here, drawn over the objects so the
 * leaf shadow fell across the envelope too. It was a lovely detail and it cost a
 * second full-stage multiply composite on every frame. The objects carry their
 * own raking light instead (see .paper__light and the envelope's gradients),
 * which is most of the effect for none of the cost.
 */

@keyframes dapple-drift {
  from {
    transform: translate3d(-0.8%, -0.5%, 0) scale(1);
  }
  to {
    transform: translate3d(1%, 0.8%, 0) scale(1.04);
  }
}

/* ───────────────────────────────────────────────────────────────── petals */

.petals {
  position: absolute;
  inset: 0;
  z-index: 4;
  pointer-events: none;
}

.petal {
  position: absolute;
  width: calc(var(--stage) * var(--size, 0.011));
  aspect-ratio: 1.5;
  border-radius: 60% 40% 55% 45% / 62% 58% 42% 38%;
  background: linear-gradient(140deg, #f4c0b3 0%, #e6a496 55%, #d18b7c 100%);
  opacity: 0;
  /* box-shadow, not drop-shadow: it follows the border-radius just as well and
     does not put a filter inside the subtree the camera rescales. */
  box-shadow: 1px 2px 3px hsl(var(--shadow-hue) / 0.3);
  /*
   * Each petal gets its own layer. They are ten pixels across, but they live
   * inside the stage, and moving anything inside an unpromoted stage
   * invalidates the raster of the whole 1483px-square surface it sits on — eleven
   * tiny elements were costing as much per frame as the entire dust canvas.
   * Promoting them makes their movement a compositor transform instead.
   */
  will-change: transform, opacity;
}

/* ─────────────────────────────────────────────────────────── atmosphere */

.motes {
  position: absolute;
  inset: 0;
  z-index: 10;
  pointer-events: none;
  opacity: 0.85;
  /* The backing store is half size (see particles.js); let the GPU smooth it
     rather than showing half-resolution pixel edges. */
  image-rendering: auto;
}

/*
 * A soft warm haze where the light enters.
 *
 * Plain alpha, not screen blending. A full-viewport blend layer forces the whole
 * frame through a second compositing pass every time anything under it moves,
 * and against this palette the difference between screen and a warm translucent
 * wash is not visible — it was costing frames for nothing.
 */
.bloom {
  position: absolute;
  inset: 0;
  z-index: 11;
  pointer-events: none;
  background: radial-gradient(
    58% 48% at 20% 6%,
    hsl(32 92% 82% / calc(0.14 + var(--warmth) * 0.1)) 0%,
    hsl(22 82% 74% / 0.04) 45%,
    transparent 74%
  );
}

/* Film grain. One tile, translated in steps — no repaint, no canvas, no cost. */
.grain {
  position: absolute;
  inset: -8%;
  z-index: 12;
  pointer-events: none;
  background-image: url('../../assets/grain.png');
  background-size: 170px;
  /* Plain alpha. Overlay looked marginally richer and cost a full-viewport blend
     pass on every frame of every scroll; at this opacity nobody can tell. */
  opacity: calc(var(--grain-opacity) * 0.85);
  animation: grain-shift 1s steps(1, end) infinite;
  will-change: transform;
}

@keyframes grain-shift {
  0% { transform: translate3d(0, 0, 0); }
  20% { transform: translate3d(-1.4%, 1.1%, 0); }
  40% { transform: translate3d(1.2%, -0.9%, 0); }
  60% { transform: translate3d(-0.8%, -1.3%, 0); }
  80% { transform: translate3d(1.5%, 0.7%, 0); }
  100% { transform: translate3d(0, 0, 0); }
}

/*
 * The vignette does real structural work here, not just mood. Where a margin of
 * extended surface exists — portrait, ultrawide — it is the thing that turns the
 * edge of the photograph into a table receding into shadow. So it reaches
 * further in than a purely decorative vignette would.
 */
.vignette {
  position: absolute;
  inset: 0;
  z-index: 13;
  pointer-events: none;
  background: radial-gradient(
    124% 112% at 46% 40%,
    transparent 40%,
    hsl(var(--shadow-hue) / calc(var(--vignette-strength) * 0.3)) 66%,
    hsl(var(--shadow-hue) / calc(var(--vignette-strength) * 0.72)) 86%,
    hsl(var(--shadow-hue) / var(--vignette-strength)) 100%
  );
}
