/* ==========================================================================
   INGRADE — this site's own code
   ==========================================================================
   The second half of a client package, and the half that does not travel.

     themes/ingrade-2026.css    values — tokens, @font-face, and the @keyframes
                           those tokens name. No selectors. It is the whole
                           of a re-skinned template and it is what the
                           design system's theme switcher loads.

     clients/ingrade-2026.css   this file — selectors. Rules written for this one
                           site, layered over the base, loaded last so they
                           can override without specificity games.

   **Only what goes beyond the design system belongs here.** That is the
   test, and it is a narrow one: if a component, a state or a variant
   appears on the design-system page, it must keep working from tokens
   alone, because that page is what a re-skinned template is built and
   checked against. This file is for the niche — the one-off gesture a
   particular client's brand asks for and no other site will ever load.

   The consequence, stated so nobody discovers it later: what is in here
   **cannot be checked by switching themes on the design system**, since
   that page loads one file out of `themes/`. Accepted deliberately — a
   re-skin pays nothing and stays fully checkable; a tailored template buys
   its own hero and gives up the comparison tool for whatever it puts here.
   That is also why the test above is narrow. Every rule that migrates into
   this file is a rule the theme-comparison tool goes blind to.

   The base still owns every DUTY. Nothing in here may quietly take over a
   contract the base is enforcing — the hero scrim finishing at full
   strength before any text is readable, the reduced-motion stop, the
   reveal cascade's single owner. Overriding presentation is what this file
   is for; overriding a guarantee is how a client silently stops meeting
   PRINCIPLES §11 and §12.
   ========================================================================== */

/* --------------------------------------------------------------------
   ARTICLE DETAIL — two columns, with the next article sticky beside it

   Drga's article is one centred column and reads like a printed page,
   which suits a joiner. This one reads like a publication: the piece on
   the left, and what to read next standing beside it the whole way down.
   It is the same content in the same order — a reader with no CSS, or a
   screen reader, still gets the article and then the next-article link.

   **No markup change, and that is what makes it legal here.** The page
   already puts exactly two children in `<main>`: the article and the
   next-article section. So this is a grid on their shared parent and
   nothing else — PRINCIPLES §1 intact, and the eight other Ingrade
   subpages, plus all nine of drga's, are untouched because the selector
   asks for the article block by name.

   `:has()` rather than a class on the page: a class would be a markup
   exception for one client, which is the thing §1 forbids. The parent
   asking what is inside it is the same information without the exception.
   -------------------------------------------------------------------- */
@media (min-width: 64rem) {
  main:has(> [data-block="article"]) {
    display: grid;
    /* **The grid IS the container, and that is the whole point of doing it
       here rather than on the sections.** The first cut left the grid at
       viewport width and let each section keep its own `.container`, so the
       left column was 1480px of screen and the article's `.is-full` media
       broke out to the width of that — a gallery wider than the column it
       belonged to, with the aside pushed off to one side of it.

       Taking the container's own measurements onto the grid, and then taking
       them *off* the two inner containers below, makes the left column the
       reading column: `.prose--flow`'s three tracks now measure that column,
       so `.is-full` means "the full width of my column" instead of "the full
       width of the page". Nothing in the article can overflow it, because the
       thing it breaks out to IS it. */
    max-width: var(--container-max);
    margin-inline: auto;
    padding-inline: var(--gutter);
    column-gap: var(--space-12);
    /* `minmax(0, 1fr)` and not `1fr`: a grid item's automatic minimum size
       is its content, and this column contains a table and `<pre>` blocks
       that are wider than the column wants to be. With plain `1fr` those
       push the column open and the aside gets squeezed to nothing — the
       classic grid blowout, and it only shows on the one article that has
       a wide table in it. */
    grid-template-columns: minmax(0, 1fr) 21rem;
    /* **No `align-items: start` here, and that was the first attempt.** It
       reads as the right thing — the aside is short, so why make it as tall
       as the article — and it is exactly what stops the sticky below from
       working. A sticky element travels inside its containing block; with
       `start` the grid item shrinks to the card's own height and there is
       nowhere to travel, so it scrolls away like anything else. The default
       `stretch` gives the item the row's full height, which is the article's,
       and that is the distance the card is meant to accompany. */
  }

  /* The inner containers stand down. They would otherwise centre each
     column's content inside a second max-width and add a second gutter, so
     the two columns would each be inset from a grid that is already inset —
     and the gap between them would be gutter + gap + gutter. */
  main:has(> [data-block="article"]) > * > .container {
    max-width: none;
    padding-inline: 0;
  }

  /* **The grid is placed explicitly now, and the breadcrumbs block is why.**
     It designed itself around exactly two children; a third arrived and
     auto-placement did the obvious thing — trail into column one, article
     into column two — which put a 1400-word piece in a 21rem sidebar.
     Naming all three positions costs three declarations and cannot be
     surprised by a fourth child the way flow placement can.

     The trail belongs in the ARTICLE's column rather than across both:
     the head it introduces reads in that column, so a strip spanning the
     grid would start 21px to the left of the heading it belongs to. Row
     two is then the article beside its aside, exactly as before. */
  main:has(> [data-block="article"]) > .block-crumbs {
    grid-column: 1;
    grid-row: 1;
  }

  main:has(> [data-block="article"]) > [data-block="article"] {
    grid-column: 1;
    grid-row: 2;
  }

  main:has(> [data-block="article"]) > [data-block="next"] {
    grid-column: 2;
    grid-row: 2;
  }

  /* The measure without the gutters, because the container stand-down
     above zeroed its padding: the base rule adds two gutters to the
     max-width on the assumption they are still there, and here they are
     not. Same measure, same centring inside the column, so the trail and
     the article head start on the same pixel. */
  main:has(> [data-block="article"]) > .block-crumbs > .container {
    max-width: var(--measure);
  }

  /* The aside keeps the article's top rhythm so the two columns start on
     the same line, then stops paying for a full section's foot — it is a
     sidebar now, not a band across the page. */
  main:has(> [data-block="article"]) > [data-block="next"] {
    padding-block-end: var(--space-8);
  }

  /* Sticky on the container, not on the section. The section is the grid
     item and stretches to the row's height, which is what gives the
     sticky element somewhere to travel; a sticky grid item with nothing
     above it in the row simply sits still. */
  main:has(> [data-block="article"]) > [data-block="next"] .container {
    position: sticky;
    /* Clear of the floating pill, plus a breath — and the first number was
       measured rather than eyeballed only after it looked wrong. The pill is
       not `--header-height` tall from the top of the page: it floats, so its
       own top offset puts its lower edge at 84px. `header-height + space-6`
       parked the card at 88, which is **four pixels** of daylight and reads
       as glued to the menu rather than as a column beginning under it.
       `space-12` puts it at 112 — 28px of gap, which is the same air the
       page keeps everywhere else. */
    inset-block-start: calc(var(--header-height) + var(--space-12));
  }

  /* In a 21rem column the horizontal card has to stack. It is a grid of
     picture-then-text in the base; here the two go one above the other and
     the picture takes the full width, which is the shape every other card
     on the site already has.

     **And stacked, it has to be a smaller card than the one it stacks
     from.** The base card is built to be a band across the page — a 4:3
     lead picture, a `--text-lg` title, a full-size excerpt — and every one
     of those measurements is right there and wrong in a column that follows
     the reader for the whole article. Measured on a 1366×668 laptop, which
     is the viewport that says it plainly: the sticky container stood 545px
     tall in a visible column of 556, so the offer of the next article
     occupied **98%** of the page beside the article being read. At 1440×900
     it was 63%. Nothing was broken; it was simply the loudest thing on a
     page whose job is the piece on the left.

     Everything goes down exactly one step, which is why this is four
     declarations and not a redesign — the card keeps its picture, its date,
     its tag, its title, its excerpt and its link. **No content is hidden or
     clamped**, deliberately: a sidebar is a reason to make an offer smaller,
     never a reason to make it say less. 545 → 437 (**79%** of that column,
     59% at 1440×900).

     The picture takes the strip ratio rather than the lead ratio, and that
     is the half that carries the reduction — 213px of photograph became
     169, and 4:3 was 43% of the card's whole height. In here it is a
     thumbnail identifying an article, not the article's own opening image;
     `object-fit: cover` was already doing the cropping, so the change costs
     nothing but the crop. */
  main:has(> [data-block="article"]) .next-item {
    grid-template-columns: 1fr;
    gap: var(--space-3);
    padding: var(--space-4);
  }

  main:has(> [data-block="article"]) .next-item .img-frame {
    aspect-ratio: 16 / 9;
  }

  /* `--text-lg` is a clamp against the *viewport*, so in a 21rem column it
     sits at its maximum precisely when the column is at its narrowest
     relative to the page: 20px of title over a 16px article. One step down
     puts the offer under the prose it interrupts instead of over it, and it
     costs no lines — a smaller face fits more per line, so the title came
     down 46 → 37 rather than wrapping further. */
  main:has(> [data-block="article"]) .next-item .card__title {
    font-size: var(--text-base);
  }

  main:has(> [data-block="article"]) .next-item .card__text {
    font-size: var(--text-sm);
  }
}

/* --------------------------------------------------------------------
   HERO — the art layer paints a spectrum, and the glow is its shape

   The instance supplies three background layers (see preview/ingrade.html)
   and they only mean anything together, which is why the rules that bind
   them live here rather than in the base:

     1  the travelling spectrum      MULTIPLY  — the colour
     2  the lamp, white to clear     normal    — the source
     3  a greyscale light study      normal    — the shape
     4  flat page black              normal    — the floor

   Layer 1 multiplies with everything below it, so the spectrum survives
   only where what is underneath is light and goes black where it is dark.
   That is a mask, done in the background stack.

   **Layer 3 is a photograph in every sense that matters.** Ribbons of light
   rising from the foot, drawn in greyscale — which is exactly the shape a
   multiply wants, so the image needs no colour of its own and gets all of
   it from the band above. It carries detail no stack of radial gradients
   was going to reach.

   **It does not replace the lamp, and the first attempt that let it try is
   why that is worth writing down.** Handing the image both jobs — source
   and shape — left the block looking like a photograph of a light rather
   than a lit block: the ribbons are *structure*, and structure without a
   source has nothing to be lit by. The lamp is back above it as its own
   layer, so the two do different work — the radial says where the light
   comes from and breathes, the picture says what the light is passing
   through — and the spectrum multiplies over both at once.

   It is greyscale on purpose rather than by accident of the source: a
   *coloured* version exists and cannot be used, because a picture that
   already has hues in it has nothing left for the spectrum to say. The
   black one is a luminance map, and a luminance map is what a mask is.

   `cover` and `center bottom`: the light belongs at the foot, so that is
   the edge that must never crop. The ultrawide cap the gradients needed
   comes free with `cover` — an image does not grow a percentage wider with
   the viewport, it fills it.

   **`mask-image` would have been the obvious way and it is the wrong
   one.** Masking happens after `filter` in the rendering order, so the
   entrance's white-hot beam — which exists precisely because
   `contrast(0) brightness(2.6)` flattens the layer — would be cut away by
   a mask anchored at the bottom edge while the beam is at the middle.
   Blending happens inside the background painting instead, before the
   filter ever sees it, so the switch-on keeps working on a composited
   image it does not need to know the shape of.

   The floor is what stops the block ending as pure black against a
   #121110 page: multiply drives the spectrum to 0 at the glow's edge, and
   without a layer under it the hero would be a subtly darker rectangle
   than everything around it.
   -------------------------------------------------------------------- */
.block-hero--photo::after {
  /* The base sets `cover` for one image; four layers each want their own.
     The spectrum repeats and the others must not, or the glow would
     reappear above itself.

     **The tile's width is what decides how far apart the hues sit.** Too
     wide and the lit area covers two stops, so the light is one colour
     slowly becoming another; too narrow and the whole band crowds into one
     screen with no room between hues. 320% was the first, 200% the second,
     and 300% is where they read as separate colours travelling rather than
     as a rainbow squeezed into a window.

     An image N times its container travels `N/(N−1)` container-widths per
     tile, so at 300% one tile is 150% of position and the keyframe's two
     tiles are 300% — see `hero-rainbow` in the theme for why an exact
     number of tiles is not optional.

     **The band must be horizontal, and a tilted one is what put a hard
     vertical edge through the glow.** It ran at 100deg, on the reasoning
     that a band tilted slightly off the horizontal never lines its colour
     boundaries up with the edge of anything. That is true of a band drawn
     once and false of one that tiles: a tilted gradient's phase depends on
     y, so the tile's right edge and its left edge hold different colours at
     the same height and every repeat shows a seam. At 90deg the two edges
     agree at every height, which is the only way `repeat` can be
     invisible. */
  background-size: 300% 100%, 100% 100%, cover, 100% 100%;
  background-position: var(--rainbow-x) center, center, center bottom, center;
  background-repeat: repeat, no-repeat, no-repeat, no-repeat;
  background-blend-mode: multiply, normal, normal, normal;
}

/* --------------------------------------------------------------------
   HERO — the content is drawn by the same tube as the frame

   The base reveal is a cascade of short upward travels: things settle
   into place, which is Drga's language and is right for a page made of
   panels. This hero is a screen switching on, and a screen does not
   slide its contents in from below — it *lights them up*, out of focus
   first, and pulls them sharp as the tube warms. Every subpage and every
   other block keeps the base reveal untouched; only this block speaks the
   entrance's language, because only this block has an entrance.

   The timing hangs off the base's own contract rather than repeating it:
   `--reveal-lead-in` is published on the hero by `blocks.css` from the
   theme's `--hero-lead-in`, so the first element lights up on the exact
   frame the raster lands and the scrim reaches full strength. Change the
   entrance in the theme and this follows it, with nothing to keep in sync
   by hand.
   -------------------------------------------------------------------- */

/* The line mask stands down.

   `.reveal-line` slides each line up from behind an `overflow: hidden`
   edge — travel the glow entrance has no use for, and it would run
   *underneath* the animation below, giving the headline two arrivals at
   once. `:not(.is-revealed)` rather than a blanket override so the
   revealed state stays the base's to define, and this rule cannot outlive
   the cascade it is silencing. */
.has-reveal .block-hero--photo .reveal-line:not(.is-revealed) > span {
  translate: none;
}

/* Each child of the hero lights up in turn.

   The animation wins over the base reveal wherever the two touch the same
   property — animations outrank normal declarations — so this replaces the
   cascade's opacity and travel rather than fighting it. It has to set
   `translate` for that reason: leave it out and the 24px of reveal travel
   still plays underneath.

   `both`, so the element sits at the 0% frame through its own delay
   instead of standing at full opacity waiting for its turn. That is the
   staggered-animation trap this project has already paid for once.

   And the fill is what makes it safe with no JS at all: the animation is
   CSS, so a page where reveal.js never boots still lights up and settles
   at 100%. Content is not hostage to a script (PRINCIPLES §12). */
.block-hero--photo .block-hero__inner > * {
  /* **Not the house expo-out, and that is the whole point of this line.**
     Expo-out is a curve for something travelling into place: it spends
     almost all of its movement immediately and then coasts, which is
     exactly right for a card settling 24px and exactly wrong for something
     materialising out of nothing. Measured on the first cut — 0.8s of
     expo-out put opacity at 0.75 after 160ms and 0.94 after 320ms, so each
     element was effectively fully present in a fifth of its own duration.
     Four of those, 130ms apart, and the cascade read as four clicks rather
     than as one continuous arrival.

     `cubic-bezier(0.4, 0, 0.2, 1)` instead: slow at both ends, most of the
     change in the middle. A fade wants a curve that is *lazy at the start*
     — that is what makes something appear rather than switch on. Longer
     too, and the stagger widened with it, because the fix for stepping is
     not only the curve: elements have to overlap enough that there is
     always something mid-flight. */
  animation: hero-text-glow 1.3s cubic-bezier(0.4, 0, 0.2, 1) both;
  /* After the shorthand, never inside it: `animation` resets the delay,
     so a delay written above this line would be thrown away. */
  animation-delay: calc(
    var(--reveal-lead-in, 0s) + var(--hero-text-index, 0) * 0.18s);
}

/* The stagger index, by position.

   `nth-child` is safe *inside* a block — the hero's children are fixed by
   the template and do not reorder. It is only across blocks that position
   is a lie, which is what PRINCIPLES §3 bans it for. Written out to six
   because the hero's shape is known: heading, lead, actions, guarantees,
   and room for the eyebrow and one more before anyone has to think again. */
.block-hero--photo .block-hero__inner > :nth-child(2) { --hero-text-index: 1; }
.block-hero--photo .block-hero__inner > :nth-child(3) { --hero-text-index: 2; }
.block-hero--photo .block-hero__inner > :nth-child(4) { --hero-text-index: 3; }
.block-hero--photo .block-hero__inner > :nth-child(5) { --hero-text-index: 4; }
.block-hero--photo .block-hero__inner > :nth-child(6) { --hero-text-index: 5; }

/* Coming forward out of nothing, out of focus, and resolving.

   **Two keyframes, not three.** The middle frame the first cut had was a
   second place for the curve to be interrupted — the easing restarts at
   every keyframe, so a mid-point is a small stop however carefully its
   values are chosen. With one segment there is nothing to step: a single
   continuous curve from invisible to settled, and everything below moves
   along it together.

   Three properties saying one thing. **Scale** brings it forward — 0.92,
   small enough that nobody sees a zoom and everybody feels the approach;
   past about 0.85 it stops being an arrival and becomes an effect.
   **Opacity** does the appearing. **Blur** does the focusing, and it is
   the half that ties this to the raster, since the entrance settles on the
   same filter functions.

   Brightness came down from 2.8 to 1.9: with the fade slowed, the
   overbright frame is on screen long enough to be looked at rather than
   felt, and at 2.8 the headline spent half a second as a white slab.

   The blur is in `em` and therefore proportional, which is deliberately
   not what a defocused tube does — a real one blurs everything by the same
   absolute radius. At 96px the headline arrives as a bloom of light and
   resolves into letters, which is the gesture; at 16px an absolute 14px
   would leave the guarantees a smear for most of their own animation. One
   value, both jobs, because it scales with the type (PRINCIPLES §6 allows
   `em` for exactly this).

   `translate: none` is not decoration here: without it the base reveal's
   24px of travel still plays underneath, and the point of this animation
   is that nothing slides. `blur(0px)` and not `blur(0)`: a bare zero is
   not a length. */
@keyframes hero-text-glow {
  0% {
    opacity: 0;
    scale: 0.92;
    translate: none;
    filter: blur(0.16em) brightness(1.9);
  }
  100% {
    opacity: 1;
    scale: 1;
    translate: none;
    filter: blur(0px) brightness(1);
  }
}

/* Belt and braces, the same as motion.css.

   The blanket reduced-motion rule cuts every animation to 0.01ms but says
   nothing about `animation-delay`, and this one carries up to 1.05s of it
   — the lead-in plus four stagger steps. Left alone, the reader who asked
   for less motion would get a blank hero for a second instead of no
   animation. The base already removes the lead-in when reveals are off;
   this removes the stagger with it. */
@media (prefers-reduced-motion: reduce) {
  .block-hero--photo .block-hero__inner > * {
    animation: none;
  }
}
