/* ============================================================
   HERO APERTURE
   ============================================================
   Companion sheet to js/hero-aperture.js. Linked, not rebuilt on
   system.css — see the header of that file for why, and
   builds/README.md for the precedent (loader/).

   Nothing in here is a candidate for system.css. It is canvas
   stacking order, SVG stroke weights and the internals of a
   WebGL lens: mechanics of one component, not tokens the rest of
   the page could ever share. The three things that ARE shared —
   the ice accent, the neutrals and the mono face — come through
   as var(--kdb-*) with literal fallbacks, so the component still
   renders if it is ever opened without tokens.css.

   Selector names are the lab's. Checked against system.css
   before landing: no .skin, .probe, .roam* or #roam-* there, so
   they are not competing with anything.
   ============================================================ */

/* ---- THE STAGE AND THE NEGATIVE ---------------------------
   Two boxes, and the distinction is the whole reason the plane
   maths survived the move.

   .k-ap is the CROP — whatever the hero has decided the frame
   should be. .k-ap__plane is the NEGATIVE: the photograph at its
   own 3:2, sized and offset by the script to match object-fit:
   cover with a bottom anchor, so under a hero shorter than 3:2
   it runs off the top of the stage. Every projected coordinate
   in the module is relative to the plane, which is why none of
   the recovered geometry had to be re-fitted; the stage's own
   overflow throws away what the hero crops. */
.k-ap {
  position: absolute;
  inset: 0;
  overflow: hidden;
  z-index: 2;
  /* The negative, twice: .skin paints it and the WebGL lens
     samples it for what the glass refracts. They must be the
     same file or the window shows a different photograph than
     the one it is cut into. This URL resolves against THIS
     sheet; the lens's resolves against the document. */
  --hero: url("../img/photos/instagram/media/2024-08-24_C_D0TRJIvca_01.webp");
  --hero-roto: url("../img/photos/instagram/media/2024-08-24_C_D0TRJIvca_01_roto.webp");
}
.k-ap__plane {
  position: absolute;
  overflow: hidden;
  /* The window is centred on the pointer with no clamp — see
     aim() — so near an edge part of it is outside the picture,
     and an aperture cut INTO a photograph should be cropped by
     that photograph rather than hang off it. */
}

/* The line that says what the gesture is. A crosshair on a
   photograph is a target, and a target does not say what
   pressing it does. */
.k-ap__cap {
  position: absolute;
  left: 0;
  bottom: 0;
  margin: 0;
  padding: 0.75rem 0 0;
  font: 400 0.6875rem/1.4 var(--kdb-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: rgba(247, 248, 250, 0.52);
  pointer-events: none;
  z-index: 4;
}
.k-ap__cap b { font-weight: 400; color: rgba(247, 248, 250, 0.78); }

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

/* THE SUBJECT, IN FRONT OF THE HAIRLINES. Same negative, same
   cover, a rotoscope of him (and the ball) so the construction
   sits on the grass and he stands in front of it. The pin and
   the open window stay above this layer — a cursor that
   disappears into a shirt is not a cursor. */
.skin-roto {
  position: absolute;
  inset: 0;
  background-image: var(--hero-roto);
  background-size: 100% 100%;
  background-position: 0 0;
  background-repeat: no-repeat;
  pointer-events: none;
  z-index: 5;
  opacity: 0;
}

/* ---- THE DEPTH OF FIELD · focusing the aperture -----------
   THE OBVIOUS VERSION OF THIS IS WRONG. Blur the photograph
   while the window is open and the window reads as the subject
   — but it also stops sharing a world with it. The board is
   standing ON that grass and throwing a measured shadow ONTO
   it; a sharp object casting a sharp shadow onto a soft surface
   is the exact inconsistency the plane maths was written to
   remove. And greyscale behind a colour window, which is what
   the reference does, runs into the reference-board audit:
   monochrome and duotone were looked at and did not survive.

   SO IT IS DEPTH OF FIELD AND NOT BLUR. A second copy of the
   still, blurred, laid over the first and masked OUT in a band
   around the window's own depth — so the grass the board stands
   on and the grass its shadow falls on are sharp, and the pitch
   falls off in front of and behind it. That is not a treatment
   applied to a photograph, it is the photograph's own optics
   continued: this is a long lens at a grazing angle, the frame
   already has a shallow focal plane, and all this does is put
   the plane where the reader is looking.

   AND THE FOCAL PLANE IS THE SUBJECT'S, NOT THE WINDOW'S.
   The first build had the sharp band FOLLOW the window, which
   is the version that sounds right and measures wrong: with the
   window low in the frame the band sat under De Bruyne and
   **blurred him** — the hero's whole job is to establish the
   person, and softening the person to help a secondary device
   is the hierarchy upside down. It also took 78% of the detail
   out of the top half, which is not "a light blur" by any
   reading.

   So the softness is the FAR FIELD only: the top third, where
   the pitch runs away to a horizon 2.8 frame heights above the
   frame, and where there is nothing but mown grass. He is never
   up there. The window can be, and a sharp board against soft
   distance is the right way round. The mask is therefore STATIC
   — no following, no re-rasterising, only an opacity animating
   — and it deepens a falloff this lens already has rather than
   inventing one.

   IT ALSO CARRIES THE LUMINANCE. The soft copy is a shade
   darker, so out of focus is also very slightly down — which is
   what a vignette is imitating when it is done as a separate
   layer, done here for free and in the right place. */
.skin--soft {
  filter: blur(3px) brightness(0.95);
  opacity: 0;
  will-change: opacity;
  /* Full strength at the top edge, gone by the middle of the
     frame. -webkit- for Safari, which still wants the prefix. */
  --dof: linear-gradient(to bottom,
    #000 0%, #000 14%, rgba(0,0,0,0.55) 30%, transparent 46%);
  -webkit-mask-image: var(--dof);
  mask-image: var(--dof);
}

.roam {
  position: absolute;
  left: 0; top: 0;
  pointer-events: none;
  z-index: 6;
  transform-origin: 0 0;
  will-change: transform;
  overflow: hidden;
  /* SQUARE, AND THAT IS THE WHOLE ARGUMENT. A rounded plate was
     tried and it fights every other thing on this frame: the
     outline, the four construction lines and the registration
     crosses are all struck from the same four projected corners,
     and a fillet means the lines cross where the picture has no
     corner. D42 marks a MEASURED corner; a big radius deletes
     the thing being measured. The glass is in the edge instead,
     and the fillet is small enough to keep the corner — it
     tracks the shader's GLASS.rad, set in the script. */
  border-radius: 0;
}
/* A GRADE, NOT A LOOK. The clip is broadcast H.264 and the
   still is a press JPEG; left alone the window reads a shade
   hotter and cleaner than the grass it is cut into. This is the
   smallest correction that puts them in the same room. */
.roam canvas {
  display: block;
}
.roam__pic {
  position: relative;
  z-index: 1;
  filter: saturate(0.93) contrast(1.05) brightness(0.98);
}
/* THE GLASS DRAWS THE WHOLE PANE, NOT A RIM AROUND ONE.
   A rim laid over the flat clip has to cross-fade into it, and
   any cross-fade between a bent copy and a straight one is a
   ghost. So the lens carries the clip itself — identical in the
   middle, bent at the lip — and the 2D canvas underneath is the
   source texture, held at zero. The grade rides here now. */
.roam__lens {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 2;
  filter: saturate(0.93) contrast(1.05) brightness(0.98);
}
/* THE GLASS. What is left inside the pinned element besides the
   picture: the darkening where the board meets the ground, and
   the light that moves across it. No border — the outline is
   drawn in SVG from the same four corners, because a 1px CSS
   border under this transform is not a hairline (see pinTo). */
.roam__glass {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  /* A real plane standing on grass is occluded where it meets
     it. One shadow, on the inside of the base, in the direction
     the picture's own light already comes from. Nothing here is
     a bevel. */
  box-shadow: inset 0 -14px 18px -14px rgba(8,14,10,0.75);
}
/* THE RAKE · the resting animation.
   ---------------------------------------------------------
   The clip inside the window moves, and for a long time that
   was the only thing that did — so the window itself went
   inert the moment it finished arriving, which is most of the
   time a reader is looking at it. A pane of glass outdoors is
   never inert: something is always crossing it.

   So a single band of light travels the width of the board on a
   slow cycle, angled off vertical so it reads as a reflection
   rather than a wipe, and at six per cent it is under the
   threshold of noticing while the board is plainly no longer a
   flat rectangle sitting there. It is the smaller half of the
   idle — the other half is the sway, which is in the script,
   because a lean belongs to the plane and not to CSS. */
.roam__rake {
  position: absolute;
  top: -40%;
  bottom: -40%;
  left: 0;
  width: 42%;
  background: linear-gradient(102deg,
    rgba(247,248,250,0) 0%,
    rgba(247,248,250,0.035) 38%,
    rgba(247,248,250,0.07) 50%,
    rgba(247,248,250,0.035) 62%,
    rgba(247,248,250,0) 100%);
  animation: rake 13s cubic-bezier(0.55,0,0.45,1) infinite;
  will-change: transform;
}
@keyframes rake {
  /* Two thirds of the cycle is spent off the far edge, so the
     pass is an event and not a loop. */
  0%, 4% { transform: translateX(-120%); }
  46%, 100% { transform: translateX(340%); }
}
/* THE SECOND LIGHT, AND WHY THERE ARE TWO.
   The rigid part of the idle — the board rocking on its base,
   in the script — can only ever be about two pixels here, and
   that is not a shortcoming of the numbers: a board standing
   square to a long lens moves almost entirely IN DEPTH when it
   leans, and depth is the one direction this camera crushes. So
   the rest of the life has to be light, which costs no physical
   lie at all: a lit panel outdoors is under changing light by
   definition.

   Two passes at different speeds, because one is a metronome.
   The rake is a narrow band crossing in thirteen seconds; this
   is the whole pane getting slowly brighter on one side over
   thirty-one, which is the ambient rather than a reflection.
   Three per cent, which is under a JPEG's own noise floor and
   still enough that the window is never twice the same. */
.roam__sheen {
  position: absolute;
  inset: -20%;
  background: linear-gradient(74deg,
    rgba(247,248,250,0) 15%,
    rgba(247,248,250,0.03) 50%,
    rgba(247,248,250,0) 85%);
  animation: sheen 31s ease-in-out infinite alternate;
  will-change: transform;
}
@keyframes sheen {
  from { transform: translateX(-22%) }
  to { transform: translateX(22%) }
}
.roam[data-lens] .roam__rake,
.roam[data-lens] .roam__sheen { animation: none; opacity: 0; }

/* THE CATCH · a breath around the pane, not a lamp on it.
   ---------------------------------------------------------
   The shader already walks a specular along the lip; this is
   only the faintest spill past the cut. Kept thin and quiet so
   the hairline (#roam-edge) can do the breathing — a wide bright
   ring was reading as a soft white border, which is furniture
   on a photograph.

   Cool, opposite lodges. Screen so it lifts what is already
   there. The angle is written per frame from the same clock the
   shader uses. */
.roam-bloom {
  position: absolute;
  left: 0;
  top: 0;
  pointer-events: none;
  z-index: 6;
  transform-origin: 0 0;
  overflow: visible;
}
.roam-bloom__ring {
  position: absolute;
  inset: -2px;
  padding: 2px;
  background: conic-gradient(
    from var(--catch, 39deg),
    rgba(236, 242, 248, 0) 0deg,
    rgba(236, 242, 248, 0) 64deg,
    rgba(255, 255, 255, 0.42) 78deg,
    rgba(236, 242, 248, 0) 92deg,
    rgba(236, 242, 248, 0) 244deg,
    rgba(236, 242, 248, 0.22) 258deg,
    rgba(236, 242, 248, 0) 272deg,
    rgba(236, 242, 248, 0) 360deg
  );
  -webkit-mask:
    linear-gradient(#fff 0 0) content-box,
    linear-gradient(#fff 0 0);
  -webkit-mask-composite: xor;
  mask:
    linear-gradient(#fff 0 0) content-box,
    linear-gradient(#fff 0 0);
  mask-composite: exclude;
  filter: blur(1px);
  mix-blend-mode: screen;
  opacity: 0.34;
}
@media (prefers-reduced-motion: reduce) {
  .roam-bloom__ring { filter: blur(0.8px); opacity: 0.28; }
}

.roam-svg {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 4;
  overflow: visible;
}
.roam-hud {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 7;
  overflow: visible;
}
.roam-svg path {
  fill: none;
  stroke: rgba(247,248,250,0.85);
  stroke-width: 1;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}
/* The four lines that leave the frame. Lighter than the window's
   own edge, because they are construction and it is the subject —
   D42's frame, where the edges run past their own corners so the
   rectangle reads as a position rather than a container. */
#roam-bleed path {
  stroke: rgba(247,248,250,0.3);
}

#roam-pin {
  fill: none;
  stroke: rgba(247,248,250,0.9);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
}

/* THE WINDOW'S OWN EDGE. One hairline, at a weight above the
   construction lines because it is the subject and they are
   working drawings — and drawn here, in the frame's flat space
   from the projected corners, rather than as a border on the
   plane. Under a homography that squashes the box to a tenth of
   its height a 1px border rasterises into dashes; a non-scaling
   stroke on the same four points cannot. */
#roam-edge {
  fill: none;
  /* The opacity is written per frame — it goes up as the tube
     closes, because the same light has less line to spread
     over. */
  stroke: rgb(247,248,250);
  stroke-opacity: 0.78;
  stroke-width: 1;
  stroke-linejoin: miter;
  vector-effect: non-scaling-stroke;
}
/* THE REGISTRATION CROSSES · D42. They mark a MEASURED corner,
   so they arrive when there is a corner to measure — which is
   when the board has stopped moving. Screen-aligned and
   non-scaling on purpose: a cross is a cross at any size, and
   four of them squashed onto a plane at a grazing angle are
   four dashes. */
#roam-marks path {
  fill: none;
  stroke: rgba(247,248,250,0.9);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
}

/* THE SELECTED DIAMOND. A cell of the mowing, cut from the same
   chords the areas are — and a wash, with no outline. A hairline
   around a cell was drawing a box on a photograph that already
   has edges of its own; taking it away leaves the pattern doing
   the drawing and the light doing the saying. Opacity is driven
   from the script, because the press is a shape in time rather
   than a state.

   FEATHERED, IN TWO LAYERS. A hard-edged polygon put every
   imperfection of the tracing on show — a corner half a degree
   out reads as a mistake when it has a crisp edge and as nothing
   at all when it does not. So: a wide soft glow that spills past
   the cell, and a tighter wash inside it. Blurring is done to
   the LIGHT, never to the field: the photograph is untouched and
   what softens is only what is being laid over it.

   The glow is deliberately larger than its own cell. Light coming
   off a surface does not stop at the surface's edge, and that
   spill is what makes the selection read as sitting proud of the
   grass rather than painted onto it. */
#roam-sel polygon {
  fill: rgb(247,248,250);
  fill-opacity: 0;
  stroke: none;
}
#roam-sel .glow { filter: blur(26px); }
#roam-sel .wash { filter: blur(9px); }
/* Both of them drift upward as they fade — see the note in the
   loop. The transform is written per frame, so nothing here
   animates it. */
#roam-sel polygon { will-change: fill-opacity, translate; }

/* ---- WHAT IS UNDER THE WINDOW ----------------------------
   The diamond's light and the window's shadow both belong to
   the GRASS, so they are drawn in their own overlay beneath the
   plate rather than in the construction layer above it. That is
   not a z-index tidy-up: a shadow drawn on top of the thing
   casting it is the single clearest way to tell a reader that
   none of this is really there. */
.roam-under {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 2;
  overflow: visible;
}
/* THE SHADOW IS THROWN BY THE PHOTOGRAPH'S OWN SUN, whose
   direction is measured off the subject's shadow — see LIGHT in
   the script. It is not a drop shadow and it is not a blur
   behind a box: it is four points on the pitch, projected by the
   same maths as the window, so it lies on the grass at the right
   angle and the right length and it grows out from under the
   board as the board comes up.

   Two parts, because contact and cast do different work. The
   CAST is wide and soft and carries the light's direction; the
   CONTACT is a tight dark strip welded to the hinge — ambient
   occlusion, the thing that says the window is standing ON
   something rather than in front of it. */
#roam-cast .cast { fill: rgb(6,12,8); fill-opacity: 0; filter: blur(6px); }
#roam-cast .contact { fill: rgb(4,8,6); fill-opacity: 0; filter: blur(6px); }


/* The aperture's labels. One fact per corner, never on the
   plane — see the note in draw(). The script hands each one the
   projected corner it belongs to; these rules only say which way
   it grows from there. The corner itself is named by the
   registration cross, so the labels do not grow their own
   leader ticks. */
.roam__tag {
  position: absolute;
  left: 0;
  top: 0;
  margin: 0;
  translate: 0 0;
  transform-origin: 0 100%;
  /* Its own height, upwards: the script hands it the corner it
     is naming, and the label sits just above that point. */
  transform: translateY(-100%);
  z-index: 8;
  pointer-events: none;
  opacity: 0;
  padding-bottom: 6px;
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: 0.62rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  line-height: 1.35;
  color: rgba(247,248,250,0.94);
  white-space: nowrap;
  /* IT IS SITTING ON GRASS, and grass is the worst substrate a
     caption can have: mid-luminance, high-frequency, and it
     changes under the label as the window moves. Two shadows —
     one tight for the edge, one wide and low for the ground —
     hold 10px caps against it without putting a panel on the
     photograph. */
  text-shadow: 0 1px 2px rgba(6,11,8,0.9), 0 0 14px rgba(6,11,8,0.6);
}
/* THE GROUND, at the top right, set from its corner leftwards
   so its last character lands on the corner rather than its
   first — the two top labels then read inwards from the ends of
   the same edge instead of both running off the right. */
.roam__tag--r {
  transform: translate(-100%, -100%);
  text-align: right;
}

/* THE SCORER, under the bottom left, growing downwards. Below
   and not above because the board stands on the grass at that
   edge: a label placed above it would sit inside the window,
   over the footage. */
.roam__tag--b {
  transform: none;
  padding-bottom: 0;
  padding-top: 6px;
}

/* `color: inherit` is doing real work here. type.css sets
   `strong, b` to the primary INK colour, which is right on the
   page and wrong on a photograph — it put the number in near
   black on grass while the two labels beside it stayed white.
   The label is one voice, so it states its own. */
.roam__tag b { font-weight: 400; color: inherit; }

/* THE RETICLE'S READOUT. At the crop, where each hairline
   meets the frame — across on the bottom, along on the right,
   not on the ring. Same micro-caps as the corner labels
   because it is the same apparatus, quieter because a
   position is the least important thing here and the only
   one that changes every frame. Tabular figures so the two
   numbers do not jitter their own width as they count. */
.roam__coord {
  position: absolute;
  left: 0;
  top: 0;
  margin: 0;
  translate: 0 0;
  z-index: 8;
  pointer-events: none;
  opacity: 0;
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: 0.58rem;
  letter-spacing: 0.1em;
  font-variant-numeric: tabular-nums;
  line-height: 1.35;
  color: rgba(247,248,250,0.72);
  white-space: pre;
  text-shadow: 0 1px 2px rgba(6,11,8,0.9), 0 0 14px rgba(6,11,8,0.6);
}
.roam__tag span { font-style: normal; }
/* THE COUNTER'S FIELD. Fixed to the target number's own width
   in digits and right-aligned, so the count runs like an
   odometer — the ones column never moves and the name after it
   never shifts. `u` because it is the one inline element left
   with no meaning of its own; the underline is off. */
.roam__tag u {
  display: inline-block;
  text-align: right;
  text-decoration: none;
  font-variant-numeric: tabular-nums;
}

/* The one real control. It carries no look at all: the brief is
   that a reader should not know where to press, and D22's
   requirement is that a reveal have a real activation behind it
   for touch and keyboard. Both are true of a frame-sized button
   with no chrome — tap anywhere, or focus it and the window
   builds at the centre. */
.probe {
  position: absolute;
  inset: 0;
  z-index: 9;
  padding: 0;
  border: 0;
  background: none;
  /* NO SYSTEM CURSOR. The ring is the pointer at rest; once a
     window is up both are gone. The probe covers the frame, so
     a click to swap or close does not need an arrow. */
  cursor: none;
  color: inherit;
}
.probe:focus-visible { outline: 2px solid var(--kdb-ice-45, #74b4d8); outline-offset: -2px; }

/* The layer's own element. NOT `display: none` — a media element
   that is not rendered is exactly what every browser's
   power-saving heuristics are written to stop decoding, and a
   canvas drawing from it needs it decoding. So it stays rendered,
   two pixels of it, in the corner, behind the surface. */
video.layer {
  position: absolute;
  left: 0;
  bottom: 0;
  width: 2px;
  height: 2px;
  opacity: 0.01;
  pointer-events: none;
  z-index: -1;
}

/* ---- THE TRAIL · stamps left on the path -------------------
   Same white and same hairline as the construction it replaces,
   one step quieter: these are notes the frame has already made,
   not the line it is drawing now. The text layer sits on top of
   the HUD but below the window, and never takes a pointer. */
.roam-trail__box {
  fill: none;
  stroke: rgba(247,248,250,0.4);
  stroke-width: 1;
  stroke-linecap: square;
  stroke-linejoin: miter;
  vector-effect: non-scaling-stroke;
  opacity: 0;
}
.roam-trail-tx {
  position: absolute;
  inset: 0;
  z-index: 8;
  pointer-events: none;
  overflow: visible;
}
.roam-trail__tx {
  position: absolute;
  left: 0;
  top: 0;
  margin: 0;
  translate: 0 0;
  opacity: 0;
  pointer-events: none;
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: 0.58rem;
  letter-spacing: 0.1em;
  font-variant-numeric: tabular-nums;
  line-height: 1.35;
  /* QUIETER THAN THE WINDOW'S OWN LABELS. Those name the subject
     and sit at 0.94; these are the frame's working notes on the
     way past, and at half that they are something the reader
     catches rather than reads. The shadow stays - grass is still
     the substrate, and unreadable is not the same as subtle. */
  color: rgba(247,248,250,0.5);
  white-space: pre;
  text-shadow: 0 1px 2px rgba(6,11,8,0.75), 0 0 12px rgba(6,11,8,0.5);
}

/* ONE EDGE, TWO ENDS. Both labels are laid out in the square's
   own width from its left edge and both read from it — the count
   over the top-left corner, the name under the bottom-left. They
   used to sit on opposite ends, which put the name on whichever
   corner the box happened to reach and made a run of stamps two
   ragged columns instead of one. Sharing the left edge gives the
   run a single spine: a column of counts with the names hanging
   directly beneath them, and neither has to be hunted for. */
.roam-trail__tx--head { text-align: left; }
.roam-trail__tx--foot { text-align: left; }
