/* ===========================================================================
   The chameleon's eye. Owned by brand-ui, loaded after site.css.

   Its own file because it is a self-contained feature and site.css is at this
   project's 500-line limit. Nothing here is referenced from anywhere else.

   READ THE COMMENT ABOVE THE MARKUP IN index.html FIRST. It records the
   geometry, how the overlay registers against the artwork, and the three
   approaches that were measured and rejected before this one.

   TWO MECHANISMS LIVE HERE AND THEY ARE NOT THE SAME TRICK:

     THE SACCADE slides a copy of the banner inside the disc. Only the disc's
     RIM has to match, because that is the only place the copy meets the base -
     so what it needs is a radius where the art is locally unchanged by the
     shift, and a copy of the same export as the base. LIGHT ONLY, and read the
     dark block at the bottom before you try to change that: a dark version was
     built, measured clean, and abandoned for moving the wrong thing.

     THE BLINK paints a lid inside the same disc. It needs one flat colour at
     the rim and no copy at all. BOTH THEMES.

   THE SACCADE IS BROKEN AT REST IN THE LIVE PAGE AND NOT BY ANYTHING HERE.
   hero-chameleon.js swaps the base <picture> for a <canvas>, and its `SOURCES`
   draws hero-*-1280.webp while this overlay carries hero-*-1920. Base and
   overlay are therefore different exports at different resolutions, so they
   cannot agree however the filtering falls. Measured in the browser on the
   light hero at rest, DPR 2, eye region, overlay shown vs `display: none`,
   with nothing from the blink involved:

     base <picture> (script off)   worst 1    mean 0.0024   0 of 34040 px over 5
     base <canvas>  (script on)    worst 99   mean 3.38     4789 px over 5

   And measured offline, 1280 export bilinear-upscaled to 1920 vs the 1920
   export over the same eye region - i.e. the asset mismatch on its own:

     light  worst 144  mean 6.05   2122 of 11881 px over 5
     dark   worst  74  mean 3.43   2668 of 16641 px over 5

   That accounts for the browser figure, so the cause is the ASSET, not the
   clip and not the compositing. The one-line fix is hero-chameleon.js pointing
   SOURCES at the 1920 files it is being composited against, or keeping the
   <picture> and putting the motes in a canvas above it. Until then the light
   saccade shows a disc at rest. Do not "fix" it by re-tuning the clip here.
   The dark figure is kept because it is evidence about the same defect.
   =========================================================================== */

/* ---------------------------------------------------------------------------
   SOURCE-PIXEL BOOK-KEEPING. Every number below was derived with this, so
   check it before you change any of them.

   The banner is 1920x768 (2.5:1), the stage is 16/10, and site.css paints it
   with `object-fit: cover; object-position: RIGHT center`. Cover matches the
   height, so the image is 1.5625x the element's width and is cropped on the
   LEFT, not centred. Hence, for an element box W x H:

     1 source px across = 0.0813802% of W      (1/1920 * 1.5625)
     1 source px down   = 0.1302083% of H      (1/768)
     source x -> % of W = (x/1920 * 1.5625 - 0.5625) * 100
     source y -> % of H = y/768 * 100
     clip-path circle r% resolves against sqrt(W^2+H^2)/sqrt(2) = 0.833786*W,
       so r source px = r * 0.0813802 / 0.833786 % ... i.e. 0.0976039% each.

   ASSUMING A CENTRED CROP GIVES AN ANSWER ~350 SOURCE PX OFF and lands on flat
   snout skin, which looks plausible enough in the numbers to fool you. It did
   fool us. Verify any coordinate by rendering the crop and looking at it:

     # in the page console
     const i = new Image(); i.src = 'assets/brand/banners/hero-light-1920.png';
     await i.decode();
     const c = document.createElement('canvas'); c.width = 800; c.height = 880;
     const x = c.getContext('2d'); x.imageSmoothingEnabled = false;
     x.drawImage(i, 1680, 160, 200, 220, 0, 0, 800, 880);  // eye @ 4x
     document.body.prepend(c);
   --------------------------------------------------------------------------- */

/* THE EYE OVERLAY. Geometry, the mechanism and the three measured-and-rejected
   approaches are documented above the markup in index.html - read that first.
   Two rules matter here: every number is a fraction of the stage, which is only
   safe because --s-stage-ratio is fixed; and NO `will-change`, because promoting
   this to its own compositing layer rasterises it differently from the base
   picture and the disc becomes visible at rest (measured: worst channel delta
   52 with it, 0-5 without). */
.hero-eye { clip-path: circle(4.4885% at 87.5488% 38.8672%); animation: eye-look 19s infinite both; }

/* Saccades: a chameleon's eye darts and holds. A smooth sweep reads as a
   searchlight. Leftward and vertical only - the painted pupil already sits
   slightly over the eyelid on its right, so there is no rightward travel. */
@keyframes eye-look {
  0%,  11% { transform: translate(0, 0); }
  13%, 27% { transform: translate(-0.41%, 0.12%); }
  29%, 38% { transform: translate(-0.16%, -0.24%); }
  40%, 61% { transform: translate(0, 0); }
  63%, 72% { transform: translate(-0.73%, -0.08%); }
  74%, 85% { transform: translate(-0.28%, 0.20%); }
  87%,100% { transform: translate(0, 0); }
}

/* ===========================================================================
   THE BLINK.

   WHY IT IS A PAINTED LID AND NOT A MOVED COPY. The obvious idea - translate
   the overlay DOWN so it drags the eyelid and brow above the eye across the
   pupil - cannot work, and it is worth knowing why before you try it again.
   `clip-path` is applied in the element's own coordinate space and the
   transform then moves the CLIPPED RESULT, so the disc travels with its
   contents. A translate slides the whole eye in its socket; it can never
   cover it. Nothing the overlay does to itself adds coverage, because the
   base picture underneath it is always an open eye.

   So a blink has to put non-eye content over the eye, and there are only two
   sources: artwork from somewhere else, or a colour.

   ARTWORK FROM ELSEWHERE WAS TRIED FIRST and rejected. You can hold the disc
   still while sliding other artwork through it by animating `clip-path` and
   `transform` in opposite directions. The problem is the painting: the eye
   is r46 and the head is small, so every 92px patch within reach is spoiled -
   60px up crosses the silhouette into the cream background, 60px down and
   105px down straddle the dark mouth band, 105px left touches the jaw. There
   is no patch of snout that reads as closed-eye skin. Verified by overlaying
   each candidate disc on the source at 2x and looking at it.

   WHAT MAKES THE PAINTED LID HONEST. The rejected "draw a replacement pupil"
   approach failed on a 228/255 resting delta. A lid does not have that
   problem: it is parked entirely outside the clip disc at rest, so the
   resting hero is still pixel-identical to the artwork by construction. It
   only has to be right for the ~280ms it is on screen.

   AND THE COLOUR IS MEASURED, NOT INVENTED. Sampled at 12 directions on the
   clip circle of hero-light-1920.png:

     r46: upper right (0/30/60/330 deg) is snout skin rgb(172,232,203);
          90-300 deg is the dark eyelid ring, rgb(6,78,59) +/- a few.
     r58: skin in every direction, rgb(158-172, 228-233, 196-204), G spread 5.

   So a skin-coloured lid filling the r46 disc lands correctly BOTH ways round:
   where the rim sits in skin it is skin-on-skin and invisible, and where the
   rim sits in the dark eyelid ring it reads as the lid's own aperture edge.
   The base's outer eyelid ring (r46-56) is never covered, so the closed eye
   keeps its dark rim - which is what a closed chameleon turret looks like.

   NOTE THE FILE'S OLD CLAIM THAT r46 IS A FLAT DARK BAND ALL ROUND. It is
   not: about this centre the r46 G spread is 180, because the disc is ~8px
   right of the eye's true centroid (1758.8, 301.7) and pokes into skin at the
   upper right. The saccade is unharmed - it slides mixed content over mixed
   content, all of it inside the eye - and the blink needs both the skin arc
   and the dark arc, so the off-centre disc is left exactly as it was. Do not
   "fix" the centre without re-measuring both mechanisms.
   =========================================================================== */

/* The lid is a box covering exactly the disc's bounding square, parked just
   above it (see the -112% note below). The parent's clip-path confines it to
   the disc, so it cannot spill onto the snout however far it travels - the disc
   clips the pseudo-element as well as the <img>, which was verified on screen
   by filling the lid with red and watching it come out as a circular segment.

     source (1721,252.5) to (1813,344.5), i.e. the r46 disc's bounding square
     left 83.805% = (1721/1920*1.5625-0.5625)*100     width  7.487% = 92px
     top  32.878% = 252.5/768*100                     height 11.979% = 92px

   `left/top/width/height` are static - they are set once and never animated,
   so there is no layout work and CLS stays 0. Only `transform` animates.

   The last 10% of the gradient is the lid margin: skin mixed ~45% toward the
   eyelid's rgb(6,78,59). Without it the lid reads as the eye being erased
   rather than covered; a hard dark line instead of a soft one reads as a
   scratch, because its ends are cut off square where they meet the rim.

   IT PARKS AT -112%, NOT -100%, AND THE 12% IS THE WHOLE BALL GAME.
   At -100% the lid's bottom edge lands exactly on the disc's topmost point,
   and one rasterised row of the margin colour lands inside the clip. That is
   worth a worst channel delta of 50 across 23 pixels of the top rim at rest -
   the resting state stops being pixel-identical, which is the one property
   this whole feature is built on. Measured, on a DPR 2 screenshot of the eye
   region with the overlay shown vs `display: none`:

     original file, no blink   worst 1   mean 0.0024   0 px over 5
     lid parked at -100%       worst 50  mean 0.0293   23 px over 5
     lid parked at -112%       worst 1   mean 0.0024   0 px over 5

   So the park has to clear the disc, not touch it. If you change the lid's
   height, re-measure - do not assume 12% is still enough. */
.hero-eye::after {
  content: "";
  position: absolute;
  left: 83.805%; top: 32.878%; width: 7.487%; height: 11.979%;
  background: linear-gradient(to bottom,
    rgb(164 230 198) 0%, rgb(172 233 204) 88%, rgb(110 180 152) 100%);
  transform: translateY(-112%);
  animation: eye-blink 19s infinite both;
}

/* TIMING. Close 100ms, hold 50ms, open 140ms - a real lid shuts faster than it
   opens, which is why the two legs differ and why the closing leg gets the
   decelerating ease. 290ms of 19s is 1.526% of the timeline. The close is
   100ms rather than 90 because the first 12 of its 112% of travel happens
   above the disc where nothing is visible yet.

   THE TWO BLINKS SIT INSIDE THE SACCADE'S REST PHASES (0-11% and 40-61%) AND
   THAT IS LOAD-BEARING, not tidiness. The rim colours above were measured at
   translate(0,0); during a dart the disc is up to 9 source px left of them,
   which puts the arc that was skin-on-skin over the eye instead and shows as a
   bite out of the eyelid. If you move a blink, keep it inside a rest phase.

   In dark the disc does not move at all, so the rest-phase rule costs nothing
   there - but keep the blinks inside the windows anyway. If a dark saccade ever
   returns it will need them, and a blink on a moving disc is a subtle bug.

   Gaps of 7.8s and 11.2s. Irregular on purpose - evenly spaced blinking reads
   as a cursor. */
@keyframes eye-blink {
  0%, 6%            { transform: translateY(-112%); animation-timing-function: ease-out; }
  6.526%            { transform: translateY(0); }
  6.789%            { transform: translateY(0); animation-timing-function: ease-in-out; }
  7.526%, 47%       { transform: translateY(-112%); animation-timing-function: ease-out; }
  47.526%           { transform: translateY(0); }
  47.789%           { transform: translateY(0); animation-timing-function: ease-in-out; }
  48.526%, 100%     { transform: translateY(-112%); }
}

/* ===========================================================================
   DARK: THE ELEMENT ITSELF DOES NOT MOVE. It carries the clip, the lid and the
   eyeball patch; the pupil sprite below is the only thing that travels.

   TWO APPROACHES WERE TRIED HERE AND BOTH ARE WORTH A LINE, because each stood
   for a while and each was abandoned for a reason a reader will otherwise hit
   again. hero-light-* and hero-dark-* are different paintings, not one image
   recoloured, and the overlay <picture> carries the light banner only.

   1. NO SACCADE AT ALL, on the grounds that the dark socket has no uniform ring
      for a moving cut to hide in. That stood for a year and was wrong: it was
      measured at r46 - the radius the LIGHT theme uses - and the scan stopped at
      r44. The ring is at r52..r56, G spread 15-22 about G 171.7, further out
      than the light eye's own. At r46 the cut genuinely does show a crescent
      (167/255, rendered and looked at), so the conclusion was right about r46
      and wrong about the artwork.

   2. A REGISTERED DARK COPY, SLID. Cut at r57 it was clean - a 9 source px dart
      measured a worst channel seam of 47/255 across the whole cycle, against the
      150/255 the LIGHT saccade ships with. Abandoned anyway, because a copy
      under a circular clip translates the eyeball, socket and surrounding skin
      together: the whole turret slides in the head. An eye looking somewhere
      moves its PUPIL across a stationary eyeball. The user named it: "the eye
      movement is bad - I want the eye to move in 3D space - the center of the
      eye the pupil needs to do the movement." The seam tables are gone with it;
      they measured the cleanliness of the wrong motion.

   And CSS gradients cannot do the pupil either - erase-and-redraw bottoms out at
   a resting delta of worst 67, mean 3.37, 385 of 2127 px over 5, because the
   highlight is a 9x9px feature with a 1px edge cliff and a rim brighter than its
   core. Hence the image layers.
   =========================================================================== */
[data-theme="dark"] .hero-eye {
  clip-path: circle(5.2705% at 83.3170% 35.8073%);   /* r54 @ source (1715,275) */
  animation: none;
}
[data-theme="dark"] .hero-eye > img { visibility: hidden; }

/* The lid, cut for the dark socket.

   The closed state here is flatter than the light one and it is worth knowing
   why: the dark eye is a deep shaded socket, so a near-flat fill removes the
   depth and reads as "no eye" rather than "lid over a socket". At 1x that is
   clean and convincing; at 5x the fill is visibly flatter than the surround.
   Cutting smaller to leave some socket rim for depth was tried and is worse -
   at r46 the rim runs rgb(0,127,75) to rgb(1,51,29) by direction, so you trade
   a flat fill for a visible circle. */
[data-theme="dark"] .hero-eye::after {
  /* the r54 disc's bounding square: source (1661,221) to (1769,329) */
  left: 78.9225%; top: 28.776%; width: 8.7891%; height: 14.0625%;
  /* Measured on the r54 ring: brighter at the top of the socket (176) than the
     bottom (167), so the fill carries that gradient rather than one flat tone.
     Margin mixed ~45% toward the socket's dark. */
  background: linear-gradient(to bottom,
    rgb(1 176 105) 0%, rgb(0 167 100) 88%, rgb(0 116 70) 100%);
}

/* ===========================================================================
   THE PUPIL MOVES, AND ONLY THE PUPIL. Dark theme.

   The eyeball, socket and skin stay exactly where they are; a sprite carrying
   the artist's own pupil and highlight pixels travels across them, foreshortened
   as it goes. Two generated layers do it:

     hero-eye/eyeball-1280.{webp,png}  the eye with the pupil inpainted out
     hero-eye/pupil-1280.{webp,png}    the pupil and highlight, with alpha

   BOTH ARE GENERATED, NOT DRAWN. No artist made them, the brand kit does not
   have them, and they are deliberately NOT in brand/banners/ because VENDOR.md
   reserves that directory for verbatim copies of the kit - a re-copy would
   delete them. Regeneration recipe at the bottom of this comment.

   -1280- IS IN THE FILENAME BECAUSE IT IS LOAD-BEARING. They are cut from
   hero-dark-1280.webp, which is what hero-chameleon.js composites into the
   canvas that replaces the base <picture>. Cutting from the 1920 export instead
   puts the patch on a differently-resampled pupil and leaves a sliver of the
   original showing. And it must be the WEBP, not its png sibling: that webp is
   LOSSY - worst 49, mean 4.32 over the eye region against the png - so a patch
   cut from the png does not composite to zero either. Decode the webp.

   Which is also why both layers only paint when the canvas is actually there:
   `:has(> canvas)`. With JS off, or under reduced motion, hero-chameleon.js
   leaves the static <picture> and the base becomes the 1920 background - these
   layers would not register against it, so they stay off and the artwork shows
   untouched. One selector covers both cases.

   WHY NOT CSS GRADIENTS. Erasing the pupil and redrawing it with
   radial-gradients bottoms out at a resting delta of worst 67, mean 3.37, 385
   of 2127 px over 5, because the highlight is a 9x9px feature (at 1920) with a
   1px edge cliff and a rim brighter than its core. Moving the artist's pixels
   makes the rest state exact instead of close.

   THE MASK MUST BE BINARY AND SHARED, and that is the whole construction.
   Compositing is sprite*a + patch*(1-a), so for the composite to equal the
   original at every pixel the patch must equal the original wherever a < 1. A
   feathered mask demands pixels that are both the inpaint and the original at
   once, and there it drifts - measured worst 17 before this was understood.
   With a in {0,1} no such pixel exists, and the rest state is bit-identical.
   Measured by compositing the WRITTEN files against the decoded webp: worst 0,
   0 of 1020 pixels non-zero. Zero, not near zero.

   MASK GEOMETRY, in hero-dark-1280.webp source pixels:
     flood fill from (1158,182) over G < 45, bounded to r17 of (1157.5,181.0)
     UNION a disc r3.6 about the highlight at (1163.5,174.0)
     then dilate 1px, then DROP any pixel with G > 100 that is not the
     highlight's own.                        -> 576 px, x1143-1172 y167-192

   Three things in that recipe are each load-bearing, and each was found by
   rendering it wrong first:
     - THE UNION. At 1920 the highlight's far edge is r19.34 from the pupil
       centre and the socket's dark accent is at r19.36 - the same radius - so
       no circular mask can take the highlight and leave the accent. They lie in
       different directions, so a union can. With a bounding disc instead, the
       accent fell outside the mask and floated in place while the pupil left:
       a dark bar, exactly the thing this feature must not do.
     - THE FLOOD, not a disc. A disc carries eyeball-coloured pixels at its rim,
       and the sprite's circular edge then shows as an arc against the patch.
       A flood fill makes the sprite's edge the pupil's own painted edge.
     - THE G > 100 TRIM. The dilate reached the socket's bright limb - 14 pixels
       at x1172 and along the lower rim - which would have travelled with the
       pupil and been erased from the base.

   THE INPAINT IS HARMONIC. Each pixel inside the mask is the mean of its four
   neighbours with the mask's boundary pinned to the original; 302 Jacobi sweeps
   to converge below 0.01. C0 at the boundary by construction, which is what
   removed the visible arc that inverse-distance weighting left, and a harmonic
   function has no interior extrema so the eyeball's shading carries across as a
   ramp rather than being invented.

   AND THE PUPIL DOES NOT START AT THE EYEBALL'S CENTRE. It sits 13.5px off a
   sphere of radius 27.3 (at 1280), already 30deg round, so a leftward dart moves
   it TOWARD the view axis and it WIDENS rather than squashing. The artist also
   did not foreshorten it at rest - the painted pupil is wider than tall - so the
   term has to be normalised to 1 at rest or the rest state breaks: measured at
   142 against 67 before normalising.

   THE PROJECTION IS A SPHERE ROTATING, NOT A DISC SLIDING, and getting that
   wrong is what two rejected versions had in common. The pupil is a point on a
   sphere with unit direction d = (dx, dy, dz). Its screen offset is R*(dx,dy),
   and a circle on that surface projects to an ELLIPSE compressed along the
   RADIAL direction by dz and unchanged across it. So the squash has an AXIS, and
   the axis rotates with the direction of travel:

     transform: translate(Dx, Dy) rotate(a) scale(k, 1) rotate(-a)
     a = atan2(dy, dx)      k = dz / dz0      origin = the pupil's own centre

   `scaleX()` alone is the same thing with a pinned to zero, which is correct
   ONLY for horizontal travel. Every pose in the version before this one was
   near-horizontal, so it was nearly right and still read as a disc pushed around
   on a flat surface - the user's words were a grape cut in half and pushed on the
   cut face, which is exactly what a fixed squash axis is.

   k = dz/dz0 makes rest the identity, so gate 1 stays exactly zero. Note that at
   rest scale is 1, which makes the two rotate() terms cancel algebraically - rest
   is identity for ANY value of a. And a may be shifted by 180deg freely, since
   squashing along an axis is the same as squashing along its opposite; re-base
   the angles that way if an interpolated spin ever looks long.

   REST IS MEASURED, NOT ASSUMED: dx +0.4939, dy -0.0976, dz 0.8640, which is
   30.2deg off the view axis on a bearing of -11.2deg. The rest offset is not
   purely horizontal - there is a small upward component - and using dy0 = 0
   throws every k off.

   WHAT LIMITS THE TRAVEL IS THE CLIP DISC, and per direction, not overall. The
   sprite is cut when any opaque pixel leaves `.hero-eye`'s clip circle. Measured
   as a fraction of the clip radius, worst pixel, full transform applied:

     pose            d(x,y)          screen(src)    k        clip clearance
     rest            +0.494,-0.098     +0.0, +0.0  1.0000      7.8%
     A up-left       -0.050,-0.320    -14.9, -6.1  1.0950     23.0%
     B centre         0.000, 0.000    -13.5, +2.7  1.1574     38.0%
     C left          -0.260,-0.098    -20.6, +0.0  1.1118     22.9%
     D down-left     -0.050,+0.320    -14.9,+11.4  1.0950     26.5%

   PURE VERTICAL FROM REST IS NOT AVAILABLE and that is the surprise. Rest sits
   against the clip's right side with only 7.8% clearance, so moving straight up
   or down keeps dx at +0.494 and runs the sprite into the clip: dy -0.30 leaves
   2.6%, dy -0.45 leaves 0.2%. Every reachable pose therefore carries a leftward
   component.

   AND 15.7% OF CLEARANCE IS NOT ENOUGH, which gives a usable threshold. The pose
   -0.22,+0.40 clears the clip on paper at 15.7% and still fails in the browser:
   a hard-edged notch where the clip cuts the sprite, at real size, not a
   high-magnification artefact. So the clip bites before the eyeball's dark side
   does - an earlier revision of this comment blamed the dark side, which was the
   wrong reason for the right rejection. The four shipped poses sit at 22.9% and
   above; treat anything under about 20% as unusable rather than tight.

   PATHS ARE ARCS AND IT DOES NOT MATTER HERE. Interpolating the screen offsets
   linearly cuts the chord rather than following the great circle - the flat-grape
   mistake one level up. Measured at the midpoint of each transition in this
   cycle, the arc-to-chord gap is 0.06 to 0.44 source px, under half a pixel, and
   it is traversed in 95ms. Per-step keyframes would add a dozen lines to buy
   something no one can see. If the snaps are ever slowed to where the path
   itself reads, subdivide then - interpolate in (dx,dy) on the sphere, not on
   screen.

   THE RHYTHM IS SACCADIC, AND NEITHER OF US HAS SEEN THE REFERENCE. A chameleon
   snaps to a bearing and holds it; it does not glide. So each move occupies 0.5%
   of the cycle - 95ms - and the holds run 1.4s to 3.9s, deliberately uneven so
   it does not read as a metronome, with two large sweeps and two small
   adjustments rather than four moves of the same size. That description came
   from anatomy rather than from the video the user sent, which was not watchable
   by either of us: if the video shows a different rhythm, the video wins.

   TO REGENERATE: dwebp hero-dark-1280.webp, build the mask above, Laplace-fill
   it for the eyeball layer, copy the decoded pixels with alpha = the mask for
   the pupil layer, crop both to x1141..1174 y165..194 (the same bounds, so one
   position serves both), and encode with `cwebp -lossless -exact` - lossy would
   break the bit identity and -exact keeps the RGB under transparent pixels.
   Then composite the two WRITTEN files and diff against the decoded webp. It
   must be 0. Anything else means the two files were cut with different masks.
   =========================================================================== */
[data-theme="dark"] .hero-art:has(> canvas) .hero-eye {
  /* the eyeball layer, static. Box is source x1141..1174 y165..194 at 1280:
     left 83.0322% top 32.2266% size 4.1504% x 5.8594% of the stage. As a
     background that has to go through background-position's own arithmetic,
     which places the image at pct * (100 - size): 83.0322/95.8496 = 86.6308%
     and 32.2266/94.1406 = 34.2333%. */
  background-position: 86.6308% 34.2333%;
  background-size: 4.1504% 5.8594%;
  background-repeat: no-repeat;
  background-image: url("hero-eye/eyeball-1280.png");
  background-image: image-set(
    url("hero-eye/eyeball-1280.webp") type("image/webp"),
    url("hero-eye/eyeball-1280.png") type("image/png"));
}
[data-theme="dark"] .hero-art:has(> canvas) .hero-eye::before {
  content: "";
  position: absolute;
  left: 83.0322%; top: 32.2266%; width: 4.1504%; height: 5.8594%;
  background-size: 100% 100%;
  background-repeat: no-repeat;
  background-image: url("hero-eye/pupil-1280.png");
  background-image: image-set(
    url("hero-eye/pupil-1280.webp") type("image/webp"),
    url("hero-eye/pupil-1280.png") type("image/png"));
  /* the pupil's own centre inside the crop - the axis the widening turns about */
  transform-origin: 48.529% 53.333%;
  animation: eye-pupil-dark 19s infinite both;
  animation-timing-function: ease-out;
}

/* Every move is 0.5% of the cycle - 95ms - and lands on a hold. The pupil is
   STILL across 6-7.5% and 47-48.5%, which is when the lid closes: a blink over
   a pupil mid-snap would read as a stutter. Move a snap and check those windows.

   `animation-timing-function: ease-out` is on the rule rather than per keyframe,
   which is safe because every hold is constant-valued, so easing only ever
   applies to the snaps - fast away, settling in, which is the saccade's shape. */
@keyframes eye-pupil-dark {
  0%,     12% { transform: translate(0, 0) rotate(-11.17deg) scale(1, 1) rotate(11.17deg); }
  12.5%,  28% { transform: translate(-43.725%, -20.267%) rotate(-98.88deg) scale(1.0950, 1) rotate(98.88deg); }
  28.5%,  45% { transform: translate(-60.608%, 0%) rotate(-159.43deg) scale(1.1118, 1) rotate(159.43deg); }
  45.5%,  62% { transform: translate(0, 0) rotate(-11.17deg) scale(1, 1) rotate(11.17deg); }
  62.5%,  70% { transform: translate(-39.706%, 8.889%) rotate(0deg) scale(1.1574, 1) rotate(0deg); }
  70.5%,  91% { transform: translate(-43.725%, 38.044%) rotate(98.88deg) scale(1.0950, 1) rotate(-98.88deg); }
  91.5%, 100% { transform: translate(0, 0) rotate(-11.17deg) scale(1, 1) rotate(11.17deg); }
}

/* An eye darting and blinking on the first screen is the motion most likely to
   provoke vestibular symptoms, and there is no Play control to make it
   consensual. Resetting `animation` stops both eye-look and eye-blink whatever
   they are named; the lid's own declaration parks it outside the disc, so what
   is left is the artwork untouched in either theme. */
@media (prefers-reduced-motion: reduce) {
  .hero-eye, .hero-eye::before, .hero-eye::after { animation: none; }
}
