/* jjscoolwebsite.com — homepage only: the intro and the project cards. */

/* --- the dials -------------------------------------------------------
   Tunable numbers for the page, all in one place. Clouds: each drifts
   left one art pixel at a time round a loop slightly wider than the sky
   (the dead zone), so a cloud is often visible at both edges at once —
   hence two spans per cloud in the markup (see .clouds below). */
:root {
  /* The art's own canvas in art pixels — only change if the beach art is
     re-exported at a different size. */
  --sky-w: 256;
  --sky-h: 288;

  /* How far past the left edge a cloud pixel drifts before reappearing at
     the right. Small = crowded sky (visible at both edges often); past
     --sky-w = sky empties out between passes. Must be a whole number. */
  --cloud-dead: 10;

  /* One lap of the cloud loop: sky width + dead zone. Derived, not a dial. */
  --cloud-loop: calc(var(--sky-w) + var(--cloud-dead));

  /* Per-cloud speed (time to cross one art pixel): near/big clouds get
     small numbers, far/small ones get big numbers. Keep the four spaced
     apart or the sky falls back into sync. */
  --cloud-1-speed: 0.55s;
  --cloud-2-speed: 0.95s;
  --cloud-3-speed: 0.68s;
  --cloud-4-speed: 1.10s;

  /* Sea: line-scroll water, one CSS row per art-pixel row, each swaying
     slightly out of phase with its neighbours (the old 16-bit line-scroll
     trick for faking water). --sea-band-top/-h place the water band on
     the shared canvas (measured off the art's own alpha); --sea-amplitude/
     -speed control the sway width (art pixels, must be whole) and its
     speed; --sea-cycle is the resulting loop length, derived once here. */
  --sea-band-top: 232;
  --sea-band-h: 55;
  --sea-rows: var(--sea-band-h);
  --sea-amplitude: 2;
  --sea-speed: 0.85s;
  --sea-cycle: calc(var(--sea-amplitude) * 4 * var(--sea-speed));

  /* Birds: how long each of the two wing cels holds before the other
     takes over. One flap cycle is twice this. */
  --bird-frame: 0.5s;

  /* Things lying on the sand (the cool sunglasses — see index.njk): how
     wide the sprite sits, and how far up from the canvas's bottom edge,
     both in art pixels off the same 256x288 canvas as everything else
     here. Turned into percentages of that canvas below, never px, so the
     pickup stays in the same spot on the beach at any window size. The
     sand runs from about row 261 down to the bottom edge at the middle of
     the art, so keep w + bottom under ~27 or the sprite floats off it. */
  --beach-item-w: 19;
  --beach-item-bottom: 3;

  /* Pixel-art scale for the logo/badge — whole numbers only, so source
     pixels map exactly to screen pixels. Dropped a notch on narrow phones. */
  --hero-px: 3;
  --badge-px: 2;
}

@media (max-width: 560px) {
  :root { --hero-px: 2; --badge-px: 1; }
}

/* The beach background, homepage only. Stretched to window width, pixels
   kept hard. Set on <main> not <body>, since on a chrome page <body> is
   pinned to one viewport and never scrolls — <main> is the real scroll
   container. background-attachment: local ties the image to main's
   content so it scrolls with the page instead of sitting fixed.
   Only the static sun+sky layers are painted here; sea/clouds/stars/
   birds/foreground are drawn as real elements below so they can animate
   independently. The stars image here is the no-script fallback — main.js
   redraws stars into the .stars canvas and adds .has-live-stars to drop
   this copy once it has, so stars are never painted twice. */
.has-chrome > main {
  background-image:
    url("/assets/web/pages/home/beach-background-stars.png"),
    url("/assets/web/pages/home/beach-background-sun.png"),
    url("/assets/web/pages/home/beach-background-bg.png");
  background-repeat: no-repeat;
  background-position: top center;
  background-size: 100% auto;
  background-attachment: local;
  image-rendering: crisp-edges;
  image-rendering: pixelated;
  /* The stars canvas is absolutely positioned against this box. */
  position: relative;
}

.has-chrome > main.has-live-stars {
  background-image:
    url("/assets/web/pages/home/beach-background-sun.png"),
    url("/assets/web/pages/home/beach-background-bg.png");
}

/* Twinkling star layer, pinned to main's content (not viewport) so it
   scrolls with the sky, matching background-attachment: local above.
   width: 100% + the art's aspect-ratio reproduces background-size:
   100% auto exactly, so stars land on their exported pixels at any width. */
.stars {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 0;
  width: 100%;
  height: auto;
  aspect-ratio: var(--sky-w) / var(--sky-h);
  pointer-events: none;
  image-rendering: crisp-edges;
  image-rendering: pixelated;
}

/* --- the rest of the beach's layers -----------------------------------
   Birds and beach foreground: two more exports off the same 256x288
   canvas, each carrying only its own pixels, so flush (top/left: 0)
   reproduces the original painting. Stacking is by source order
   (.sea/.clouds/.stars before, these after), all at z-index: 0. */
.birds,
.beach-foreground {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 0;
  width: 100%;
  aspect-ratio: var(--sky-w) / var(--sky-h);
  background-repeat: no-repeat;
  background-size: 100% 100%;
  pointer-events: none;
  image-rendering: crisp-edges;
  image-rendering: pixelated;
}

/* Two wing cels, both full-canvas exports like every other layer here.
   Each gets its own pseudo-element (.birds itself paints nothing) and
   the pair shares one keyframe, the second running half a cycle behind
   via a negative delay — so exactly one cel is ever visible. They can't
   simply stack, transparent PNGs that they are: a cel laid over the
   other would show both sets of wings at once rather than replacing it.

   steps(1, end) holds each half of the cycle flat, so the swap is a hard
   cut on the --bird-frame beat, never a cross-fade through a ghost
   frame. Both cels are painted from the first frame (one just sits at
   opacity: 0), so the browser has fetched and decoded both before the
   first flap — no blank gap waiting on the second PNG. */
.birds::before,
.birds::after {
  content: "";
  position: absolute;
  inset: 0;
  background-repeat: no-repeat;
  background-size: 100% 100%;
  image-rendering: crisp-edges;
  image-rendering: pixelated;
  animation: birds-flap calc(var(--bird-frame) * 2) steps(1, end) infinite;
}

.birds::before {
  background-image: url("/assets/web/pages/home/beach-background-birds-1.png");
}

/* Half a cycle in, i.e. cel 2 shows exactly while cel 1 doesn't. */
.birds::after {
  background-image: url("/assets/web/pages/home/beach-background-birds-2.png");
  animation-delay: calc(var(--bird-frame) * -1);
}

@keyframes birds-flap {
  0%   { opacity: 1; }
  50%  { opacity: 0; }
}

.beach-foreground {
  background-image: url("/assets/web/pages/home/beach-background-beach-foreground.png");
}

/* --- things lying on the sand -------------------------------------------
   One more full-canvas frame, same as .birds/.beach-foreground above, but
   holding real elements instead of a painted export — so anything put in
   it is positioned in the art's own coordinates (as percentages of the
   frame) and scales with the window along with the beach it's lying on.

   z-index: 1 rather than the 0 the painted layers share: the pickup in
   here is a button, not scenery, and .intro (also z-index: 1, but earlier
   in paint order) is a tall column of text that reaches down over the sand
   on a narrow window — at 0 the sunglasses would end up behind the intro
   panel and unclickable. The frame itself stays transparent to the mouse,
   so only the sprite ever intercepts anything. */
.beach-items {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 1;
  width: 100%;
  aspect-ratio: var(--sky-w) / var(--sky-h);
  pointer-events: none;
}

/* Centred on the sand and sized off the same canvas: left: 50% plus a
   translateX(-50%) rather than a measured offset, so the middle of the
   beach is still the middle at any window width, and width/bottom as
   percentages of --sky-w/--sky-h (via the dials at the top of this file)
   so it rides the art rather than sticking to one screen's pixels.
   translateX nudged from -50% (centred) to 0% shifts it right by a
   quarter of its own width, still relative to its own size so it holds
   at any resolution.

   quest-hover (quest.css) bobs it up and down on the `translate` property
   rather than `transform`, so it stacks with the translateX positioning
   above instead of overriding it — same split used for held items in
   quest.css. Without it the turn on .beach-item__turn alone reads as a
   flat optical-illusion flicker rather than something floating.

   perspective here (rather than on .beach-item__turn itself) is what gives
   .beach-item__turn's rotateY below actual foreshortening instead of a
   flat mirror-flip — perspective only affects a 3D-transformed element's
   *children*, so it has to sit one level up, on the element that doesn't
   itself rotate. */
.beach-item {
  position: absolute;
  left: 50%;
  bottom: calc(100% * var(--beach-item-bottom) / var(--sky-h));
  width: calc(100% * var(--beach-item-w) / var(--sky-w));
  aspect-ratio: 1;
  padding: 0;
  background: none;
  border: none;
  perspective: 200px;
  transform: translateX(0%);
  animation: quest-hover 1.6s ease-in-out infinite;
  pointer-events: auto;
  cursor: var(--cursor-link);
}


/* Spelled out rather than left to the UA's own [hidden] rule, which any
   later `display` on this class would quietly outweigh — the same fight
   .icon--pickup already lost once in projects.css. syncBeachSunglasses()
   in main.js is what sets it, once they've been taken. */
.beach-item[hidden] {
  display: none;
}

/* A real 3D turn rather than a faked scaleX squash: .beach-item__turn is
   the thing that actually rotates (a full 360° around the vertical axis,
   matching quest-spin in quest.css in spirit — reads as something waiting
   to be picked up, not part of the painting), and preserve-3d keeps its
   two children (see .beach-item__sprite below) living in that same rotated
   space instead of being flattened back onto the page. .beach-item's
   perspective (set one level up, above) is what turns this rotation into
   actual foreshortening — without it rotateY alone is indistinguishable
   from a horizontal mirror-flip. */
.beach-item__turn {
  display: block;
  position: relative;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  animation: beach-item-spin 1.8s linear infinite;
}

@keyframes beach-item-spin {
  from { transform: rotateY(0deg); }
  to   { transform: rotateY(360deg); }
}

/* Front and back sprites stacked in the same spot inside .beach-item__turn,
   each pinned to one face of the rotation rather than swapped by hand:
   .beach-item__sprite--back is pre-rotated 180° so it lands exactly on the
   opposite face, and backface-visibility: hidden on both is what makes each
   one disappear the instant its own face turns away from the viewer — the
   browser's own rotation math decides the cut, not a hand-timed opacity
   keyframe, so there's no seam to get wrong. */
.beach-item__sprite {
  display: block;
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  backface-visibility: hidden;
  image-rendering: crisp-edges;
  image-rendering: pixelated;
  filter: drop-shadow(0 0 8px rgba(153, 229, 80, 0.65));
}

/* scaleX(-1) on top of the placement flip: cool-shades-back.png was drawn
   as a mirror of the front (hinge/arm swapped side, matching how a real
   pair of glasses reads once actually turned around), but rotateY(180deg)
   alone lands it back at zero net rotation when it's facing the viewer, so
   without this it re-mirrors the art a second time and cancels the artist's
   intent — the extra scaleX(-1) is what makes it read the way it was
   drawn. */
.beach-item__sprite--back {
  transform: rotateY(180deg) scaleX(-1);
}

.beach-item:hover .beach-item__sprite,
.beach-item:focus-visible .beach-item__sprite {
  filter: drop-shadow(0 0 12px rgba(153, 229, 80, 1));
}

/* --- the sea, line-scrolled --------------------------------------------
   .sea is just a full-canvas frame (top/left: 0, art's aspect ratio)
   holding its slot behind the clouds/stars; it paints nothing itself.
   .sea__band crops out the water rows (--sea-band-top/-h) as top/height
   percentages of that frame. Each .sea__row is one art-pixel-tall slice;
   slicing this fine is the point — every row sways a few pixels out of
   phase with its neighbours, and that scanline-to-scanline lag is what
   reads as rippling water instead of a sliding image. */
.sea {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 0;
  width: 100%;
  aspect-ratio: var(--sky-w) / var(--sky-h);
  pointer-events: none;
}

.sea__band {
  position: absolute;
  left: 0;
  width: 100%;
  top: calc(100% * var(--sea-band-top) / var(--sky-h));
  height: calc(100% * var(--sea-band-h) / var(--sky-h));
  display: flex;
  flex-direction: column;
}

/* Each row draws the *whole* sea export, scaled up (background-size) so
   every art-pixel-row is exactly one row-box tall — the standard CSS
   sprite-sheet trick for picking one frame out of a strip — then
   positioned (background-position, using --i + --sea-band-top over
   --sky-h - 1, the sprite sheet's usual off-by-one) to land on this row.
   background-size's width also overscans by --sea-amplitude on each side,
   giving background-position-x room to slide the row left/right below
   without ever uncovering the box's edges. */
.sea__row {
  flex: 0 0 calc(100% / var(--sea-rows));
  width: 100%;
  background-image: url("/assets/web/pages/home/beach-background-sea.png");
  background-repeat: no-repeat;
  background-size:
    calc(100% + (100% * var(--sea-amplitude) * 2 / var(--sky-w)))
    calc(var(--sky-h) * 100%);
  background-position:
    50%
    calc((var(--sea-band-top) + var(--i, 0)) / (var(--sky-h) - 1) * 100%);
  image-rendering: crisp-edges;
  image-rendering: pixelated;
  animation-name: sea-sway;
  animation-iteration-count: infinite;
  /* 4 legs (centre-left-centre-right-centre) of --sea-amplitude pixels
     each = step count; times --sea-speed = --sea-cycle. */
  animation-timing-function: steps(calc(var(--sea-amplitude) * 4));
  animation-duration: var(--sea-cycle);
  /* Half a step (avoids landing exactly on a step boundary, same as the
     clouds' delay) plus this row's share of a full stagger lap, spread
     over the band so rows sweep continuously instead of pulsing in sync. */
  animation-delay: calc(
    (var(--sea-speed) * -0.5) -
    (var(--i, 0) * var(--sea-cycle) / var(--sea-rows))
  );
}

@keyframes sea-sway {
  0%, 100% { background-position-x: 50%; }
  25%      { background-position-x: 0%; }
  75%      { background-position-x: 100%; }
}

/* --i can't be set inline (CSP has no unsafe-inline) — one line per row
   to hand each .sea__row its index. */
.sea__row:nth-child(1)  { --i: 0; }
.sea__row:nth-child(2)  { --i: 1; }
.sea__row:nth-child(3)  { --i: 2; }
.sea__row:nth-child(4)  { --i: 3; }
.sea__row:nth-child(5)  { --i: 4; }
.sea__row:nth-child(6)  { --i: 5; }
.sea__row:nth-child(7)  { --i: 6; }
.sea__row:nth-child(8)  { --i: 7; }
.sea__row:nth-child(9)  { --i: 8; }
.sea__row:nth-child(10) { --i: 9; }
.sea__row:nth-child(11) { --i: 10; }
.sea__row:nth-child(12) { --i: 11; }
.sea__row:nth-child(13) { --i: 12; }
.sea__row:nth-child(14) { --i: 13; }
.sea__row:nth-child(15) { --i: 14; }
.sea__row:nth-child(16) { --i: 15; }
.sea__row:nth-child(17) { --i: 16; }
.sea__row:nth-child(18) { --i: 17; }
.sea__row:nth-child(19) { --i: 18; }
.sea__row:nth-child(20) { --i: 19; }
.sea__row:nth-child(21) { --i: 20; }
.sea__row:nth-child(22) { --i: 21; }
.sea__row:nth-child(23) { --i: 22; }
.sea__row:nth-child(24) { --i: 23; }
.sea__row:nth-child(25) { --i: 24; }
.sea__row:nth-child(26) { --i: 25; }
.sea__row:nth-child(27) { --i: 26; }
.sea__row:nth-child(28) { --i: 27; }
.sea__row:nth-child(29) { --i: 28; }
.sea__row:nth-child(30) { --i: 29; }
.sea__row:nth-child(31) { --i: 30; }
.sea__row:nth-child(32) { --i: 31; }
.sea__row:nth-child(33) { --i: 32; }
.sea__row:nth-child(34) { --i: 33; }
.sea__row:nth-child(35) { --i: 34; }
.sea__row:nth-child(36) { --i: 35; }
.sea__row:nth-child(37) { --i: 36; }
.sea__row:nth-child(38) { --i: 37; }
.sea__row:nth-child(39) { --i: 38; }
.sea__row:nth-child(40) { --i: 39; }
.sea__row:nth-child(41) { --i: 40; }
.sea__row:nth-child(42) { --i: 41; }
.sea__row:nth-child(43) { --i: 42; }
.sea__row:nth-child(44) { --i: 43; }
.sea__row:nth-child(45) { --i: 44; }
.sea__row:nth-child(46) { --i: 45; }
.sea__row:nth-child(47) { --i: 46; }
.sea__row:nth-child(48) { --i: 47; }
.sea__row:nth-child(49) { --i: 48; }
.sea__row:nth-child(50) { --i: 49; }
.sea__row:nth-child(51) { --i: 50; }
.sea__row:nth-child(52) { --i: 51; }
.sea__row:nth-child(53) { --i: 52; }
.sea__row:nth-child(54) { --i: 53; }
.sea__row:nth-child(55) { --i: 54; }

/* --- the drifting clouds -----------------------------------------------
   Four clouds, one export each, all on the same 256x288 canvas as the
   beach so a layer at left: 0 reproduces the original painting. Offsets
   are % of --sky-w (not px) so they scale with window width, and steps()
   moves each cloud exactly one art pixel per tick — no subpixel creep.

   The wrap: a cloud drifting off the left edge travels --cloud-dead
   pixels further before reappearing at the right, so one lap is
   --cloud-loop (sky + dead zone) art pixels; every cloud shares that
   loop, only speed differs. Because the loop is barely wider than the
   sky, a cloud is often on screen twice at once — hence two spans per
   cloud in the markup, the second exactly one lap behind the first
   (--cloud-lap), so the pair never drifts apart. Two spans is enough
   unless a cloud approaches the sky's own width (widest here is 86px).

   Speed = depth: near/big clouds fast, far/small ones slow, via
   --cloud-N-speed at the top of the file.

   Animating `left` rather than transform is deliberate: a transform
   would rasterize once onto its own layer and slide it by fractional
   screen pixels (the blur pixelated rendering exists to avoid); `left`
   repaints hard-edged every step, and steps() means that's only ~once a
   second per cloud on out-of-flow boxes, so it's cheap here. */
.clouds {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 0;
  width: 100%;
  aspect-ratio: var(--sky-w) / var(--sky-h);
  /* Clouds park a whole screen-width off-edge mid-lap; without this,
     main's overflow-y: auto would grow a horizontal scrollbar for them. */
  overflow: hidden;
  pointer-events: none;
}

/* Drawn after .stars, sharing its z-index, so a cloud passing a star
   covers it (though the current art doesn't have them overlap). */
.cloud {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-repeat: no-repeat;
  background-size: 100% 100%;
  image-rendering: crisp-edges;
  image-rendering: pixelated;
  /* Which lap this copy rides: lap 0 sits where the art puts it, lap 1
     (the second span) sits one --cloud-loop to its right. */
  --cloud-lap: 0;
  animation-name: cloud-drift;
  animation-iteration-count: infinite;
  /* One step per art pixel of the lap, so the drift always lands on whole
     pixels; steps() (checked to resolve calc()ed integers in all engines)
     is the whole timing function, no easing. */
  animation-timing-function: steps(var(--cloud-loop));
  animation-duration: calc(var(--cloud-loop) * var(--cloud-speed));
  /* Half a step, negative, so the first frame isn't exactly on a step
     boundary (which floating point could round either way). */
  animation-delay: calc(var(--cloud-speed) * -0.5);
}

.cloud--lap {
  --cloud-lap: 1;
}

/* One lap, left to right. The wrap at the end is exactly one lap, so it's
   not a visible jump — the copy that just finished stands where its
   partner was. All in % of --sky-w so it scales with the window. */
@keyframes cloud-drift {
  from { left: calc(100% * var(--cloud-lap) * var(--cloud-loop) / var(--sky-w)); }
  to   { left: calc(100% * (var(--cloud-lap) - 1) * var(--cloud-loop) / var(--sky-w)); }
}

/* Per cloud: just the picture and speed dial (each export is already a
   full --sky-w x --sky-h canvas, registered). */

/* Big, low over the water — nearest, fastest, widest (most often doubled). */
.cloud--1 {
  background-image: url("/assets/web/pages/home/beach-background-cloud-1.png");
  --cloud-speed: var(--cloud-1-speed);
}

/* Small wisp beside it — far off, slow. */
.cloud--2 {
  background-image: url("/assets/web/pages/home/beach-background-cloud-2.png");
  --cloud-speed: var(--cloud-2-speed);
}

/* Second big cloud, lower — near, a little behind cloud 1. */
.cloud--3 {
  background-image: url("/assets/web/pages/home/beach-background-cloud-3.png");
  --cloud-speed: var(--cloud-3-speed);
}

/* Furthest wisp — slowest in the sky. */
.cloud--4 {
  background-image: url("/assets/web/pages/home/beach-background-cloud-4.png");
  --cloud-speed: var(--cloud-4-speed);
}

/* Page content sits over the sky. Needs both position and z-index — a
   positioned canvas paints after static content regardless of source
   order, so without this it'd flicker stars over the logo/text. */
.intro,
.projects {
  position: relative;
  z-index: 1;
}

/* --- intro -------------------------------------------------------------
   Scaled by --hero-px/--badge-px (dials at the top of this file). */

.intro {
  padding-block: 2.5rem 1.5rem;
  line-height: 1.6;
  text-align: center;
}

/* Text needs a solid room to stand in over the beach background — same
   raised-panel grammar as other windows on the site. Wraps the copy
   through the -JJ sign-off; the logo above stays out on the beach. */
.intro__window {
  display: inline-block;
  width: fit-content;
  max-width: 100%;
  /* Translucent, not the solid --face fill used elsewhere, so the beach
     stays visible behind the panel. */
  background: rgba(69, 55, 94, 0.55);
  backdrop-filter: blur(2px);
  box-shadow: var(--raised);
  border: var(--rule) solid var(--dim);
  padding: 0.75rem clamp(0.625rem, 2vw, 1.5rem) 0.875rem;
  margin: 0.5rem auto 0;
}

/* "jjscoolwebsite.com" is one unbroken word — break-word keeps it inside
   the viewport on a narrow phone. */
.intro__title {
  margin: 0;
  font-size: clamp(1.6rem, 6.5vw, 3.5rem);
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: -0.01em;
  overflow-wrap: break-word;
}

/* Hand-drawn logo (256x128), under the heading. min() caps it on narrow
   phones; sliced into 64 columns below (4 source px each, landing on
   pixel edges since --hero-px is a whole number) so the wave's per-slice
   translateY reads as a curve, not hard cuts. */

/* Gentle bob for the whole logo. position: relative anchors the sparkle
   sprites below to the art, not the viewport. */
.intro__logo-bob {
  position: relative;
  width: min(100%, calc(256px * var(--hero-px)));
  margin: 1.25rem auto 1.75rem;
  animation: logo-bob 5.4s ease-in-out infinite;
}

@keyframes logo-bob {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-4px); }
}

/* The logo itself: a flex row of slices, each showing one column of the
   same background image. */
.intro__logo {
  display: flex;
  width: 100%;
  height: calc(128px * var(--hero-px));
}

.intro__logo-slice {
  position: relative;
  flex: 0 0 calc(100% / 64);
  height: 100%;
  background-image: url("/assets/web/pages/home/jjscoolwebsite-cloth.png");
  background-repeat: no-repeat;
  background-size: calc(256px * var(--hero-px)) calc(128px * var(--hero-px));
  background-position-x: calc(var(--i) * -256px * var(--hero-px) / 64);
  image-rendering: crisp-edges;
  image-rendering: pixelated;
  /* Squash-and-stretch (scaleY only, so narrow columns don't gap), not a
     plain slide — reads as a jelly wobble. Bottom-anchored so it "lands". */
  transform-origin: 50% 100%;
  /* Traveling wave: same cycle per slice, staggered by a negative delay
     scaled by index (must be negative — a positive delay would leave each
     slice at its unanimated base until it elapsed, then jump). */
  animation: logo-ripple 6.2s ease-in-out infinite;
  animation-delay: calc(var(--i) * -6.2s / 64);
}

@keyframes logo-ripple {
  0%, 100% { transform: translateY(0) scaleY(0.96); }
  50%      { transform: translateY(-3.5px) scaleY(1.05); }
}

/* --i can't be set inline (CSP style-src 'self' has no unsafe-inline) —
   set per nth-child instead. */
.intro__logo-slice:nth-child(1)  { --i: 0; }
.intro__logo-slice:nth-child(2)  { --i: 1; }
.intro__logo-slice:nth-child(3)  { --i: 2; }
.intro__logo-slice:nth-child(4)  { --i: 3; }
.intro__logo-slice:nth-child(5)  { --i: 4; }
.intro__logo-slice:nth-child(6)  { --i: 5; }
.intro__logo-slice:nth-child(7)  { --i: 6; }
.intro__logo-slice:nth-child(8)  { --i: 7; }
.intro__logo-slice:nth-child(9)  { --i: 8; }
.intro__logo-slice:nth-child(10) { --i: 9; }
.intro__logo-slice:nth-child(11) { --i: 10; }
.intro__logo-slice:nth-child(12) { --i: 11; }
.intro__logo-slice:nth-child(13) { --i: 12; }
.intro__logo-slice:nth-child(14) { --i: 13; }
.intro__logo-slice:nth-child(15) { --i: 14; }
.intro__logo-slice:nth-child(16) { --i: 15; }
.intro__logo-slice:nth-child(17) { --i: 16; }
.intro__logo-slice:nth-child(18) { --i: 17; }
.intro__logo-slice:nth-child(19) { --i: 18; }
.intro__logo-slice:nth-child(20) { --i: 19; }
.intro__logo-slice:nth-child(21) { --i: 20; }
.intro__logo-slice:nth-child(22) { --i: 21; }
.intro__logo-slice:nth-child(23) { --i: 22; }
.intro__logo-slice:nth-child(24) { --i: 23; }
.intro__logo-slice:nth-child(25) { --i: 24; }
.intro__logo-slice:nth-child(26) { --i: 25; }
.intro__logo-slice:nth-child(27) { --i: 26; }
.intro__logo-slice:nth-child(28) { --i: 27; }
.intro__logo-slice:nth-child(29) { --i: 28; }
.intro__logo-slice:nth-child(30) { --i: 29; }
.intro__logo-slice:nth-child(31) { --i: 30; }
.intro__logo-slice:nth-child(32) { --i: 31; }
.intro__logo-slice:nth-child(33) { --i: 32; }
.intro__logo-slice:nth-child(34) { --i: 33; }
.intro__logo-slice:nth-child(35) { --i: 34; }
.intro__logo-slice:nth-child(36) { --i: 35; }
.intro__logo-slice:nth-child(37) { --i: 36; }
.intro__logo-slice:nth-child(38) { --i: 37; }
.intro__logo-slice:nth-child(39) { --i: 38; }
.intro__logo-slice:nth-child(40) { --i: 39; }
.intro__logo-slice:nth-child(41) { --i: 40; }
.intro__logo-slice:nth-child(42) { --i: 41; }
.intro__logo-slice:nth-child(43) { --i: 42; }
.intro__logo-slice:nth-child(44) { --i: 43; }
.intro__logo-slice:nth-child(45) { --i: 44; }
.intro__logo-slice:nth-child(46) { --i: 45; }
.intro__logo-slice:nth-child(47) { --i: 46; }
.intro__logo-slice:nth-child(48) { --i: 47; }
.intro__logo-slice:nth-child(49) { --i: 48; }
.intro__logo-slice:nth-child(50) { --i: 49; }
.intro__logo-slice:nth-child(51) { --i: 50; }
.intro__logo-slice:nth-child(52) { --i: 51; }
.intro__logo-slice:nth-child(53) { --i: 52; }
.intro__logo-slice:nth-child(54) { --i: 53; }
.intro__logo-slice:nth-child(55) { --i: 54; }
.intro__logo-slice:nth-child(56) { --i: 55; }
.intro__logo-slice:nth-child(57) { --i: 56; }
.intro__logo-slice:nth-child(58) { --i: 57; }
.intro__logo-slice:nth-child(59) { --i: 58; }
.intro__logo-slice:nth-child(60) { --i: 59; }
.intro__logo-slice:nth-child(61) { --i: 60; }
.intro__logo-slice:nth-child(62) { --i: 61; }
.intro__logo-slice:nth-child(63) { --i: 62; }
.intro__logo-slice:nth-child(64) { --i: 63; }

/* --- floating letter layers --------------------------------------------
   Each letter (+ the "website" subtext) is its own transparent cutout at
   the full 256x128 canvas size, so it drops in already registered.
   .intro__letters overlays the cloth wave; letters stack in the art's own
   back-to-front order: cloth, COOL letters, JJs, then website subtext. */
.intro__logo {
  position: relative;
}

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

.intro__letter {
  position: absolute;
  inset: 0;
  background-repeat: no-repeat;
  background-size: 100% 100%;
  image-rendering: crisp-edges;
  image-rendering: pixelated;
  /* Bottom-anchored like the cloth above, so a letter reads as hovering
     over it rather than detached. */
  transform-origin: 50% 100%;
  animation: letter-bob var(--letter-dur, 4s) ease-in-out infinite;
  animation-delay: var(--letter-delay, 0s);
}

/* One shared keyframe, vertical bob only; per-letter height/delay via
   custom properties keeps the seven letters out of lockstep. */
@keyframes letter-bob {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(var(--letter-lift, -4px)); }
}

/* COOL bobs on the cloth's own rhythm so the two read as one swell. */
.intro__letter--c {
  z-index: 1;
  background-image: url("/assets/web/pages/home/jjscoolwebsite-letter-c.png");
  --letter-dur: 6.2s;
  --letter-delay: -0.6s;
  --letter-lift: -4px;
}

.intro__letter--o1 {
  z-index: 1;
  background-image: url("/assets/web/pages/home/jjscoolwebsite-letter-o1.png");
  --letter-dur: 6.2s;
  --letter-delay: -2.1s;
  --letter-lift: -4px;
}

.intro__letter--o2 {
  z-index: 1;
  background-image: url("/assets/web/pages/home/jjscoolwebsite-letter-o2.png");
  --letter-dur: 6.2s;
  --letter-delay: -3.7s;
  --letter-lift: -4px;
}

.intro__letter--l {
  z-index: 1;
  background-image: url("/assets/web/pages/home/jjscoolwebsite-letter-l.png");
  --letter-dur: 6.2s;
  --letter-delay: -5.2s;
  --letter-lift: -4px;
}

/* JJs just bobs gently, on its own quicker, unsynced cycle. */
.intro__letter--j1 {
  z-index: 2;
  background-image: url("/assets/web/pages/home/jjscoolwebsite-letter-j1.png");
  --letter-dur: 4.4s;
  --letter-delay: -0.2s;
  --letter-lift: -3px;
}

.intro__letter--j2 {
  z-index: 2;
  background-image: url("/assets/web/pages/home/jjscoolwebsite-letter-j2.png");
  --letter-dur: 4.4s;
  --letter-delay: -1.8s;
  --letter-lift: -3px;
}

.intro__letter--s {
  z-index: 2;
  background-image: url("/assets/web/pages/home/jjscoolwebsite-letter-s.png");
  --letter-dur: 4.4s;
  --letter-delay: -3.1s;
  --letter-lift: -3px;
}

/* website bobs only — no deform — with a lift big enough to actually read. */
.intro__letter--website {
  z-index: 3;
  background-image: url("/assets/web/pages/home/jjscoolwebsite-website.png");
  --letter-dur: 3.1s;
  --letter-delay: -2.6s;
  --letter-lift: -7px;
}

/* Twinkle: a few pixel sparkles (4-point plus of single-pixel dots),
   each flashing on its own uneven loop rather than in sync. */
.intro__sparkle {
  position: absolute;
  width: calc(1px * var(--hero-px));
  height: calc(1px * var(--hero-px));
  background: #fff;
  opacity: 0;
  pointer-events: none;
  box-shadow:
    calc(-2px * var(--hero-px)) 0 #fff,
    calc(2px * var(--hero-px)) 0 #fff,
    0 calc(-2px * var(--hero-px)) #fff,
    0 calc(2px * var(--hero-px)) #fff;
  animation: logo-twinkle 6s ease-in-out infinite;
}

.intro__sparkle--1 { top: 8%;  left: 14%; animation-duration: 2.2s; animation-delay: 0.2s; }
.intro__sparkle--2 { top: 15%; left: 82%; animation-duration: 3.7s; animation-delay: 1.1s; }
.intro__sparkle--3 { top: 78%; left: 55%; animation-duration: 4.3s; animation-delay: 2.3s; }

@keyframes logo-twinkle {
  0%, 82%, 100% { opacity: 0;   transform: scale(0.4); }
  88%           { opacity: 1;   transform: scale(1); }
  94%           { opacity: 0.5; transform: scale(1.3); }
}

@media (prefers-reduced-motion: reduce) {
  .intro__logo-bob,
  .intro__logo-slice,
  .intro__letter,
  .intro__sparkle,
  /* Dropping the animation leaves clouds at left: 0 and sea rows at
     centred background-position-x: 50% — the original painting, not a
     frame caught mid-motion. */
  .cloud,
  .sea__row,
  /* Stops the turn and bob — with .beach-item__turn's rotateY dropped back
     to its unanimated 0deg, the front face is what naturally faces the
     viewer (the back is still there, just turned away and hidden by
     backface-visibility), no separate rule needed. Glow is what's left
     saying "take me". */
  .beach-item__turn,
  .beach-item {
    animation: none;
  }

  /* Birds settle on cel 1 — both cels are unanimated opacity: 1 once the
     animation is dropped, which would show the two sets of wings at
     once, so cel 2 is hidden outright. */
  .birds::before,
  .birds::after {
    animation: none;
  }

  .birds::after {
    opacity: 0;
  }

  .intro__sparkle {
    opacity: 0;
  }
}

/* Favicon sprite (64x64) as the page's sign-off. Decorative (alt="") —
   the signature text below carries the name. */
.intro__badge {
  display: block;
  width: calc(64px * var(--badge-px));
  height: calc(64px * var(--badge-px));
  margin: 1.5rem auto 0.75rem;
  image-rendering: crisp-edges;
  image-rendering: pixelated;
}

/* Held to a readable measure rather than a wide monitor's full width. */
.intro p {
  margin: 0 auto 1rem;
  max-width: 62ch;
  font-size: clamp(1rem, 2.2vw, 1.15rem);
  color: var(--yellow-dark);
}

/* Doubled class to out-specify `.intro p` above — a bare class here would
   silently lose the `color` fight. */

/* Tucked under the title so the two read as one unit. */
.intro .intro__tagline {
  margin: 0.4rem auto 0;
  font-size: clamp(0.9rem, 2vw, 1.05rem);
  color: var(--green);
}

.intro .intro__sign {
  color: var(--green);
}

/* Drops the shared bottom margin so it sits flush under the badge. */
.intro .intro__sign--name {
  margin-bottom: 0;
}

/* --- project cards -------------------------------------------------------
   auto-fit reflows the row to two cards, then one, with no media query. */

.projects {
  padding-block: 1rem 3rem;
}

.projects__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 2rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.card__title {
  margin: 0 0 0.6rem;
  font-size: clamp(1.3rem, 3vw, 1.75rem);
  font-weight: 600;
}

.card__title a {
  color: var(--yellow);
  text-decoration: none;
}

.card__title a:hover,
.card__title a:focus-visible {
  text-decoration: underline;
  text-decoration-thickness: 3px;
  text-underline-offset: 4px;
}

/* Placeholder until there's real work for the frame — empty, not a
   stand-in image, so nothing here pretends to be a real project. */
.card__frame {
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 4 / 3;
  margin-bottom: 0.9rem;
  border: var(--rule) solid var(--yellow);
  color: var(--muted);
  font-size: 0.9rem;
}

.card__blurb {
  margin: 0;
  color: var(--yellow-dark);
  font-size: clamp(0.9rem, 2vw, 1rem);
  line-height: 1.6;
}
