/* ==========================================================================
   exp33 . HERO . the living intent graph
   Markup: partials/herograph.html      Driver: assets/herograph.js
   Supersedes assets/herofilm.css (still in the tree, not loaded on index).

   HOW THE CONTRAST IS BUILT
   --hg-op is an ENVELOPE, not a flat wash: it is the opacity of the whole svg,
   and every stroke and fill inside expresses its own share of it. One lever per
   theme therefore controls the whole graph, and inside the graph there is a
   real hierarchy:

     the graph asleep        ~6-8%   against the ground
     structure, lit          ~15-19%
     a state mark            ~26-32%
     verified, at its peak   ~29%

   THE WHOLE RAMP MOVED UP, AND ITS TOP MOVED FURTHER THAN ITS BOTTOM.
   Measured on the default terminal theme at 1440x900, the graph used to reach a
   p99 luminance delta of 24/255 against the ground with 5% of the band covered.
   Downscaled to a contact-sheet thumbnail that averages away to nothing, which
   is the founder's "almost invisible" failure mode: a still at full size read
   fine and the animation did not read at all. The envelope is up ~1.5x per
   theme, the STORY's own alphas are up a further ~1.4x, and the resting lattice
   is DOWN ~20% -- so the story is roughly twice as present, the sleeping
   structure is where it was, and the internal hierarchy is steeper than before
   rather than flatter.

   WHAT PAYS FOR IT IS BANDS, NOT AN L-SHAPED KEEP-OUT.
   The story used to be authored strictly outside the union of the hero copy's
   measured glyph boxes. That union wraps the copy in an L, so obeying it pushed
   the entire graph into the lower right and the hero read as copy beside a
   picture. The rule now: the story lives in the horizontal and vertical bands
   that no viewport in the matrix puts a glyph in (y 758..833, y 866..900,
   x 0..99, x 943..1440), and everything that genuinely crosses the copy column
   goes in #hg-under at the behind-glass tier -- dimmed, not excluded. So the
   full-alpha marks are still alpha the copy never sees, and the graph still
   spans the whole band.

   Nothing else comes close, which is why the eye finds the verified marks
   without being asked to, and why the composition still reads as black.

   COLOUR (exp33 token contract)
     verified            var(--pass)
     changed / stale     var(--stale, <per-theme amber>, var(--fail))
     invalidated         var(--fail)
     inactive structure  var(--unknown, <per-theme grey>, var(--muted))
   var(--signal) is an affordance and never appears here. Nothing is green
   unless it is verified.

   LAYERING
     .hg-veil     ground wash, over the Game-of-Life field, under the graph
     .hg-svg      the graph
     .hg-ed       machine-evidence texture at the frame edges
     .hero-film::after  (exp33.css) the copy guard, painted over all of it
   The whole stack is clipped to .hero-film, so the only thing that crosses the
   section boundary is the #hgx overlay.
   ========================================================================== */

.hg{
  position:absolute; inset:0;
  overflow:hidden; pointer-events:none;
  contain:paint;

  /* --- the per-theme levers ------------------------------------------- */
  --hg-op:.51;            /* envelope: opacity of the whole graph          */
  --hg-bgk:1;             /* kept as a lever, pinned at 1: nothing is dimmed */
  --hg-veil:.20;          /* ground wash that dims the field in this band  */
  --hg-wide:1.02;         /* multiplier during the 11.9-15.2 s pull-back   */

  /* --- state colours, forward compatible with future tokens ----------- */
  --hg-verified:var(--verified, var(--hg-verified-def, var(--pass)));
  --hg-stale:var(--stale, var(--hg-stale-def, var(--fail)));
  --hg-invalid:var(--fail);
  --hg-unknown:var(--unknown, var(--hg-unknown-def, var(--muted)));
  --hg-verified-def:var(--pass);
  --hg-stale-def:#B0761F;
  --hg-unknown-def:var(--muted);
  --hg-ink:var(--ink);    /* label ink; light themes swap this for the ink  */
}

.hg-veil{
  position:absolute; inset:0;
  background:var(--paper);
  opacity:var(--hg-veil);
  -webkit-mask-image:linear-gradient(to bottom,#000 0,#000 52%,transparent 98%);
          mask-image:linear-gradient(to bottom,#000 0,#000 52%,transparent 98%);
}

.hg-svg{
  position:absolute; inset:0; display:block; width:100%; height:100%;
  opacity:0;                              /* .is-in dissolves it up */
  transition:opacity 1.6s ease;
  /* THE MASK IS GONE. THE GRAPH RUNS THE FULL HEIGHT OF THE BAND.
     This used to carry a vertical knockout -- transparent to 21% of the band,
     .42 at 33%, opaque only at 52% -- to enforce "the top third carries no
     graph ink". That rule is revoked. It was the single cause of the dead
     upper half: whatever the partial authored above the midline was erased
     here before it could be seen, so the hero read as copy floating over
     nothing. The graph now paints from the band's first pixel, i.e. right up
     under the site header, and the copy's legibility is bought with placement
     (cards in the gaps, edges and small marks behind glyphs) rather than with
     a knockout. Measured at the glyph boxes: craft/regress.mjs check D. */
}
.hg.is-in .hg-svg{opacity:var(--hg-op);}
.hg.is-in.is-wide .hg-svg{opacity:calc(var(--hg-op) * var(--hg-wide));}
/* a seek / still frame must be exact, not mid-transition */
.hg.is-still .hg-svg{transition:none;}

/* ---------- node bodies: inactive structure, verified, stale ------------
   Three stacked layers per node. The inactive body never leaves; the state
   overlays crossfade over it, so a state change is never a repaint. */
.hg-r    {fill:var(--hg-unknown);fill-opacity:.045;stroke:var(--hg-unknown);stroke-opacity:.42;stroke-width:1.4;}
.hg-r-ok {fill:var(--hg-verified);fill-opacity:.055;stroke:var(--hg-verified);stroke-opacity:.62;stroke-width:1.7;}
.hg-r-bad{fill:var(--hg-stale);fill-opacity:.050;stroke:var(--hg-stale);stroke-opacity:.58;stroke-width:1.6;}

/* ---------- type ----------
   EVERY SIZE IS MULTIPLIED BY --hg-tk. assets/herograph.js publishes it per
   resize as 1/scale, clamped to [1, 1.35] and pinned to 1 on the reference
   viewport and on .is-narrowfit, so the factor only ever recovers type that the
   frame's own reduction would have taken away on a short band. Without it the
   9 px captions measured 7.68 px effective at 1366 x 768 and 8.00 px at
   1280 x 800, i.e. under the floor on two ordinary laptops.
   The captions are 9.5 px rather than 9 px so the smallest class clears the
   floor with margin instead of landing exactly on it. */
.hg-svg text{font-family:var(--mono);letter-spacing:.05em;}
.hg-id  {font-size:calc(13px * var(--hg-tk,1));font-weight:500;fill:var(--hg-ink);fill-opacity:.54;}
.hg-id.hg-sm{font-size:calc(11px * var(--hg-tk,1));}
.hg-sub {font-size:calc(9.5px * var(--hg-tk,1));font-weight:400;fill:var(--hg-ink);fill-opacity:.32;letter-spacing:.02em;}
/* the status marks carry the whole semantic payload, so they sit above the
   structure in the internal hierarchy */
.hg-st  {font-size:calc(10.5px * var(--hg-tk,1));font-weight:500;letter-spacing:.1em;fill-opacity:.72;}
.hg-ok  {fill:var(--hg-verified);}
.hg-stale{fill:var(--hg-stale);}

/* ---------- THE HIERARCHY IS A MEASURED ORDER, NOT AN IMPRESSION ----------
   The thing the viewer is meant to follow must be the brightest graph content on
   the frame at EVERY beat, with the periphery below it and the sleeping field
   below that. It was inverted: the two right-hand peer cards measured a p98
   luminance delta of 23.1 against the ground while the active bottom cluster
   measured 22.2, because terminal's copy guard (a radial plate centred on the
   left copy column, hero-v2.css) covers the bottom cluster and does not reach
   the right flank. Alpha alone therefore does not decide what looks brightest --
   which is why this is now measured per element class per beat, on rendered
   pixels, rather than eyeballed. See craft/checks.mjs check 5.
     .hg-foc   SW-REQ-042, CODE, DOCS, TEST, the commit  -- the causal spine
     .hg-per   SW-REQ-018/031/091, blast_radius          -- the neighbourhood
     (unclassed) the sleeping field                      -- depth
   Ratio after the change: focus / periphery >= 1.9 at every sampled beat. */
.hg-foc .hg-r    {stroke-opacity:.56;fill-opacity:.060;}
.hg-foc .hg-r-ok {stroke-opacity:.82;fill-opacity:.076;stroke-width:1.9;}
.hg-foc .hg-r-bad{stroke-opacity:.76;fill-opacity:.070;stroke-width:1.8;}
.hg-foc .hg-id   {fill-opacity:.70;}
.hg-foc .hg-sub  {fill-opacity:.44;}
.hg-foc .hg-st   {fill-opacity:.90;}
.hg-per .hg-r    {stroke-opacity:.27;fill-opacity:.026;}
.hg-per .hg-r-ok {stroke-opacity:.22;fill-opacity:.026;stroke-width:1.5;}
.hg-per .hg-r-bad{stroke-opacity:.35;fill-opacity:.030;stroke-width:1.5;}
.hg-per .hg-id   {fill-opacity:.36;}
.hg-per .hg-sub  {fill-opacity:.24;}
.hg-per .hg-st   {fill-opacity:.44;}
/* THE PERIPHERY'S VERIFIED GREEN v. THE STORY'S STALE AMBER.
   The two tiers are not the same hue at the beat that matters. Between 4.4 s and
   9.3 s the story is amber and the neighbourhood is still green, and on the tube
   green carries far more luminance than amber at the same alpha: measured emission
   (effective alpha x the relative luminance of the mark's own ink) put the
   periphery's verified card at 128 against the story's brightest card at 91, i.e.
   the founder's exact symptom, latent in the tokens even where the copy guard
   happens to mask it in rendered pixels. The verified overlays on the peripheral
   tier are therefore sized against the STORY'S AMBER rather than against the
   periphery's own grey, which is why .22 here and not the .38 the grey would
   suggest. Both measures pass at every beat after this: see craft/checks.mjs
   check 5, which asserts the rendered-pixel order AND the emission order. */
.hg-per .hg-meta-k{fill-opacity:.40;}
.hg-per .hg-meta-v{fill-opacity:.28;}

/* ---------- THERE IS NO BEHIND-GLASS TIER ANY MORE -----------------------
   #hg-under used to be a DIMMING tier: the one group authored to cross the copy
   column, knocked down as a group by --hg-bgk (.42-.70 per theme) on top of the
   envelope, under a plate of the page's own ground painted over the column. Three
   multiplied reductions put a hairline in there at roughly 8% effective alpha,
   which is what made the copy column read as a hole in the graph.
   The founder's ruling now: do not dim anything. Content that happens to sit
   behind the copy renders at exactly the tier it would render at anywhere else,
   so --hg-bgk is 1 in every theme and this group is a naming convenience -- it
   says "these are the relations that cross the column", not "these are dimmer".
   Legibility is bought with PLACEMENT instead: labelled cards live in the
   measured gaps around the copy, and only edges and small marks pass behind
   glyphs. Measured at the glyph boxes: craft/regress.mjs check D.
   ------------------------------------------------------------------------ */
#hg-under{opacity:var(--hg-bgk,1);}
/* a symbol node is raw material, not intent: it is the dimmest labelled card
   in the graph, one step under the periphery, because in act 1 it is what the
   requirement has not condensed out of yet.
   THE STEP IS NOW IN THE MARK, NOT IN A GROUP KNOCKDOWN. With --hg-bgk at 1 the
   old .30/.42/.22 would have put raw material ABOVE the periphery on both
   measures (check 5 asserts periph >= under), because the group opacity was
   doing the ordering. Each alpha therefore drops just under its peripheral
   counterpart (.27/.36/.24), which keeps the semantic order and costs the layer
   nothing that a viewer can see. */
.hg-raw .hg-r  {stroke-opacity:.26;fill-opacity:.026;}
.hg-raw .hg-id {fill-opacity:.34;}
.hg-raw .hg-sub{fill-opacity:.22;}

/* ---------- hazards (act 3) ----------
   A diamond is the hazard mark the rest of the site uses, and a hazard is a
   thing that has not been discharged yet, so it is drawn in the stale colour.
   The one on the requirement under the microscope is focus tier; the one on the
   audit peer answers to the periphery. */
.hg-hazd{fill:var(--hg-stale);fill-opacity:.60;stroke:var(--hg-stale);stroke-opacity:.68;stroke-width:1.2;}
.hg-hazt{font-size:calc(9.5px * var(--hg-tk,1));font-weight:500;letter-spacing:.10em;fill:var(--hg-stale);fill-opacity:.54;}
.hg-hazl{fill:none;stroke:var(--hg-stale);stroke-opacity:.32;stroke-width:1.2;stroke-linecap:round;}
.hg-haz.hg-per .hg-hazd{fill-opacity:.32;stroke-opacity:.38;}
.hg-haz.hg-per .hg-hazt{fill-opacity:.30;}
.hg-haz.hg-per .hg-hazl{stroke-opacity:.20;}

/* ---------- acceptance criteria (act 4) ----------
   An obligation that is bound to evidence IS verified, so the chips are drawn
   in the verified colour -- and under SW-REQ-042's own card, because they are
   its detail rather than a peer of it. */
.hg-ac .hg-r {stroke:var(--hg-verified);stroke-opacity:.34;fill:var(--hg-verified);fill-opacity:.030;stroke-width:1.3;}
.hg-ac .hg-id{fill-opacity:.46;}
.hg-ac .hg-st{fill-opacity:.62;}
.hg-acl{fill:none;stroke:var(--hg-verified);stroke-opacity:.30;stroke-width:1.2;stroke-linecap:round;}
/* the edges answer to the same order. Addressed by id so the markup carries no
   second class for a thing its id already says. */
/* THE EDGE TIER, BY MEASURED EFFECTIVE ALPHA.
   Every one of these is a hairline, so the ordering is exact arithmetic rather
   than a pixel estimate: paint opacity x --hg-op. On the tube (--hg-op .46) the
   four tiers come out
     focus, verified   .68 -> 313      focus, inactive  .56 -> 258
     periphery, verif. .50 -> 230      periphery, inact .42 -> 193
     the wide graph    .34 -> 156      the sleeping field .34 x .74 -> 116
   which is strictly descending with no crossing at any beat. The story's own
   edges are lifted here rather than the periphery being pushed down further,
   because the periphery still has to clear the lattice -- and during the
   pull-back the wide graph is lit at full strength. */
#hg-ecode,#hg-edocs,#hg-etest{stroke-opacity:.56;}
#hg-e018,#hg-e031,#hg-e091{stroke-opacity:.42;}
#hg-ve018,#hg-ve031,#hg-ve091{stroke-opacity:.50;stroke-width:1.8;}
/* at phone scale .hg.is-small .hg-edge / .hg-ve widen every edge; the peripheral
   three keep their place in the order by taking a smaller share of that widening */
.hg.is-small #hg-e018,.hg.is-small #hg-e031,.hg.is-small #hg-e091{stroke-opacity:.42;stroke-width:1.9;}
.hg.is-small #hg-ve018,.hg.is-small #hg-ve031,.hg.is-small #hg-ve091{stroke-opacity:.50;stroke-width:2.4;}
.hg-commit{font-size:calc(10px * var(--hg-tk,1));font-weight:500;letter-spacing:.06em;fill:var(--hg-stale);fill-opacity:.74;}
/* the commit's own disc. It is the CAUSE of the whole middle of the loop, so it
   is the one mark in the stale colour that is allowed to read as solid. It also
   gives the skew matrix a genuinely circular reference element. */
.hg-commit-dot{fill:var(--hg-stale);fill-opacity:.80;}
.hg-meta-k{font-size:calc(9.5px * var(--hg-tk,1));font-weight:500;letter-spacing:.14em;fill:var(--hg-stale);fill-opacity:.60;}
.hg-meta-v{font-size:calc(9.5px * var(--hg-tk,1));font-weight:400;letter-spacing:.06em;fill:var(--hg-stale);fill-opacity:.44;}

/* ---------- edges ---------- */
.hg-edge{fill:none;stroke:var(--hg-unknown);stroke-opacity:.40;stroke-width:1.5;stroke-dasharray:1;stroke-linecap:round;}
.hg-edge.hg-thin{stroke:var(--hg-stale);stroke-opacity:.64;stroke-width:1.3;}
.hg-invalid{stroke:var(--hg-invalid);stroke-opacity:.58;stroke-width:1.8;stroke-dasharray:5 4.5;}
.hg-ve{stroke:var(--hg-verified);stroke-opacity:.68;stroke-width:2.1;}
.hg-arrow{fill:none;stroke:var(--hg-stale);stroke-opacity:.64;stroke-width:1.3;stroke-linecap:round;stroke-linejoin:round;}

/* ---------- uncertainty, travelling ----------
   One disc per affected edge, walked outward from the file that changed by
   assets/herograph.js. It is the strongest single mark in the stale colour and
   it is on screen for 380-440 ms at a time, which is what makes the blast radius
   read as something PROPAGATING rather than as six nodes recolouring on a
   stagger. */
.hg-sp{fill:var(--hg-stale);fill-opacity:.92;r:3.6;}
/* THE AMBIENT PULSE IS A DIFFERENT MARK, NOT A DIMMER ONE.
   Travelling light now happens in every second of the loop, not only at the two
   acts that mean something, so the two kinds have to be told apart at a glance:
   the blast radius is stale-amber and large, the re-proof rides an HDR image,
   and the ambient pulse is verified-green, small, and only ever out in the
   sleeping field where nothing is labelled. Same mark class for the transform
   pipeline, different colour and radius for the eye. */
/* AND IT STILL ANSWERS TO THE HIERARCHY. At .88 of the verified green this is
   the brightest MARK on the frame -- measured emission 541 against the focus
   tier's own trace dot at 443 -- so the thing the eye is asked to follow was
   a decoration out in the sleeping field. It is a lattice-tier mark and it sits
   under the focus tier's marks, which is what check 5 asserts. */
.hg-sp-amb{fill:var(--hg-verified);fill-opacity:.60;r:2.2;}

/* ---------- the graph asleep / the wider graph --------------------------
   Most of the graph is this, for ever. That contrast against the one proven
   cluster is what the composition is about. */
.hg-latline path{fill:none;stroke:var(--hg-unknown);stroke-opacity:.20;stroke-width:1.3;}
/* A DISTANT NODE IS NOT AN EMPTY CARD.
   The sleeping graph used to be drawn as framed rectangles with no text in them:
   card chrome, card size, no label. At full size that reads as content that
   failed to load, which is exactly what it was reported as. The rule now is
   ONE-WAY: card chrome implies a label, so anything without a label cannot have
   chrome. A distant node is therefore a different KIND of mark -- a dot, and a
   hairline tick standing where the label would be if it were near enough to
   read. No fill, no frame, nothing to mistake for a card.
   The dot carries more of the ink than the old outline did, because a 2.1-unit
   disc has a fiftieth of a 148x40 outline's area: the alphas below are up
   roughly 20x on the fill and are what keeps the field's presence unchanged. */
.hg-latd{fill:var(--hg-unknown);fill-opacity:.56;}
/* THE STUB IS AN EDGE, NOT A TICK.
   The field's old primitive was a "tick": a hairline running out of a dot to a
   label that is never drawn -- a HALF-EDGE, a line with one free end by
   definition. Giving each one a terminating circle made the geometry check
   pass and changed nothing a viewer could see, because the circle was drawn at
   the SAME alpha as the line and only 3.4 px across, so the line still read as
   dissolving into nothing. The field is now generated from a node/edge model
   (craft: gen33-field.py) in which a stub is an ordinary edge between two real
   nodes, and the paint below enforces the rule the eye actually applies:
   A NODE MARK IS ALWAYS BRIGHTER THAN THE EDGE IT TERMINATES.
   leaf .50 against a stub at .28 and a link at .20; hub .56 against both. */
.hg-latstub path{fill:none;stroke:var(--hg-unknown);stroke-opacity:.28;stroke-width:1.25;stroke-linecap:round;}
.hg-latl{r:1.95;fill-opacity:.50;}
.hg-sleep{opacity:.74;}
/* the wider graph is the whole point of the pull-back beat, so it is allowed a
   little more presence than the flank ghosts that are on all the time */
/* THE WIDE GRAPH IS THE ONE LAYER THAT CROSSES THE COPY.
   Its first pass at these alphas was measured on a contact sheet at a third of
   full size and the "much larger intent graph" the beat is about did not read at
   all -- the cluster was visible and the thing it was supposed to be dwarfed by
   was not. So the lattice keeps its presence and the COPY GUARD carries the
   protection instead: hero-v2.css lifts .hero-film::after by --film-wide for
   exactly the 11.9-15.0 s window, which flattens the lattice where the copy is
   standing and nowhere else. */
/* The wide graph is the subject of the pull-back beat, but it is still the LATTICE
   tier: measured effective alpha 0.239 against the periphery's re-proof edges at
   0.212 put it above the neighbourhood for the 3.4 s the beat lasts. It takes its
   presence from covering the whole frame instead -- there are fifty of these nodes
   where the neighbourhood has three -- so the per-mark alpha comes down under the
   periphery's and the beat still reads. */
#hg-lat .hg-latd{fill-opacity:.70;}
#hg-lat .hg-latl{fill-opacity:.66;}
#hg-lat .hg-latstub path{stroke-opacity:.34;}
#hg-lat .hg-latline path{stroke-opacity:.36;}

/* ---------- the verification wave ----------
   stop-color cannot take a var() as an SVG attribute, so the gradient stops
   are painted from CSS and only the stop-opacity ramp lives in the markup. */
.hg-wave{mix-blend-mode:screen;}
#hg-wavegrad stop{stop-color:var(--hg-verified);}

/* ---------- the boundary crossing, hero side ---------- */
.hg-cross-line{fill:none;stroke:var(--hg-verified);stroke-opacity:.58;stroke-width:1.4;stroke-dasharray:1;}
.hg-cross-dot{fill:var(--hg-verified);fill-opacity:.72;}

/* ---------- the depth-of-field variant (?hero=depth) -------------------
   Off unless the driver adds .hg-depth. Some elements sit nearer the camera,
   softly out of focus, and drift faster: a technical observatory, not Tron. */
.hg-near{opacity:0;}
.hg-depth .hg-near{opacity:.5;}
.hg-neard{fill:var(--hg-unknown);fill-opacity:.46;}
.hg-nearl2{r:2.2;fill-opacity:.42;}
.hg-nearline path,.hg-nearstub path{fill:none;stroke:var(--hg-unknown);stroke-opacity:.24;stroke-width:2.6;stroke-linecap:round;}
.hg-depth #hg-near{filter:blur(3.4px);}
.hg-depth #hg-lat{filter:blur(1.5px);}

/* ==========================================================================
   machine-evidence texture at the frame edges
   Not readable at a glance, and deliberately clipped by the frame. Rendered in
   HTML rather than SVG so it hugs the real viewport edges at every fit.
   ========================================================================== */
.hg-ed{position:absolute;top:0;bottom:0;width:0;}
.hg-ed-l{left:0;}
.hg-ed-r{right:0;}
/* --t IS A USER-SPACE Y, NOT A PERCENTAGE OF THE BAND.
   These strings are HTML so they hug the true viewport edges at every fit -- but
   their VERTICAL position has to answer to the graph, or the same stop lands on a
   different part of the story in every fit mode. A percentage of the band put the
   92% stop at user y 841 on a 1440x900 landscape band and at user y 798 on a
   1024x1366 portrait one, and 798 is exactly where the blast-radius block sits.
   herograph.js publishes the band's own user->px mapping per resize (--hg-y0 is
   where user y=0 lands, --hg-ys is px per user unit), so a stop authored as a user
   y stays at that user y everywhere, and one static keep-out is correct at every
   viewport. The fallbacks make the stops read as plain px before the first fit. */
.hg-ed b{
  position:absolute;
  top:calc(var(--hg-y0,0px) + var(--t,0) * var(--hg-ys,1px));
  display:block; white-space:nowrap;
  font-family:var(--mono); font-size:10.5px; font-weight:400;
  letter-spacing:.14em; text-transform:none;
  /* THE FLANK GOT BIGGER, NOT BRIGHTER.
     Its alpha ceiling is a founder rule -- the periphery must outshine this tier
     at every beat (check 5) -- and .hg-ed is a sibling of .hg-svg so it never
     pays the --hg-op envelope, which leaves it almost no headroom: a peripheral
     card's label sits at .36 x .46 = .166 effective, and every value above .085
     measured as an inversion at one viewport or another. Type SIZE is the lever
     that is left. A 10.5px string has the same peak |dL| as a 9px one, so the
     hierarchy is untouched, and it is what makes the strings read as fragments
     rather than as a texture of dots. */
  /* THE FLANK TEXTURE IS THE OUTERMOST TIER AND MUST MEASURE LIKE IT.
     .hg-ed is a SIBLING of .hg-svg, not a child, so it never carried the --hg-op
     envelope (0.46 on the tube) that every graph mark pays. At the old alphas it
     therefore measured a p98 luminance delta of 12.75 against the ground while the
     peripheral requirement cards measured 9.2-10.7 -- the machine-evidence strings
     at the frame edges were brighter than the requirements they annotate, which is
     the same inverted hierarchy the story cluster had, one tier further out.
     Every per-theme value below is scaled to put this tier under the periphery at
     every beat, which also lands it back on the 8-12% the layer was specified at
     before it drifted up. Verified by measurement, not by eye: craft/checks.mjs
     check 5 compares focus > periphery > lattice AND periphery >= edge texture. */
  color:var(--hg-unknown); opacity:var(--hg-ed-op,.09);
}
/* NO STRING IS EVER CUT THROUGH A WORD -- AND THE EDGE STILL CARRIES INK.
   These used to be authored at left:-14px / right:-10px so they ran off the
   frame, which sliced `SW-REQ-042` to `-REQ-042`, `evidence/current` to
   `dence/current` and so on. A cut SHAPE reads as bleed; a cut WORD reads as
   broken text. So the decision, kept and applied identically on both flanks:
   INSET so every string is whole, and dissolve it OUTWARD with a mask.
   14px matches the gutter the rest of the page's fixed chrome uses.
   WHAT CHANGED: the fade used to reach 58% at the outer end and cross over at
   52%, which combined with an 8-9% layer opacity left the frame's own edge
   carrying a whisper -- the founder's report that "the left flank now carries
   whispers rather than legible fragments". So the outer end holds 78% and the
   ramp finishes sooner (46%), and there are five strings per flank rather
   than four. The layer OPACITY barely moved (.09 -> .115 on the tube) because it
   has almost no headroom: .hg-ed is a sibling of .hg-svg, so it never pays the
   --hg-op envelope, and a peripheral card's label sits at .33 x .46 = .152
   effective. Measured, .20 read 15.49 against those labels' 11.09 at 2560x1080
   and .14 read 16.94 against 15.58 at 1440x900 -- both inversions of the founder's
   own hierarchy rule. So the legibility came from the MASK and from density, not
   from alpha, which is the only place the headroom actually was.
   The strings stay whole and stay inside the viewport; what they no longer do is
   dissolve to nothing before the edge. The tier order is unchanged and still
   asserted: craft/checks.mjs check 5 keeps the periphery above this layer at
   every beat, and check 7 measures the flank strips' own ink. */
.hg-ed-l b{
  left:14px;
  -webkit-mask-image:linear-gradient(to right,rgba(0,0,0,.74) 0,#000 48%);
          mask-image:linear-gradient(to right,rgba(0,0,0,.74) 0,#000 48%);
}
.hg-ed-r b{
  right:14px;
  -webkit-mask-image:linear-gradient(to left,rgba(0,0,0,.74) 0,#000 48%);
          mask-image:linear-gradient(to left,rgba(0,0,0,.74) 0,#000 48%);
}
.hg.is-in .hg-ed b{transition:opacity 1.6s ease;}
/* THE EDGE TEXTURE YIELDS TO THE WIDER GRAPH.
   #hg-lat is the one layer the pull-back scales (coverDec measures it through the
   beat's own zoom, up to k=1.34 at 2560), so where its marks land in user space is
   a function of the viewport and cannot be held clear of the flank strings by
   authored geometry alone. The beat is also the one moment the frame is meant to
   be nothing but graph -- the machine-evidence strings are what the wider graph
   replaces -- so they step out of its way for exactly that window. Both problems,
   one gesture. A frozen frame must be at one end of the fade or the other, never
   mid-transition, which is the second rule below (.hg-svg already had it). */
.hg.is-wide .hg-ed b{opacity:0;}
.hg.is-still .hg-ed b{transition:none;}

/* ==========================================================================
   PER-THEME INTENSITY
   THE ENVELOPE SITS BETWEEN THE TWO PASSES THAT MISSED. It was .46 on the tube
   and read as almost invisible; the correction took it to .84 and read as too
   bright. It now lands at the midpoint of those two, which is the founder's own
   instruction -- "less bright, but still clearly there":
       terminal .65 -> .52      verified .72 -> .58     night .67 -> .54
       acid     .66 -> .53      paper    .84 -> .75     riso  .78 -> .69
       brutal   .82 -> .73      olo      .81 -> .72     (phone .58 -> .46)
   The light grounds move least, in both directions, because they have the least
   headroom: measured at the copy's own glyph boxes, a light theme pays about
   four times as much contrast per point of envelope as the tube does, so they
   never went as far up and they do not have as far to come back down.
   --hg-ed-op MOVES WITH IT, BY THE SAME RATIO PER THEME. .hg-ed is a sibling of
   .hg-svg and never pays the envelope, so an envelope cut that left it alone
   would lift the machine-evidence strings above the requirement cards they
   annotate -- the inversion check 5 exists to catch. Both tiers move together,
   so the hierarchy is exactly where it was.
   THE COPY IS ALSO PROTECTED LOCALLY, not by this lever: assets/hero-v2.css
   paints a feathered elliptical guard centred on the measured glyph column, at
   about a third of the strength of the plate that used to sit there. The graph
   still runs under the copy and is still visible there; it is quieter.
   Dark grounds carry a graph at a fraction of the alpha a light ground needs.
   Where a theme's --pass is too dark or desaturated to read as green at these
   alphas the graph uses a more chromatic green of its own: at 15% alpha a dark
   green and a grey are the same colour, which would defeat the whole rule.
   ========================================================================== */

/* --- verified: the showcase. #060806 ground, #91FF65 verified ----------- */
html[data-theme="verified"] .hg{
  --hg-op:.58; --hg-veil:.30; --hg-wide:1.04; --hg-bgk:1;
  --hg-stale-def:#E8B24A; --hg-unknown-def:#59615A; --hg-ed-op:.049;
}

/* --- night ------------------------------------------------------------- */
html[data-theme="night"] .hg{
  --hg-op:.54; --hg-veil:.26; --hg-wide:1.02; --hg-bgk:1; --hg-stale-def:#E2A33C;
  --hg-unknown-def:rgba(237,235,227,.72); --hg-ed-op:.048;
}

/* --- acid -------------------------------------------------------------- */
html[data-theme="acid"] .hg{
  --hg-op:.53; --hg-veil:.20; --hg-wide:1.00; --hg-bgk:1; --hg-stale-def:#FFB35C;
  --hg-unknown-def:rgba(237,231,255,.62); --hg-ed-op:.047;
}

/* --- terminal: monochrome phosphor. State reads on a tube as saturation and
       brightness, not hue: --fail is already amber and "inactive" has to be a
       dimmed pale phosphor or it disappears. Terminal also LEFT-aligns the
       hero copy, so the copy keep-out moves to the left column: the graph
       flips its emphasis to the right flank. ---------------------------- */
html[data-theme="terminal"] .hg{
  --hg-op:.52; --hg-veil:.30; --hg-wide:1.00; --hg-bgk:1;
  --hg-unknown-def:rgba(210,255,226,.58); --hg-ed-op:.046;
}

/* --- paper / riso / brutal / olo: LIGHT GROUNDS ------------------------
   Dark ink on warm paper has far less headroom than green on near-black. A
   mid grey at 8% over #FAF8F2 is literally invisible, so on light grounds the
   inactive structure is drawn in the page ink itself and the envelope roughly
   doubles. Verified stays the theme's own green, which is dark enough to read
   at these alphas on paper. ------------------------------------------- */
html[data-theme="paper"] .hg,html:not([data-theme]) .hg{
  /* the pull-back fills the whole frame with lattice, and on a light ground
     that reads busier than it does on black, so the wide beat is DIMMED here
     rather than lifted */
  --hg-op:.75; --hg-veil:.16; --hg-wide:.84; --hg-bgk:1;
  --hg-unknown-def:#3B3A33; --hg-stale-def:#8A5A10; --hg-verified-def:#0A7F3C;
  --hg-ink:#25241E; --hg-ed-op:.062;
}
html[data-theme="riso"] .hg{
  /* riso prints in two inks, klein blue and hot pink; it has no green ink and
     its own --pass IS the blue. Forcing green here would break the print, so
     this is the one theme where "verified" is not green. */
  --hg-op:.69; --hg-veil:.14; --hg-wide:.84; --hg-bgk:1;
  --hg-unknown-def:#2B2A44; --hg-stale-def:#B4540A; --hg-ink:#232244; --hg-ed-op:.060;
}
html[data-theme="brutal"] .hg{
  --hg-op:.73; --hg-veil:.12; --hg-wide:.86; --hg-bgk:1;
  --hg-unknown-def:#232323; --hg-stale-def:#8F5300; --hg-verified-def:#00782F;
  --hg-ink:#141414; --hg-ed-op:.069;
}
html[data-theme="olo"] .hg{
  --hg-op:.72; --hg-veil:.16; --hg-wide:.86; --hg-bgk:1;
  --hg-unknown-def:#2E2B3A; --hg-stale-def:#8A5A00; --hg-verified-def:#0A7A46;
  --hg-ink:#241F33; --hg-ed-op:.061;
}
/* light grounds: the wave is a screen-blend of green, which does nothing over
   paper. Multiply is the equivalent gesture on a light ground. */
html[data-theme="paper"] .hg-wave,html:not([data-theme]) .hg-wave,
html[data-theme="riso"] .hg-wave,
html[data-theme="brutal"] .hg-wave,
html[data-theme="olo"] .hg-wave{mix-blend-mode:multiply;}

/* the hard-edged themes set --radius:0 for the whole site; the graph follows,
   or a rounded graph reads as a foreign object inside them. rx is a CSS
   geometry property; where it is unsupported the markup has no rx anyway. */
html[data-theme="riso"] .hg rect,
html[data-theme="brutal"] .hg rect,
html[data-theme="terminal"] .hg rect,
html[data-theme="verified"] .hg rect{rx:0;}

/* ==========================================================================
   NARROW VIEWPORTS
   The copy fills the whole column, so there is no free flank, and the graph
   drops to a whisper.

   The frame is no longer a tall close-up on SW-REQ-042. assets/herograph.js
   fit() now guarantees the whole safe zone is visible at every aspect, which on
   a phone means the width is the binding constraint: the complete story is
   drawn as a band across the bottom of the hero at about 0.27 of desktop scale,
   with the rest of the band empty above it. That is the trade this makes -- the
   whole graph, small, instead of a legible corner of it -- and at that scale the
   labels are sub-pixel, so the type recedes to texture and the story is carried
   by topology and state colour.

   THE TRIGGER IS .is-small, NOT .is-narrowfit. It used to be .is-narrowfit,
   which only reports that the WIDTH is the binding constraint -- true on a phone
   and also true on a tall desktop, where the graph still draws at or above 1:1.
   1512 x 982 and 1728 x 1117 were therefore rendering the desktop graph with the
   phone's treatment at scale 1.05 and 1.20. .is-small is set from the rendered
   scale (< 0.7), which is the thing this treatment is actually about.
   ========================================================================== */
.hg.is-small .hg-id{fill-opacity:.16;}
.hg.is-small .hg-sub{fill-opacity:.07;}
.hg.is-small .hg-commit,
.hg.is-small .hg-meta-k{fill-opacity:.26;}
.hg.is-small .hg-meta-v{fill-opacity:.16;}
/* the node bodies and the state overlays carry the whole payload at this scale,
   so they gain the presence the type gives up */
/* A CLOSED FRAME IS A CARD; TWO BRACKETS ARE A MARK.
   At this scale a card is 48x11 device px and its label renders 3 px tall, so the
   story nodes read exactly the way the sleeping lattice used to: chrome with
   nothing legible in it. The outline is therefore clipped to its outer quarters
   (#hg-brk in the markup, objectBoundingBox units so one clip fits all seven
   cards) and what is left is an open bracket pair. Nothing moves: the clip is a
   paint operation, so every geometric reference this hero depends on -- the skew
   elements, the containment boxes, the camera pivot, the trace exit, the HDR
   tile -- is untouched. The labels keep the alphas below, where they are texture
   by intent; with the frame open they carry no obligation to be readable. */
.hg.is-small .hg-node rect{clip-path:url(#hg-brk);}
/* AND AN OPEN BRACKET HAS NO MIDDLE TO LAND ON.
   The clip removes the middle half of every card outline, so an edge that runs
   into a card's top or bottom border terminates on nothing at this scale --
   the field's own defect, one tier up, and the pixel check counts it. Each
   attachment point therefore carries a PORT: a node mark of its own, written
   inside its card's group so its visibility is the card's and the two can
   never desynchronise. Transparent at desktop scale, where the frame is whole
   and terminates the edge itself. The tier order is restated here for the same
   reason the frame rules above restate it. */
.hg-port{fill:var(--hg-unknown);fill-opacity:0;}
.hg.is-small .hg-port{fill-opacity:.58;r:5.2;}
.hg-port-ok{fill:var(--hg-verified);}
.hg-port-bad{fill:var(--hg-stale);}
.hg.is-small .hg-port-ok{fill-opacity:.80;}
.hg.is-small .hg-port-bad{fill-opacity:.76;}
.hg.is-small .hg-foc .hg-port{fill-opacity:.72;}
.hg.is-small .hg-r{stroke-opacity:.52;stroke-width:3.0;}
.hg.is-small .hg-r-ok{stroke-opacity:.74;stroke-width:3.6;}
.hg.is-small .hg-r-bad{stroke-opacity:.70;stroke-width:3.4;}
/* THE TIER ORDER HOLDS AT EVERY SCALE, INCLUDING THIS ONE.
   The three rules above are .hg.is-small .hg-r -- three classes -- and .hg-foc /
   .hg-per are two, so at phone and tablet scale the generic treatment WON and the
   story cluster and the peripheral cards were drawn identically. Measured at
   768x1024: the periphery read 30-44 against the focus's 6-20, i.e. the inverted
   hierarchy this pass exists to remove, still fully present at the two smallest
   sizes because the fix was specificity-shadowed. The tier is restated here at
   four classes so it survives, and it is restated in the SAME direction and by the
   same proportion as the desktop rule rather than being re-invented. */
.hg.is-small .hg-foc .hg-r{stroke-opacity:.60;stroke-width:3.2;}
.hg.is-small .hg-foc .hg-r-ok{stroke-opacity:.86;stroke-width:3.9;}
.hg.is-small .hg-foc .hg-r-bad{stroke-opacity:.82;stroke-width:3.7;}
.hg.is-small .hg-per .hg-r{stroke-opacity:.26;stroke-width:2.3;}
.hg.is-small .hg-per .hg-r-ok{stroke-opacity:.20;stroke-width:2.5;}
.hg.is-small .hg-per .hg-r-bad{stroke-opacity:.20;stroke-width:2.5;}
.hg.is-small .hg-foc .hg-id{fill-opacity:.24;}
/* the state glyph is the periphery's brightest label at this scale, and it is
   green while the story's is amber -- same hue problem, same answer */
.hg.is-small .hg-per .hg-st{fill-opacity:.19;}
.hg.is-small .hg-per .hg-id{fill-opacity:.11;}
.hg.is-small .hg-per .hg-sub{fill-opacity:.05;}
.hg.is-small .hg-edge{stroke-width:2.8;}
.hg.is-small .hg-ve{stroke-width:3.8;}
/* THE FLANK TEXTURE GOES WITH THE LABELS.
   .hg-ed is HTML pinned to the real viewport edges, so its 9 px strings do NOT
   shrink with the frame -- and at .is-small the story's own labels are down to
   fill-opacity .16 / .07. Measured at 768x1024 (the graph drawn at 0.53 scale):
   the peripheral edge texture was the brightest type in the whole graph, i.e.
   exactly the hierarchy inversion this pass exists to remove, one tier further
   out. It also put the two layers in each other's way: the strings live in
   viewport space and the lattice in user space, and the map between them is a
   function of the fit, so no authored geometry can hold them apart at every
   aspect. Both problems have the same answer -- where the graph is a whisper,
   the texture at its edges is not the thing to read, so it goes. */
.hg.is-small .hg-ed{display:none;}
/* the behind-glass tier goes quieter still where the copy fills the column and
   the graph is already a whisper */
/* nothing is knocked down at phone scale either: the whole graph is already
   drawn at 0.27 of desktop scale and a second reduction erased it.
   THE ORDER STILL HAS TO HOLD THOUGH. .hg.is-small .hg-r / .hg-id are the
   phone's own treatment and they out-specify .hg-raw's, so with the group
   knockdown gone the raw symbol nodes came out ABOVE the periphery on both
   measures (check 5: card 136 vs 108, label 107 vs 103). The three rules below
   restate the raw tier's place at phone scale, per mark, exactly as the desktop
   rules do -- one step under .hg.is-small .hg-per. */
.hg.is-small .hg-raw .hg-r  {stroke-opacity:.38;stroke-width:2.2;}
.hg.is-small .hg-raw .hg-id {fill-opacity:.10;}
.hg.is-small .hg-raw .hg-sub{fill-opacity:.04;}
.hg.is-small .hg-hazt,.hg.is-small .hg-ac .hg-id{fill-opacity:.14;}
/* CARD CHROME IMPLIES A LEGIBLE LABEL, AT EVERY SIZE CLASS.
   The criteria chips carry two rows now, which makes their frame tall enough to
   count as card chrome at phone scale -- and at phone scale an 11 px id renders
   4.0 px tall, under the 4.5 px legibility floor the chrome rule holds every
   framed mark to. The id takes the next size up here and nowhere else. */
.hg.is-small .hg-ac .hg-id{font-size:calc(14px * var(--hg-tk,1));}
.hg.is-small .hg-ac .hg-st{fill-opacity:.18;}
/* AN OBLIGATION CHIP IS ONE LINE AT PHONE SCALE.
   The chips carry a second line now. At 0.27 of desktop scale that line renders
   4.0 px tall, i.e. under the 4.5 px floor at which a label stops being a label
   -- so a card with a legible id was being scored as chrome with an illegible
   caption in it. The caption is the part that does not survive the reduction,
   so the caption is the part that goes. */
.hg.is-small .hg-acc .hg-sub{display:none;}
/* the story is drawn at ~0.27 of desktop scale here, so a 3.2-unit disc lands
   under one device pixel. The travelling marks are the only thing left carrying
   the propagation once the labels have receded to texture, so they scale up. */
.hg.is-small .hg-sp{r:7;}
.hg.is-small .hg-commit-dot{r:7;}
.hg.is-small .hg-latline path{stroke-width:2.2;}
.hg.is-small .hg-latd{fill-opacity:.54;r:4;}
/* the same rule one tier down: at .27 of desktop scale the leaf has to hold a
   clear margin over a 2.2-wide stub or the stub reads as a free end again. */
.hg.is-small .hg-latl{fill-opacity:.50;r:3.4;}
.hg.is-small .hg-latstub path{stroke-width:2.2;stroke-opacity:.30;}

@media (max-width:760px){
  .hg{--hg-op:.46;--hg-veil:.14;--hg-wide:1.00;--hg-bgk:1;}
  html[data-theme="verified"] .hg{--hg-op:.53;}
  html[data-theme="night"] .hg,html[data-theme="acid"] .hg{--hg-op:.46;}
  html[data-theme="terminal"] .hg{--hg-op:.46;}
  html[data-theme="paper"] .hg,html:not([data-theme]) .hg{--hg-op:.75;}
  html[data-theme="riso"] .hg{--hg-op:.69;}
  html[data-theme="brutal"] .hg{--hg-op:.71;}
  html[data-theme="olo"] .hg{--hg-op:.72;}
  /* no mask here either: the phone band is the SHORTEST one there is, so a
     ramp that removed its top half removed most of the hero. */
  /* one string per edge is plenty at 390 */
  .hg-ed b:nth-child(n+3){display:none;}
}

/* ==========================================================================
   THE BOUNDARY CROSSING OVERLAY
   Built by assets/herograph.js. A fixed layer so the trace can leave the hero
   (which clips its own contents) and land on #req-card in section 2. Scroll
   drives it, not the clock: per frame it writes one transform, one
   stroke-dashoffset and one opacity, and never reads layout.
   ========================================================================== */
.hgx{
  position:fixed; inset:0; z-index:0;
  pointer-events:none; overflow:hidden;
  opacity:0;
}
.hgx svg{position:absolute;inset:0;display:block;width:100%;height:100%;}
.hgx-line{
  fill:none;
  stroke:var(--pass);
  stroke-opacity:.28; stroke-width:1.2;
  stroke-dasharray:1;
}
.hgx-land{
  fill:none;
  stroke:var(--pass);
  stroke-opacity:.42; stroke-width:1.2;
}
.hgx-dot{fill:var(--pass);fill-opacity:.5;}
html[data-theme="paper"] .hgx-line,html:not([data-theme]) .hgx-line{stroke:#0A7F3C;stroke-opacity:.42;}
html[data-theme="paper"] .hgx-land,html:not([data-theme]) .hgx-land{stroke:#0A7F3C;stroke-opacity:.5;}
html[data-theme="paper"] .hgx-dot,html:not([data-theme]) .hgx-dot{fill:#0A7F3C;}
html[data-theme="brutal"] .hgx-line{stroke:#00782F;stroke-opacity:.46;}
html[data-theme="brutal"] .hgx-land{stroke:#00782F;stroke-opacity:.54;}
html[data-theme="brutal"] .hgx-dot{fill:#00782F;}
html[data-theme="olo"] .hgx-line{stroke:#0A7A46;stroke-opacity:.46;}
html[data-theme="olo"] .hgx-land{stroke:#0A7A46;stroke-opacity:.54;}
html[data-theme="olo"] .hgx-dot{fill:#0A7A46;}

/* reduced motion: the driver renders one static frame and never starts the
   loop; the crossing is drawn at full length as soon as it is in range. */
@media (prefers-reduced-motion: reduce){
  .hg-svg,.hg-ed b{transition:none;}
}
