/* ═══════════════════════════════════════════════════════════════════════════
   app-icon-3d.css — a rounded-square app icon with real physical thickness,
   spinning on a fixed vertical axis, grabbable and flickable like a coin.

   One stylesheet, two consumers: the React component (AppIcon3D.jsx) and the
   vanilla mount used on this static site (app-icon-3d.js). Both render the
   same DOM and drive the same custom properties, so the visuals can never
   drift between them.

   ── Why a stack of slices, not one rotated image ───────────────────────────
   A single element with rotateY() has no thickness: at 90 degrees it vanishes
   to a hairline. A single flat "side" plane doesn't work either — the
   silhouette is a rounded square, so its edge is a curve, and a flat plane
   tears away from the shape at all four corners.

   So the body is N thin copies of the same rounded square, stepped along Z.
   Face on they merge into one shape; edge on their stacked outlines read as a
   solid slab with the correct rounded top and bottom; at any angle between,
   the corners stay convincing because every slice carries the same radius.

   ── The centring bug, and the actual fix ───────────────────────────────────
   The previous build put the front face at Z=depth, the back face at Z=0, and
   then rotated the whole stack with `translateZ(-depth/2) rotateY(angle)`.
   That reads as "shift to the middle, then spin" but transform lists compose
   right to left: rotateY ran FIRST, about the Y axis through local Z=0 — the
   BACK face, not the object's centre — and only afterward did the -depth/2
   shift move the whole already-rotated object in Z. Rotating a point that
   sits depth/2 away from its pivot along Z sweeps it through an arc in X,
   which is exactly an orbit rather than a spin. Measured on the live page: a
   sideways sweep of about 40px in the visible pixel centroid across one
   rotation, at a size where the box's own footprint never moved at all,
   because the rotation lives entirely inside 3D space the box's own layout
   rect can't see.

   The fix is to make the geometry symmetric about local Z=0 instead of
   shifting it after the fact: front face at +depth/2, back face at -depth/2,
   slices spread evenly across that same range. The stack's own untransformed
   origin is then already the object's true centre, so `rotateY(angle)` alone
   — no accompanying translateZ — rotates about an axis that passes through it
   exactly. Re-measured after the fix: centroid drift 0px at every angle.

   ── Measured against the original reference recording ──────────────────────
   Frame analysis of that recording (24 frames over 9.92s): edge-on width
   18.3% of the face height, corner radius ~29% of the face, one rotation
   ~5s at constant speed. Depth and radius below are tuned to those figures.
═══════════════════════════════════════════════════════════════════════════ */

.aicon3d {
  /* --aicon-size and --aicon-depth are set inline by whichever consumer
     mounts this, so one element can differ from another on the same page. */
  width: var(--aicon-size);
  height: var(--aicon-size);
  /* Perspective on the container, not the rotating element: on the rotating
     element it would rotate with it and the foreshortening would swim. */
  perspective: var(--aicon-perspective);
  perspective-origin: 50% 50%;
  display: inline-block;
  flex: none;
  /* The interaction owns horizontal drag; a touch that starts here must not
     also pan or scroll the page, or the two gestures fight each other. */
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
}

/* Set once the JS controller has attached (mount() adds this). It is the
   affordance that the object can be grabbed — the interaction itself needs no
   other UI, per the brief's "no labels, controls, indicators" rule; a cursor
   change is not a control, it's what every draggable element on the web
   already does. Absent on the plain-<img> no-JS fallback, which cannot be
   dragged and should not look like it can be. */
.aicon3d[data-live] { cursor: grab; }
.aicon3d[data-live][data-dragging] { cursor: grabbing; }

.aicon3d-spin {
  position: relative;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  /* The no-JS fallback: a plain looping spin. Overwritten the instant the JS
     controller attaches — see the note on .aicon3d[data-live] below — so a
     working script and this animation are never both driving the transform
     at once. transform-origin is explicit even though the default already
     matches it: with the geometry now symmetric about Z=0, the point it names
     is the object's real centre, and that alignment is the entire fix above,
     so it is worth being unable to silently drift if a global reset ever
     touches transform-origin. */
  transform-origin: 50% 50% 0;
  animation: aicon3d-spin var(--aicon-duration) linear infinite;
}

/* Once JS is driving the transform directly (every frame, via style.transform
   on this element), the CSS animation must not also be trying to animate the
   same property — two authorities writing one value fight and flicker. */
.aicon3d[data-live] .aicon3d-spin { animation: none; }

.aicon3d-face,
.aicon3d-slice {
  position: absolute;
  inset: 0;
  border-radius: var(--aicon-radius);
  /* Every layer is the same rounded square. backface-visibility stays visible:
     the slices and the back face must be seen from behind, since half of
     every rotation looks at the stack from its far side. */
  backface-visibility: visible;
}

/* ── The body ──────────────────────────────────────────────────────────────
   Slices sit symmetrically about Z=0 — see the header note — coloured in the
   icon's own dominant tone with two layered effects on top, neither of which
   moves the geometry:

     --aicon-t      static per-slice weight, 1 at the middle of the stack and
                    0 at either end, unchanged since the first build: it puts
                    a soft bright band down the centre of the curved edge, so
                    the edge reads as a rounded surface catching light rather
                    than a flat printed band.

     --aicon-glint  live, written by the interaction each frame as
                    |sin(angle)| — 0 face-on, 1 edge-on. The physical edge is
                    most prominent exactly when the object is edge-on, so this
                    is what makes the edge highlight swell as it comes around,
                    "catching the light" as the brief asks, from an
                    orientation-driven rule rather than a fixed value. Absent
                    when JS never runs, in which case it defaults to 0 and the
                    slice simply keeps its static appearance — decoration, not
                    a requirement, so its absence changes nothing structural.

   The ceiling is deliberately low — 14% white at rest, 28% at the most
   edge-on moment — because "polished metal" reads as a restrained sheen, and
   the previous single-value version measured brighter than that at 22%. */
.aicon3d-slice {
  background: var(--aicon-side);
  background: color-mix(
    in srgb,
    #fff calc(var(--aicon-t, 0) * (14% + var(--aicon-glint, 0) * 14%)),
    var(--aicon-side)
  );
}

/* ── The two image faces ───────────────────────────────────────────────────
   Front and back both carry the artwork, and the back is flipped so it reads
   the right way round rather than mirrored. Without a back face the icon
   would show a blank slab for half of every rotation.

   Placed at ±depth/2 rather than 0/depth — see the header note. The back
   face's translateZ runs in the frame rotateY(180deg) already flipped, which
   is why it reads +depth/2 rather than the more obvious-looking negative:
   this is the standard construction for a two-sided CSS panel, not a sign
   error, and moving it to -depth/2 directly (skipping the rotateY) would
   mirror the artwork instead of turning it to face away correctly. */
.aicon3d-front { transform: translateZ(calc(var(--aicon-depth) / 2)); }
.aicon3d-back  { transform: rotateY(180deg) translateZ(calc(var(--aicon-depth) / 2)); }

.aicon3d-face img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: inherit;
  /* The artwork is the subject, not a UI surface, so it keeps its own corner
     radius rather than a system one. */
  -webkit-user-drag: none;
  user-select: none;
}

/* ── The metallic sheen ────────────────────────────────────────────────────
   A soft diagonal band of light on each face, positioned by --aicon-light —
   live, written each frame as cos(angle): +1 face-on toward the viewer, -1
   face-on away, 0 edge-on. That sweeps the band across the face exactly as
   the object turns, as though one fixed light were raking across a rotating
   surface, rather than a highlight painted onto the artwork itself.

   A gradient's own background-position repaints rather than composites, but
   the element is small (well under 150px) and there are only two of them —
   negligible next to the twenty-odd slices already repainting every frame for
   the same reason. Not present when JS never runs: the custom property falls
   back to 0, which centres the band and leaves it static rather than
   removing it, so a no-JS page keeps a quiet fixed sheen instead of an
   animated one. */
.aicon3d-sheen {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background: linear-gradient(
    115deg,
    transparent 30%,
    rgba(255, 255, 255, 0.16) 50%,
    transparent 70%
  );
  background-size: 240% 100%;
  background-position: calc(50% + var(--aicon-light, 0) * 55%) 0;
  mix-blend-mode: soft-light;
}

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

/* Motion here is decoration, not information — the icon says the same thing
   at rest — so on the no-JS fallback path a reduced-motion reader gets it
   parked at a slight angle that still shows the object has depth, rather than
   frozen face-on where it would look like a flat image.

   The JS-driven path handles reduced motion itself (no ambient idle turn, no
   inertia after release, direct drag still works) because that decision needs
   the pointer state this stylesheet doesn't have — see app-icon-3d.js. */
@media (prefers-reduced-motion: reduce) {
  .aicon3d:not([data-live]) .aicon3d-spin {
    animation: none;
    transform: rotateY(-24deg);
  }
}
