/* The copy sits high rather than centred: the lower half of the viewport
   belongs to the landscape and to the rabbit standing at his hole, and a
   vertically centred block lands right on the treeline. --copy-top is the knob
   — it is measured from the top of the viewport, and the floor keeps it clear
   of the fixed header (~84px) on a short window. */
.enhanced .surface {
  --copy-top: clamp(112px, 19vh, 240px);
  /* This block's height IS the ground line, because .underground follows it in
     normal flow. It was 100vh, and geometry.js was told window.innerHeight; on
     a desktop those are the same number so nothing showed, and on a phone they
     differ by the height of the URL bar. main.js now writes --ground-y from the
     same value it hands the geometry, so the two cannot disagree. The 100vh
     fallback is what an unenhanced or pre-JS frame gets, which is correct: it
     is also what the old behaviour was. */
  min-height: var(--ground-y, 100vh);
  padding: var(--copy-top) 0 0;
  display: grid;
  align-content: start;
  position: relative;
}

.enhanced .surface-scene {
  display: block;
  position: absolute;
  inset: 0;
  overflow: hidden;
  z-index: 0;
}

/* The gradient here is only what shows before daycycle's first frame lands —
   the midnight palette, so an unstyled flash matches the site's darkest state
   rather than flashing bright. surface.js owns it from load onward. */
.enhanced .sky {
  position: absolute;
  inset: 0;
  background-image: radial-gradient(120% 78% at 50% 100%, #2A3348 0%, #161B2B 38%, #0B0E18 70%, #06070D 100%);
}

/* Two stacked layers so a palette change during a sweep is an opacity
   crossfade rather than a repaint mid-motion. The base layer holds the sky as
   it currently is; the fade layer carries the incoming palette in over it. Only
   the fade layer ever animates, which is what keeps the swap deterministic —
   see the comment on crossfadeSky in surface.js. */
.enhanced .sky-layer {
  position: absolute;
  inset: 0;
  will-change: opacity;
}

.enhanced .sky-fade { opacity: 0; }

.enhanced .stars { position: absolute; inset: 0; }

/* ── The wishing star ─────────────────────────────────────────────────────
   A meteor across the deep-night sky. Ambient only. Spec in
   docs/wishing-star-spec.md; surface.js owns when and where, this owns how it
   looks and how it moves.

   The wrapper is a zero-size point carrying WHERE and WHICH WAY. Its opacity is
   written from daycycle's `deep`, so the whole thing is gated on the dead of
   night without either child knowing about the clock. */
.enhanced .wish {
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  opacity: 0;
  translate: var(--wish-x, 0) var(--wish-y, 0);
  rotate: var(--wish-a, 160deg);

  --wish-len: 180px;      /* trail length at full stretch — surface.js sizes it */
  --wish-travel: 300px;   /* how far the head goes — likewise                   */
  --wish-ms: 1100ms;      /* one crossing. A real meteor is 300–800.            */
  --wish-w: 2px;
  --wish-head: 3px;
}

/* Both children start invisible, because without a running animation they would
   simply sit in the sky at full opacity. */
.enhanced .wish-tail,
.enhanced .wish-head {
  position: absolute;
  opacity: 0;
  will-change: translate, opacity;
}

/* The trail. Its RIGHT edge is the head, and `transform-origin` puts the scale
   there — which is the whole trick: growing `scale` from 0 extends the trail
   BACKWARDS from the head while the head travels forwards, so the back of it
   stays near where the meteor entered. That is what a real one does. A
   fixed-length dash sliding along at constant speed is the wrong version, and it
   is what this looks like if the origin ever goes back to the default centre.

   Stretching the gradient along with it is correct rather than a side effect:
   the gradient is a fade profile in trail-space, not a picture.

   Cool blue at the dying end, white at the head — the trail cools as it decays,
   which is both what happens and the prettier of the two. The capsule radius and
   the sub-pixel blur take the digital edge off a 2px rectangle; blur(0) is the
   toggle if it ever costs anything. */
.enhanced .wish-tail {
  left: calc(var(--wish-len) * -1);
  top: calc(var(--wish-w) / -2);
  width: var(--wish-len);
  height: var(--wish-w);
  transform-origin: 100% 50%;
  border-radius: 999px;
  filter: blur(.4px);
  background: linear-gradient(to right,
    transparent 0%,
    rgba(150,185,255,.25) 30%,
    rgba(214,230,255,.75) 78%,
    #FFFFFF 100%);
  will-change: translate, scale, opacity;
}

/* Three nested glows, not one. A single shadow reads as a blurred dot; the cool
   outer bloom is most of what makes this feel magical rather than merely
   correct — the same layered trick the moon above uses. The head takes the
   travel and NOT the scale, which is why it is a separate element: one transform
   cannot stretch the trail and leave this round. */
.enhanced .wish-head {
  left: calc(var(--wish-head) / -2);
  top: calc(var(--wish-head) / -2);
  width: var(--wish-head);
  height: var(--wish-head);
  border-radius: 50%;
  background: #FFFFFF;
  box-shadow:
    0 0 5px 1.5px rgba(255,255,255,.95),
    0 0 16px 5px  rgba(176,206,255,.50),
    0 0 34px 12px rgba(140,175,255,.22);
}

/* Three animations rather than one keyframe list, and the reason is easing.
   `animation-timing-function` applies between EVERY pair of keyframes, so a
   single list with a mid-point for the stretch would ease in and out twice and
   the meteor would visibly hesitate half way across. Split onto the independent
   `translate` and `scale` properties, the crossing gets one global deceleration
   — fast out of the gate, slowing as it ablates — while the trail grows on its
   own shorter clock underneath it. Linear travel is the tell that gives this
   away as CSS.

   `forwards` on each: the stretch finishes at 55% and has to hold its length for
   the rest of the crossing rather than snapping back. */
@keyframes wish-fly  { to { translate: var(--wish-travel) 0; } }
@keyframes wish-grow { from { scale: 0 1; } to { scale: 1 1; } }
@keyframes wish-fade {
  0%   { opacity: 0; }
  12%  { opacity: 1; }
  70%  { opacity: 1; }
  100% { opacity: 0; }
}

.enhanced .wish--fire .wish-tail {
  animation:
    wish-fly  var(--wish-ms) cubic-bezier(.15,.55,.35,1) forwards,
    wish-grow calc(var(--wish-ms) * .55) cubic-bezier(.2,.8,.4,1) forwards,
    wish-fade var(--wish-ms) linear forwards;
}

.enhanced .wish--fire .wish-head {
  animation:
    wish-fly  var(--wish-ms) cubic-bezier(.15,.55,.35,1) forwards,
    wish-fade var(--wish-ms) linear forwards;
}

/* ── Daytime birds ─────────────────────────────────────────────────────────
   A skein crossing the daylight sky — the wishing star's opposite number, and
   built the same way for the same reason: surface.js owns when, where and the
   formation, this owns what one bird is and how it beats. Spec in
   docs/birds-spec.md.

   The wrapper's opacity is written from daycycle's `daylight`, so the whole
   thing is gated on the hour without any bird knowing about the clock. It also
   carries the parallax, which is the one place this differs from the meteor:
   the star field does not move with scroll and the birds do, because they sit
   between the ridge and the sky rather than in it. */
.enhanced .birds {
  position: absolute;
  inset: 0;
  opacity: 0;

  /* The four that decide whether these are visible at all, which is the first
     thing to judge and the first thing that was wrong. Nine pixels at 1.6 thick
     and .8 alpha — a real bird's angular size at a real distance — came to about
     fourteen square pixels of half-transparent grey per bird, and disappeared
     completely against a noon sky. Honest and useless.
     Raised to fourteen. Still small: about 1% of a laptop's width, against a
     rabbit who is roughly 130px tall. If the flock now reads as too near, this
     is the dial, not the parallax. */
  --bird-w: 14px;                 /* wingspan, before each bird's own --bird-s */
  --bird-t: 2.2px;                /* how thick one wing is                    */
  --bird-ink: rgba(30,36,48,.85); /* silhouette. Distance lives in the alpha,
                                     which varies per bird — see surface.js    */
  --flap-up: -14deg;              /* wingtips above the body                  */
  --flap-down: 24deg;             /* and below it. Over a 7px wing that sweeps
                                     a tip about 4px, which at this size is
                                     the difference between shimmer and
                                     invisibility                             */
  --bird-bob: 4px;                /* how far one bird drifts inside the
                                     formation, on its own clock              */
}

/* BIRDS_DEBUG in surface.js. Not a look — a way to tell "wrong hour" and "one
   skipped firing" apart from "drawn but too faint", which are three questions
   that all present as an empty sky. Unmissable on purpose. */
.enhanced .birds--debug {
  --bird-w: 44px;
  --bird-t: 7px;
  --bird-ink: #FF0000;
  --bird-bob: 10px;
}

/* Zero-size, so the birds' own negative offsets place them behind a leader
   sitting at the origin. `scale` mirrors the whole formation when the crossing
   runs right-to-left, which is what keeps the apex of the V at the FRONT — the
   one thing that would read as flying backwards. It is safe beside the crossing
   below because `translate` and `scale` are independent properties: the
   translate stays in the parent's pixels either way.

   Hidden by default. Without this the flock would simply sit in the sky at its
   start position whenever no crossing is running. */
.enhanced .flock {
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  opacity: 0;
  scale: var(--flock-flip, 1) 1;
}

.enhanced .birds--fly .flock {
  opacity: 1;
  animation: birds-cross var(--birds-ms, 30s) linear forwards;
}

/* Linear, and that is the whole difference from the meteor above. A meteor
   decelerates because it is burning away; a bird crossing the sky does not, and
   an eased crossing here reads as the sky itself slowing down. `forwards` parks
   the flock off the far edge instead of snapping it back to the near one.

   Both ends are px from surface.js rather than percentages: the flock is a
   zero-width element, so a percentage would resolve against nothing. */
@keyframes birds-cross {
  from { translate: var(--birds-from, 0) 0; }
  to   { translate: var(--birds-to, 0) 0; }
}

/* One bird. `left`/`top` are its place in the formation and never animate;
   `translate` is its own slow drift within it, so the two do not fight. Every
   per-bird value is set by surface.js, because what sells a flock is that no
   two birds in it are the same size, the same darkness, or on the same beat. */
.enhanced .bird {
  position: absolute;
  width: var(--bird-w);
  height: var(--bird-t);
  opacity: var(--bird-o, .7);
  scale: var(--bird-s, 1);
  animation: bird-bob var(--bob-ms, 3.4s) ease-in-out infinite alternate;
}

/* The two wings, each rotating about its INNER edge — where the body would be —
   so the tips rise and fall while the middle stays put. There is deliberately
   no body element: at nine pixels the two wing roots meeting IS the body, and a
   third element there turns a bird into a bird-shaped logo.
   The negative delay is on purpose — every bird starts already mid-beat rather
   than the whole flock striking the same pose on the first frame. */
.enhanced .bird::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  width: 50%;
  height: 100%;
  background: var(--bird-ink);
  border-radius: 999px;
  transform-origin: 100% 50%;
  animation: flap-left var(--flap-ms, .5s) var(--flap-delay, 0ms) ease-in-out infinite alternate;
}

.enhanced .bird::after {
  content: '';
  position: absolute;
  top: 0;
  right: 0;
  width: 50%;
  height: 100%;
  background: var(--bird-ink);
  border-radius: 999px;
  transform-origin: 0 50%;
  animation: flap-right var(--flap-ms, .5s) var(--flap-delay, 0ms) ease-in-out infinite alternate;
}

/* Two keyframe sets rather than one plus a mirror, because the angles have to
   be negated and nothing else does: rotating clockwise drops the tip on the
   left of the origin and lifts the one on the right, so the same number sent to
   both wings beats them in opposite directions. */
@keyframes flap-left {
  from { rotate: var(--flap-up); }
  to   { rotate: var(--flap-down); }
}

@keyframes flap-right {
  from { rotate: calc(var(--flap-up) * -1); }
  to   { rotate: calc(var(--flap-down) * -1); }
}

@keyframes bird-bob {
  from { translate: 0 calc(var(--bird-bob) / -2); }
  to   { translate: 0 calc(var(--bird-bob) / 2); }
}

.enhanced .moon,
.enhanced .sun {
  position: absolute;
  border-radius: 50%;
}

/* The fill is still the moon. Everything below only adds shading on top of it,
   which is why this colour and TARGET_L in build-moon.py have to move together:
   the texture was scaled at bake time so its brightest highlands land exactly
   here, and that is what lets the detail arrive without the disc dimming. */
.enhanced .moon {
  background: #F2EFE2;
  box-shadow: 0 0 70px 26px rgba(238,234,214,.20), 0 0 180px 70px rgba(180,196,225,.12);

  /* Uncomment one. Deleting both, or setting --moon-detail to 0, gives back the
     flat disc exactly — the fill and the glow above are untouched by any of it.
     The two textures want different amounts because they arrive at different
     brightnesses (see the mean printed by the tool): the photograph is a real
     grey moon and goes quiet, the painting is already close to the fill and can
     be turned most of the way up. */
  --moon-texture: url("../assets/sky/moon-photo.webp"); --moon-detail: .45;
  /* --moon-texture: url("../assets/sky/moon-paint.webp"); --moon-detail: .8; */
}

/* A texture over the disc rather than a picture of a moon. The element is a
   perfect circle sized by surface.js, and build-moon.py crops the render so its
   disc touches all four edges of a square frame — so stretching that square
   over this box matches the two at every viewport with no constant to keep in
   sync and nothing for a resize to get wrong. It rides the parent's opacity,
   which is what daycycle.js writes to raise and set the moon, so the detail
   fades in and out with the body it belongs to instead of hanging in the sky
   after it has gone. */
.enhanced .moon::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--moon-texture, none) center / 100% 100% no-repeat;
  opacity: var(--moon-detail, 0);
}

/* Warmer, and a much wider glow — a moon reads as a disc, a sun reads as the
   thing washing out the sky around it. */
.enhanced .sun {
  background: #FFF6DF;
  box-shadow: 0 0 90px 34px rgba(255,226,160,.35), 0 0 260px 120px rgba(255,186,110,.22);
}

.enhanced .fog {
  position: absolute;
  left: -10%;
  width: 120%;
  height: 190px;
  background: linear-gradient(to top, rgba(150,168,200,.20), transparent);
  filter: blur(22px);
}

/* One box for all three bands. They were cut from a single painting whose
   three planes overlap almost completely — the conifers rise as high as the
   mountains, within half a percent of frame height — so giving them different
   heights would give them different `cover` scales and the picture would stop
   reassembling. They differ in image and in parallax rate, which is all they
   ever needed to differ in. surface.js owns the height. */
.enhanced .terrain {
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
}

.enhanced .band {
  position: absolute;
  inset: 0;
  will-change: transform;
}

/* `cover` from the bottom is the spec's "generate ultra-wide and crop from the
   centre" for free, with the ground line pinned: a narrow viewport crops
   further into the middle instead of squashing, and no breakpoint or JS
   measurement is involved. Below the aspect of the plate it starts cropping the
   top instead — see BAND_H in surface.js. */
.enhanced .band-layer {
  position: absolute;
  inset: 0;
  background-repeat: no-repeat;
  background-size: cover;
  /* Longhands, because only the horizontal half is variable. --band-anchor is
     written onto the parent .band by surface.js, which uses the same fraction
     to place the chimney smoke — see placeSmoke. The 50% fallback is the old
     `center` exactly, so a band nobody sets it on is unchanged. */
  background-position-x: var(--band-anchor, 50%);
  background-position-y: bottom;
  will-change: opacity;
}

/* The front layer of each band; opacity is written every tick. */
.enhanced .band-layer + .band-layer { opacity: 0; }

/* Chimney smoke. A looping WebP with a real alpha channel, not a video and not
   a blend-mode trick — `screen` over the black it was generated on would show
   at midnight and vanish at noon, which is the one thing a fire in the cabin
   must not do. Hidden without JS for the same reason the bands are: nothing
   here is positioned until surface.js has measured the `cover` crop, and an
   unplaced plume would land at its natural size in the middle of the page.
   Opacity is the knob — see SMOKE_OPACITY in surface.js, 0 takes it off. */
.smoke { display: none; }

.enhanced .smoke {
  display: block;
  position: absolute;
  pointer-events: none;
  /* Zero until surface.js has measured the crop and placed it. Without this the
     image paints once at its natural 96x265 in the corner of the band, between
     `.enhanced` landing and the first update(). */
  width: 0;
  height: 0;
}

/* The copy travels horizontally against the sun and moon, so the layout itself
   reads as a clock: title left / moon right at midnight, title right / sun left
   at noon, centred at both twilights. The max-width is what gives the travel a
   band to move in — without it the block fills the column and cannot move.
   surface.js measures the leftover space; on a narrow viewport there is none
   and the travel falls to zero on its own. */
.enhanced .surface-copy {
  position: relative;
  z-index: 3;
  padding: 0 clamp(20px, 5vw, 80px);
  max-width: min(820px, 62vw);
  will-change: transform;
}

.enhanced .scroll-hint {
  position: absolute;
  left: 50%;
  bottom: 30px;
  transform: translateX(-50%);
  z-index: 3;
  margin: 0;
  transition: opacity .4s ease;
}

/* The hint has done its job the instant he moves. */
.enhanced .scroll-hint.faded { opacity: 0; }

/* ═══ The bat signal ═══════════════════════════════════════════════════════
   The one thing on this page you can click that is not navigation. Spec in
   docs/bat-signal-spec.md; batsignal.js owns the sequence and the arithmetic.

   ONE SWITCH: `.batsig-on` on <html>. The label and the lamp are in .surface,
   the cone is in .surface-scene, and the sign is inside .moon — three containers
   with three different transforms, two of them owned by surface.js. A class on
   the root element is the only switch that reaches all four without one module
   writing into another's element, and it means every transition in the feature
   is a CSS transition with its timing in this one place.

   THERE IS NO --bat-show HERE, and the absence is deliberate. The wishing star,
   the birds, the crust and the carrot all switch off through `opacity: var(...)`
   because nothing can interact with any of them. This feature has a BUTTON in
   it, and a button at zero opacity is still a button — so the off switch is
   BAT_SHOW in batsignal.js and it removes the element outright. */

.enhanced {
  /* On the root because the four elements are in three different subtrees and a
     custom property only inherits downward. */
  --bat-fade: 900ms;       /* keep above PANEL_DELAY in batsignal.js */
  --bat-help: #F2C60E;     /* --accent is #72bd02, a green — this is a new colour on purpose */
  --bat-help-hi: #FFE066;  /* hover. Lighter rather than more saturated: at this size a
                              yellow pushed further toward orange reads as a different
                              colour, where a lighter one reads as the same word lit up */
}

.batsig { display: none; }

.enhanced .batsig {
  /* 0 hides the plate and takes the cone's apex down to the foot, so the light
     appears to come from behind the word HELP.

     THIS IS SETTLED AT 0 AND THERE IS NO PROJECTOR TO GENERATE. It began as
     Franck's own fallback — "if the projector doesn't work out we can just have
     the cone come out from behind the help text" — was built first for that
     reason, and was then judged on the page: "it works great, no need to generate
     any projector". So the plate is not missing work; a light with no visible
     source turned out to be the better picture, and it costs one file, one build
     tool and ~200 KB less than the alternative.

     The mechanism stays because it is three lines and it is what made the
     fallback free in the first place: lampH goes to 0 and beamFor is unchanged,
     no branch anywhere. Dropping a plate in is --lamp-plate plus --bat-lamp: 1.

     A MULTIPLIER on the box, never `display: none` and never `height: 0` on its
     own: batsignal.js measures this element's offsetTop for the cone's foot and
     its offsetHeight for the lamp's length, and a box removed from flow measures
     zero for BOTH — which would put the cone's apex at the top of the page. */
  --bat-lamp: 0;
  --lamp-w: 26px;
  --lamp-h: 48px;
  /* --lamp-plate: url("../assets/terrain/projector.webp"); --bat-lamp: 1; */

  position: absolute;
  right: clamp(20px, 5vw, 80px);   /* the surface copy's own inset */
  bottom: 30px;                    /* the scroll hint's baseline */
  z-index: 3;
  display: grid;
  justify-items: center;
  gap: 8px;
}

.enhanced .batsig-help {
  appearance: none;
  background: none;
  border: 0;
  padding: 2px 4px;
  cursor: pointer;
  font: 700 11px var(--mono);
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--bat-help);
  /* Two different speeds on purpose. The colour answers the pointer, so it is
     quick; the glow is the same one the lit state uses, so it shares --bat-fade
     and hovering reads as the signal warming up rather than as a UI rollover. */
  transition: color .18s ease, text-shadow var(--bat-fade) ease;
}

/* Lit, the label stops being a control and becomes part of the picture — the
   same light the lens is throwing, at a fraction of it. */
.enhanced.batsig-on .batsig-help { text-shadow: 0 0 14px rgba(242,198,14,.6); }

/* AFTER the lit rule, so hovering a signal that is already on still brightens.
   :focus-visible comes along for the ride — the keyboard path should not be the
   one that gets no feedback, and base.css's outline alone is easy to miss on a
   word this small. */
.enhanced .batsig-help:hover,
.enhanced .batsig-help:focus-visible {
  color: var(--bat-help-hi);
  text-shadow: 0 0 16px rgba(255,224,102,.75), 0 0 40px rgba(242,198,14,.35);
}

/* Drawn pointing straight up and turned to face the moon. That axis is not a
   taste call: the beam is about 19° off vertical at the widest viewport measured
   and closer to vertical at narrow ones, so the plate is never seen from an angle
   it was not drawn for.

   One plate, the barrel alone, no stand — a stand would have to NOT rotate while
   the head does, which is two plates and a pivot to keep in agreement, and at
   48px a barrel with a dark foot reads on its own. It pivots about that foot,
   which is the point batsignal.js measures, and a transform does not move
   offsetTop, so the measurement survives every reflow. */
.enhanced .batsig-lamp {
  position: relative;
  width: calc(var(--lamp-w) * var(--bat-lamp));
  height: calc(var(--lamp-h) * var(--bat-lamp));
  background: var(--lamp-plate, none) bottom center / 100% 100% no-repeat;
  transform-origin: 50% 100%;
  transform: rotate(var(--lamp-deg, 0deg));
  opacity: 0;
  transition: opacity var(--bat-fade) ease;
}

.enhanced.batsig-on .batsig-lamp { opacity: 1; }

/* What "the light spill is revealing the projector itself" means while there is
   no projector: a bloom at the apex. Sized independently of --bat-lamp so it
   survives the plate being off — at 0 the lamp's top edge IS its foot, so the
   bloom lands behind the word HELP, which is exactly where the fallback wants
   the light to come from. */
.enhanced .batsig-lamp::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: 100%;
  width: 54px;
  height: 54px;
  margin: 0 0 -27px -27px;
  border-radius: 50%;
  background: radial-gradient(circle,
    rgba(234,241,255,.85), rgba(234,241,255,.20) 40%, transparent 70%);
  opacity: 0;
  transition: opacity var(--bat-fade) ease;
  pointer-events: none;
}

.enhanced.batsig-on .batsig-lamp::after { opacity: 1; }

/* The cone is arithmetic, not art: one box, one rotate. batsignal.js writes the
   width, the height, the translate, the angle and --cone-stop; everything about
   how it looks is here.

   NO `filter: blur()`. The obvious build is a clipped triangle with a blur over
   it, and it is the same mistake as url(#tubeShade): a filter region this tall is
   rasterised in TILES, a blur cannot read across a tile boundary, and the result
   bands down its own length. That is what closed roundness in the tunnel — read
   load-bearing idea 8j before reaching for one here. Softness is gradients only.

   The taper is a clip-path trapezoid, with a real aperture at the lens rather
   than a mathematical point. Then two gradients, and the first one is doing more
   work than it looks:

   ACROSS — one linear-gradient spanning the whole box height. The clip is narrow
   at the lens, so only the gradient's bright plateau survives there; it is wide
   at the moon, so the soft falloff at the edges does. A beam that is tight and
   hard at the source and diffuse at distance falls out of the CLIP for free, with
   no second element and no filter.

   ALONG — a mask that fades to nothing before the disc. The light arrives at the
   moon and the moon is what glows. A pale wash reaching over the disc would grey
   the black sign, which is the one thing in this picture that has to stay solid. */
.cone { display: none; }

.enhanced .cone {
  --cone-apex: 4px;             /* HALF the lens aperture — the clip uses it either side */
  --cone-alpha: .34;
  --cone-tint: 234, 241, 255;   /* cool, a shade off the moon's own #F2EFE2 */
  --cone-stop: 82%;             /* written every reflow; this is only the value before the first */

  display: block;
  position: absolute;
  left: 0;
  top: 0;
  transform-origin: 0 50%;
  will-change: transform;
  pointer-events: none;
  clip-path: polygon(
    0 calc(50% - var(--cone-apex)), 100% 0,
    100% 100%, 0 calc(50% + var(--cone-apex)));
  background: linear-gradient(to bottom,
    rgba(var(--cone-tint), 0) 0%,
    rgba(var(--cone-tint), var(--cone-alpha)) 30%,
    rgba(var(--cone-tint), var(--cone-alpha)) 70%,
    rgba(var(--cone-tint), 0) 100%);
  -webkit-mask-image: linear-gradient(to right, #000 0%, #000 26%, transparent var(--cone-stop));
  mask-image: linear-gradient(to right, #000 0%, #000 26%, transparent var(--cone-stop));
  opacity: 0;
  transition: opacity var(--bat-fade) ease;
}

.enhanced.batsig-on .cone { opacity: 1; }

/* The sign is a CHILD of .moon, and that buys four things for free: `place` in
   surface.js already writes the moon's left, top and opacity on every tick and
   `parallax` writes its transform, and a child inherits all four. Nothing in
   surface.js knows this element exists, and nothing in batsignal.js has to
   reproduce the moon's placement in order to follow it.

   A MASK, not a background-image. batsign.svg is a potrace trace with fill baked
   in; as a mask the file supplies only the shape and --bat-ink supplies the
   colour, so the ink is a knob rather than someone else's #000.

   --bat-w is a fraction of the moon's own box, and the box IS the disc:
   build-moon.py crops the texture to the lit disc's bounding box, so a fraction
   of one is a fraction of the other. z-index because .moon::after — the lunar
   texture — is generated as the last child and would otherwise paint its 45%
   over the top of the sign. */
.batsign { display: none; }

.enhanced .batsign {
  --bat-w: .78;
  --bat-ink: #14161C;   /* near-black, not black: it sits in the palette instead of punching a hole */

  /* Not fully opaque, so the disc reads THROUGH the sign and the two are one
     object rather than a decal on a circle — Franck's note on the page was that
     it needed to integrate better. It has to be a variable rather than a lower
     --bat-ink alpha for a mechanical reason: the ink is a background-COLOR under
     a mask, and an alpha there is composited before the mask, so the mask's own
     anti-aliased edge gets multiplied twice and the outline goes soft. Element
     opacity fades the finished shape, edge included.
     It is also what the transition below animates, so 1 is the value to raise it
     to and there is nothing else to change. */
  --bat-sign-op: .82;

  display: block;
  position: absolute;
  left: 50%;
  top: 50%;
  z-index: 1;
  width: calc(100% * var(--bat-w));
  aspect-ratio: 4000 / 1887;   /* the trace's own, so 100% 100% cannot squash the glyph */
  transform: translate(-50%, -50%);
  background-color: var(--bat-ink);
  -webkit-mask: url("../assets/sky/batsign.svg") center / 100% 100% no-repeat;
  mask: url("../assets/sky/batsign.svg") center / 100% 100% no-repeat;
  opacity: 0;
  transition: opacity var(--bat-fade) ease;
}

.enhanced.batsig-on .batsign { opacity: var(--bat-sign-op); }

/* A real <dialog> opened with showModal(): Esc, the focus trap, the backdrop and
   inertness for everything behind it all arrive for free, with no library and no
   focus management to get wrong. Authored outside <main> — showModal() promotes
   it to the top layer where <main>'s transform cannot reach it anyway, but the
   unenhanced document should not have a dialog nested in transformed content
   either. Closed, a <dialog> is display:none by the UA sheet, so nothing here
   needs to hide it. */
.batpanel {
  border: 0;
  padding: 0;
  max-width: min(430px, calc(100vw - 36px));
  color: var(--ink);
  background: rgba(11, 13, 19, .94);
  border-radius: 3px;
  box-shadow: 0 0 0 1px var(--line), 0 30px 90px rgba(0,0,0,.62);
}

.batpanel::backdrop { background: rgba(4, 6, 12, .58); }

.batpanel-inner { padding: 26px 28px 22px; }

.batpanel h2 {
  font-size: 21px;
  font-weight: 700;
  letter-spacing: -.02em;
  line-height: 1.1;
}

.batpanel-lede { margin-top: 8px; font-size: 13px; line-height: 1.5; color: var(--muted); }

/* A definition list because that is what this is: the control, then what it does.
   Two columns, baselines aligned, so the terms read as a legend. */
.batpanel dl {
  margin: 20px 0 0;
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 11px 16px;
  align-items: baseline;
}

.batpanel dt {
  font: 400 11px var(--mono);
  letter-spacing: .1em;
  color: var(--bat-help);
  white-space: nowrap;
}

.batpanel dd { margin: 0; font-size: 14px; line-height: 1.45; color: var(--muted); }

.batpanel-close {
  appearance: none;
  margin-top: 24px;
  width: 100%;
  padding: 11px 14px;
  border: 1px solid var(--line);
  border-radius: 2px;
  background: none;
  cursor: pointer;
  font: 400 11px var(--mono);
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--ink);
  transition: border-color .2s ease, color .2s ease;
}

.batpanel-close:hover { border-color: var(--bat-help); color: var(--bat-help); }
