/* ============================================================
   MODELS — an isometric stage, and the camera over it.

   A dark floor with white-grey solids standing on it, lit by one sun.
   The blocks are placeholders for the real models; this file's job is
   the room they will stand in.

   THE GROUND IS NOT #000. It is a couple of steps up from it, because
   the entire read of a lit scene is the shadow under each solid, and
   there is no shadow available on pure black — the darkest mark you can
   make on the ground IS the ground. The site already has a word for this
   surface: --plate, "a dark panel lifted off the ground". The stage is
   that plate, full-bleed. Two levels of headroom is invisible as a
   colour and is the whole budget the shadows spend.

   THE SUN IS NOT IN THIS FILE. Its direction lives in furniture.js and
   the three face colours are computed from it by Lambert, because the
   alternative — three greys typed in here — is three greys that agree
   with the shadow's direction by luck, and stop agreeing the first time
   anybody moves the light. What is here is only what the sun does not
   decide: the shadow's density and the colour of the floor it lands on.

   The chrome needs no inversion: the stage is the site's own ground, so
   the site's own nav sits on it without a word. Nothing else floats
   over the canvas at all.
   ============================================================ */

:root[data-page="models"] {
  /* Warm charcoal, in the --plate family. See the note above on why it
     is not var(--ink): the shadows need somewhere to go. */
  --mo-ground: #1b1b19;
  /* Type on the canvas. Only the name tag uses these now that the page
     has no controls of its own — they stay named rather than inlined so
     the canvas keeps one place to say what it is, and they are aliases
     of the site's own scale rather than new values. */
  --mo-ink-2: var(--t-second);
  --mo-ink-3: var(--t-dim);

  /* What the sun does not decide.

     The shadow is nearly opaque black, which on a floor this dark is
     only a few levels of separation — but a cast shadow is contact, and
     contact is the difference between a solid standing on a floor and a
     solid hovering over one. It is the most important two per cent of
     this page.

     The floor grid is fainter than it looks like it should be. It is
     not information; it is the only thing in an empty stretch of stage
     that says the floor recedes rather than just being backdrop. Read
     it and it has already failed. */
  --bk-shadow: rgba(0, 0, 0, 0.62);
  --bk-contact: rgba(0, 0, 0, 0.8);
  --mo-grid: rgba(255, 255, 255, 0.045);
  --mo-grid-gap: 68px;
}

/* ---- the ground -------------------------------------------------
   The index is a fixed viewport, not a document: there is nothing to
   scroll to, and a page that scrolls a few pixels under a drag reads
   as broken before it reads as anything else.
   ----------------------------------------------------------------- */

:root[data-page="models"],
:root[data-page="models"] body {
  height: 100%;
  overflow: hidden;
  overscroll-behavior: none;
}

/* ---- the entrance ------------------------------------------------
   Two moves, one gesture. The camera dollies from one zoom step out to
   its resting distance — a straight line in, centre locked, nothing
   swinging — while the pieces settle in from the middle outwards, the
   stagger written per object as --d.
   ------------------------------------------------------------------ */

/* Scale, about the block's own ground point — so it grows out of the
   floor rather than fading in over it. Every keyframe has to restate
   the anchor translate, because a transform list replaces, it does not
   compose. */
@keyframes mo-settle {
  from {
    opacity: 0;
    transform: translate(calc(-1 * var(--ax)), calc(-1 * var(--ay))) scale(0.82);
  }
  to {
    opacity: 1;
    transform: translate(calc(-1 * var(--ax)), calc(-1 * var(--ay))) scale(1);
  }
}

/* ---- the canvas -------------------------------------------------- */

.mo {
  position: fixed; inset: 0; z-index: 1;
  background: var(--mo-ground);
  overflow: hidden;
  touch-action: none;              /* the gesture is ours, not the browser's */
  outline: none;                   /* it takes focus for the arrow keys only */
  /* The grab cursors are for the case where cursor.js did not run — on a
     fine pointer the reticle replaces the system cursor entirely, and it
     already contracts on mousedown, which is this site's word for a
     surface answering a press. */
  cursor: grab;
}
.mo[data-grab="true"] { cursor: grabbing; }

/* A whisper of vignette. Not for atmosphere — it is what stops the
   pieces at the edge of the window from looking cut off rather than
   continued, which is the difference between a canvas and a crop. It
   also puts the overhead light somewhere: the floor is fractionally
   brighter under the middle of the window than at its corners, which
   is the only cue on this page for where the lamp is. */
.mo::after {
  content: "";
  position: absolute; inset: 0; z-index: 2;
  pointer-events: none;
  background: radial-gradient(125% 100% at 50% 45%,
    rgba(255, 255, 255, 0.02) 0%, transparent 52%, rgba(0, 0, 0, 0.30) 100%);
}

.mo-plane {
  position: absolute; top: 0; left: 0;
  transform-origin: 0 0;
  will-change: transform;          /* the only moving layer on the page */
}

/* ---- the floor ---------------------------------------------------
   Two families of lines at ±30°, which is the ground grid of an
   isometric world seen from its own camera. It is the cheapest possible
   statement that the dark area between the blocks is a surface going
   away from you rather than a backdrop behind them.

   It fades in with the camera. At the widest step the lines are a third
   of a pixel apart on screen, which is not a grid, it is a shimmer that
   crawls whenever you pan — so it resolves as you come in, which is
   also what a floor does.
   ------------------------------------------------------------------ */

.mo-plane::before {
  content: "";
  position: absolute; inset: 0;
  pointer-events: none;
  background-image:
    repeating-linear-gradient(30deg, transparent 0 calc(var(--mo-grid-gap) - 1px),
      var(--mo-grid) calc(var(--mo-grid-gap) - 1px) var(--mo-grid-gap)),
    repeating-linear-gradient(150deg, transparent 0 calc(var(--mo-grid-gap) - 1px),
      var(--mo-grid) calc(var(--mo-grid-gap) - 1px) var(--mo-grid-gap));
  opacity: clamp(0, calc((var(--z, 1) - 0.55) * 1.6), 1);
}

/* ---- a block on the floor ----------------------------------------
   left/top carry the block's GROUND point — where it touches the floor
   — and --ax/--ay pull its drawing back onto it. Not translate(-50%,
   -50%): a block is not centred in its own drawing, it stands at the
   bottom of it, off to one side of its own shadow. The ground point is
   the only thing about it that is genuinely a position.

   z-index is set per block from that same ground point, so a block
   further down the screen — nearer the camera — paints over one behind
   it. Isometric has no depth buffer; paint order is the depth buffer.
   ------------------------------------------------------------------ */

.mo-obj {
  position: absolute;
  transform-origin: var(--ax) var(--ay);
  transform: translate(calc(-1 * var(--ax)), calc(-1 * var(--ay)));
  /* `backwards`, emphatically not `both`. A filling animation keeps
     writing its last keyframe forever, and it wins over ordinary
     declarations — so `both` would pin every block at the transform this
     ends on and nothing here could move it again. `backwards` holds the
     FROM value through the stagger delay, which is the only part that
     was ever needed, and then hands the element back to its own styles. */
  animation: mo-settle 620ms var(--ease-soft) backwards;
  animation-delay: var(--d, 0ms);
}
@media (prefers-reduced-motion: reduce) { .mo-obj { animation: none; } }

/* The blocks do not move on hover. They are objects standing on a
   floor, and an object that hops when you look at it is a button
   wearing an object's clothes — the illusion this whole stage is built
   to hold costs more than the feedback was worth.

   There is no feedback missing. The reticle opens out into crop marks
   around the block and the name row comes up over it: the response
   happens in the chrome, where a response belongs, and the scene stays
   a scene. It also means the hit area never moves, which a lift does —
   raising the solid slides its lower edge out from under a pointer
   resting there, which un-hovers it, which drops it back. */

/* The block is the target, not the rectangle it is drawn in. That box
   has to be big enough to hold the bounce pool and a shadow thrown
   sideways, which makes it several times the area of the solid and puts
   it over its neighbours — so on a crowded stretch of floor you would
   be hovering, cropping and clicking whichever invisible rectangle
   happened to be on top. Handing pointer-events to the three faces
   alone makes the hit area the silhouette, which is the only honest
   answer to "what am I about to click".

   The `none` goes on the whole .mo-obj and not just the svg inside it.
   The anchor is an element in its own right and keeps its own box: with
   only the svg opted out, the drawing stops answering and the invisible
   rectangle around it carries on doing exactly what it was doing.
   Bubbling is untouched — the click lands on a polygon and rises to the
   anchor, which is all the anchor ever needed. */
.mo-obj { pointer-events: none; }
.mo-solid { pointer-events: auto; }

.mo-obj--live .mo-solid { cursor: pointer; }
.mo-obj--live:focus-visible { outline: none; }

/* ---- the name ---------------------------------------------------
   A row above the block: what it is, what kind, and whether there is
   anywhere to go. Above rather than at its feet, because the feet are
   the busiest part of the drawing — the contact shadow, the cast, and
   the bounce pool all live down there, and mono at 10px laid over that
   is reading type off a texture. Over the block there is nothing but
   floor.

   Anchored off --ax/--ay/--ah — the ground point, and how far the solid
   reaches above it — so the row clears the top corner of a tall block
   and a flat one alike, rather than clearing the drawing box, which is
   lopsided by however far the shadow reaches.

   The row sits on the plane, so it inherits the plane's scale, and at
   the widest zoom step that is 10px of mono rendered at four — a label
   you cannot read is worse than no label, because it still costs the
   attention of noticing it. So it undoes the camera: the plane
   publishes its zoom as --z and every row scales by 1/z about its own
   bottom edge, which is the edge that has to stay put. Its padding
   rides the same correction, so the gap over the block is the same
   number of screen pixels at every distance.

   That gap has to clear the reticle's crop, which opens out around the
   same block on the same hover and reaches 22px past it — the two are
   never on screen separately, so a gap that only clears the block puts
   the name on top of the top-left corner mark every time.
   ----------------------------------------------------------------- */

.mo-tag {
  position: absolute;
  left: var(--ax);
  top: calc(var(--ay) - var(--ah));
  padding-bottom: 36px;
  display: flex; align-items: baseline; gap: 10px;
  white-space: nowrap;
  transform-origin: 50% 100%;
  transform: translate(-50%, calc(-100% + 4px)) scale(calc(1 / var(--z, 1)));
  color: var(--mo-ink-2);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--fast) var(--ease), transform var(--fast) var(--ease);
}
.mo-obj:hover .mo-tag,
.mo-obj:focus-visible .mo-tag {
  opacity: 1;
  transform: translate(-50%, -100%) scale(calc(1 / var(--z, 1)));
}

/* three cells, three weights of attention: the name, what kind of thing
   it is, and — only on the catalogued pieces — that this one goes
   somewhere. <b>/<i>/<u> for their brevity in the generated markup, not
   their default styling, which is overridden here. */
.mo-tag b { font-weight: 400; color: var(--t-primary); transition: font-size var(--mid) var(--ease-soft); }
.mo-tag i { font-style: normal; color: var(--mo-ink-3); }
/* the blog's outbound arrow, at the tag's own scale. align-self, because
   the row is baseline-aligned and an SVG has no baseline to align to —
   it would hang off the bottom of the line instead. */
.mo-tag .arrow {
  width: 11px; height: 11px;
  align-self: center;
  color: var(--mo-ink-3);
}
.mo-obj--live:hover .mo-tag .arrow { transform: translate(2px, -2px); }

/* ====================================================================
   THE PIECE — a state of the canvas, not a page.

   Everything below is what changes when one model is the subject. The
   camera flies in on it (models.js), the rest of the collection settles
   back into the floor, the model's own name grows into a title, and the
   writing arrives beside it. Nothing is replaced and nothing unmounts:
   the model you clicked is the same object in the same place, seen
   closer. That is the entire argument for doing it this way — a detail
   page would have had to redraw the thing you were already looking at,
   and no redraw is ever quite the same object.
   ==================================================================== */

/* The rest of the collection recedes but does not leave. Still lit
   enough to read as a floor with things on it, still clickable — moving
   between pieces is a matter of reaching for another one, which is the
   payoff for keeping them all on screen. */
:root[data-mo="focus"] .mo-obj:not([data-focus="true"]) { opacity: 0.2; }

/* And the floor under the writing dims further, because dimming alone
   is not enough. A block at a fifth of its brightness is still a white
   shape with hard edges, and mono at 12px laid over one is unreadable
   in a way that no amount of opacity fixes — the problem is not that
   the block is bright, it is that it has a border running through the
   sentence. The gradient is the writing's ground, and it only exists
   while there is writing. */
.mo::before {
  content: "";
  position: absolute; inset: 0; z-index: 3;
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(90deg,
    rgba(21, 21, 19, 0) 40%, var(--mo-ground) 76%);
  transition: opacity var(--mid) var(--ease-soft);
}
:root[data-mo="focus"] .mo::before { opacity: 1; }

/* ---- the title ---------------------------------------------------
   The hover row, grown up. Same element, same anchor, same counter-
   scale — it stacks and the name takes a display size while the
   category holds at 10px on the second line. Two rows of one thing
   rather than a title somewhere else about a thing over here.
   ------------------------------------------------------------------ */

.mo-obj[data-focus="true"] .mo-tag {
  opacity: 1;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding-bottom: 44px;
  transform: translate(-50%, -100%) scale(calc(1 / var(--z, 1)));
}
.mo-obj[data-focus="true"] .mo-tag b {
  font-size: 22px;
  letter-spacing: 0.08em;
  color: var(--t-primary);
}
.mo-obj[data-focus="true"] .mo-tag i { color: var(--mo-ink-2); }
/* the arrow said "this goes somewhere". You are there. */
.mo-obj[data-focus="true"] .mo-tag .arrow { display: none; }

/* ---- the marks ---------------------------------------------------
   Crop corners drawn into the model's own SVG, around its solid. The
   reticle does this on hover and cannot do it here — the reticle is at
   the pointer, and the pointer is about to be over on the right reading
   the specification. So the focused model keeps its own set, and
   models.js takes data-crop off it while it is focused so the two never
   double up on the same object.

   non-scaling-stroke in the markup: these sit on the plane and would
   otherwise thicken with the camera. The arms scale and the line does
   not, which is how a real crop mark behaves.
   ------------------------------------------------------------------ */

.mo-marks {
  opacity: 0;
  color: var(--t-primary);
  transition: opacity var(--mid) var(--ease-soft);
}
.mo-obj[data-focus="true"] .mo-marks { opacity: 0.9; }

/* ---- the writing --------------------------------------------------
   Fixed to the right half of the window, not to the plane. It is not in
   the scene — it is about the scene, and a caption that panned with the
   floor would be neither.
   ------------------------------------------------------------------- */

.mo-panel {
  position: fixed; z-index: 20;
  top: 50%; right: var(--pad-x);
  transform: translateY(-50%);
  width: min(46vw, 540px);
  max-height: calc(100dvh - var(--nav-h) - var(--s6));
  overflow-y: auto;
  scrollbar-width: none;
  pointer-events: auto;
}
.mo-panel::-webkit-scrollbar { display: none; }
.mo-panel[hidden] { display: none; }

/* Three parts, arriving in the order you would read them and a beat
   apart, under a camera that is still moving. The stagger is short —
   this is a caption catching up with a camera move, not a reveal. */
@keyframes mo-rise {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: none; }
}
.mo-panel .back, .mo-say, .mo-notes, .mo-spec {
  animation: mo-rise 520ms var(--ease-soft) backwards;
}
.mo-panel .back { animation-delay: 80ms; margin-bottom: var(--s4); }
.mo-say   { animation-delay: 140ms; }
.mo-notes { animation-delay: 220ms; }
.mo-spec  { animation-delay: 300ms; }
@media (prefers-reduced-motion: reduce) {
  .mo-panel .back, .mo-say, .mo-notes, .mo-spec { animation: none; }
}

.mo-say {
  font-family: var(--sans);
  font-size: clamp(22px, 2.5vw, 36px);
  line-height: 1.08;
  letter-spacing: -0.028em;
  color: var(--t-primary);
}

.mo-notes {
  margin-top: var(--s5);
  display: grid; gap: var(--s4);
  color: var(--t-second);
  max-width: 46ch;
}

.mo-spec { margin: var(--s6) 0 0; }
.mo-spec-row {
  display: flex; gap: var(--s4);
  padding: var(--s3) 0;
  border-bottom: 1px solid var(--hair-soft);
  color: var(--t-second);
}
.mo-spec-row:first-child { border-top: 1px solid var(--hair); }
.mo-spec-row dt { flex: 0 0 34%; color: var(--t-dim); }
.mo-spec-row dd { margin: 0; color: var(--t-primary); }

/* ---- narrow --------------------------------------------------------
   The canvas itself still needs no breakpoint — it is a camera over a
   plane, and the zoom ladder is already fitted to the window in
   models.js. Only the piece does: side by side stops being possible
   somewhere around a tablet, so the writing takes the bottom of the
   window and models.js frames the model into the top of it instead.
   -------------------------------------------------------------------- */

@media (max-width: 900px) {
  .mo-panel {
    top: auto; bottom: 0; right: 0; left: 0;
    transform: none;
    width: auto;
    max-height: 52dvh;
    padding: var(--s4) var(--pad-x) var(--s5);
    background: linear-gradient(rgba(21, 21, 19, 0), var(--mo-ground) 14%);
  }
  .mo-notes { max-width: none; }
  .mo-spec { margin-top: var(--s5); }
  /* the writing is along the bottom here, so its ground turns with it */
  .mo::before {
    background: linear-gradient(180deg,
      rgba(21, 21, 19, 0) 38%, var(--mo-ground) 74%);
  }
}

/* ---- the chrome ----------------------------------------------------
   One rule, where a paper canvas would have needed a dozen and an
   earlier draft of this one had a dock and a row of filter pills. The
   nav — its measure, its colours, its button, and the selection, focus
   ring and reticle under it — is already correct here without a word,
   because the canvas is the site's own ground and the nav is the site's
   own nav.

   What is left is the header band, and here it stops being a scrim.

   Everywhere else on the site the nav dissolves into a page scrolling
   under it, and a gradient is the right way to say that — there is no
   edge, because the content genuinely continues. This page has no scroll
   and no continuation: the stage is a fixed plane that models drift
   across, and a model sliding up into a soft gradient reads as the
   canvas fading out rather than as the header being in front of it.

   So it is a solid band with an edge on it. The band is exactly the
   nav's own height rather than the scrim's overhang — an overhang only
   made sense as the tail of a fade — and the hairline is the site's own
   --hair, the same rule the nav separator and the spec rows are drawn
   with.
   -------------------------------------------------------------------- */

:root[data-page="models"] .nav::before {
  height: var(--nav-h);
  background: var(--mo-ground);
  border-bottom: 1px solid var(--hair);
}
