/* ==========================================================================
   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-mask     the protected readability zone: the page's own ground painted
                  back over the field in three overlapping ellipses
     .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%);
}

/* ---------- the protected readability zone ------------------------------
   MEASURED ON THE LETTERS, NOT ON THE ELEMENT BOXES. Swept at 1440x900 on the
   terminal theme, the hero copy's own GLYPH rects (Range.getClientRects on the
   text nodes) inside the .hg box (0,78 1440x738) are:

     headline line 1  x 162..875   y 184..279     (11.3-60.8% ,14.4-27.2%)
     headline line 2  x 162..824   y 268..363     (11.3-57.2% ,25.7-38.6%)
     subhead          x 162..758   y 431..518     (11.3-52.6% ,47.8-59.6%)
     actions          x 240..674   y 580..597     (16.7-46.8% ,68.0-70.3%)

   The lobes used to be centred at 38% -- the FRAME's copy band, not the copy.
   The headline's own ink is centred at x=518 (36%) and is 713px wide, and a
   soft radial lobe 518px across put only its middle third inside the 80-90%
   the ruling asks for: measured alpha 55% under the first characters, 91% in
   the middle and 68% at the last glyph of line one. A headline is read from
   its first letter, so the band has to fit the WORDS.

   Five lobes now, and the first two of them hug the two headline LINES rather
   than the block they sit in -- an ellipse wide enough to hold 80% across a
   two-line headline is also tall enough to wash the top edge of the band,
   which is the graph's own perimeter. One lobe per line keeps the vertical
   reach short, so the top edge stays at 0. A third, weak, tall lobe carries
   the soft outer silhouette so the union is still one irregular blob with no
   straight edge anywhere -- which is the whole point: a rectangle reads as a
   panel and a panel reads as a hole cut in the picture.
   Measured over every glyph pixel: headline 32% -> 80% at its darkest, subhead
   29% -> 74%, actions 60% -> 86%, and the flat (>=98.5%) core grows from 11.0%
   to 16.6% of the band. Nothing anywhere on the copy is raised by more than
   the ruling asks for, and the OVERALL alpha is not raised at all: the fix is
   where the lobes are, not how strong they are.

   THE SIXTH LOBE IS GONE WITH THE SCROLL CUE IT COVERED. It sat at 93% of the
   band and was 13% wide -- the cue's glyph box and nothing else -- so once the
   cue left it was a knockdown over graph alone.
   The copy block also re-centres when the receipt strip leaves it, so every
   glyph above moves down 53.8px at 1440x900. The five lobes are deliberately
   NOT moved with it: the union still covers the whole block with margin --
   measured on the worst ground pixel under each element, headline 9.90:1,
   machine stamp 10.38:1, subhead 11.95:1, secondary action 10.77:1, against a
   7:1 floor -- and moving them down 7.3% would push the actions lobe into the
   graph's own focal card row, which the film's last act plays on.

   The lobes are deliberately ASYMMETRIC and stop short of the right edge. At
   1440 the widest now reaches x=1036, so everything past that -- the right
   flank cards, the whole periphery, the trace exit at x=1054 -- keeps all of
   its luminance: measured 0% at x=1040 for every y. That is the founder's
   "graph returning gradually toward the right and bottom", and it is why the
   mask cannot be centred on the frame. All eight perimeter probes read 0%.

   --hgm-cx is the COPY COLUMN's centre, not the frame's. The terminal theme
   left-aligns the hero copy; every other theme centres it. That is the only
   per-theme geometry this needs, and it is the same split hero-v2.css already
   makes for the copy guard.

   It paints var(--paper), so on a light theme it lightens toward the ground
   instead of darkening -- the mask hides the graph in every theme rather than
   blackening the page in some of them. */
.hg-mask{
  position:absolute; inset:0; pointer-events:none;
  background:var(--paper);
  opacity:var(--hgm-k, 1);
  --hgm-cx:50%;
  -webkit-mask-image:
    radial-gradient(36% 13% at var(--hgm-cx) 20.7%,
      rgba(0,0,0,.66) 0%, rgba(0,0,0,.65) 66%, rgba(0,0,0,.62) 80%,
      rgba(0,0,0,.36) 89%, rgba(0,0,0,.09) 95%, rgba(0,0,0,0) 100%),
    radial-gradient(36% 13% at var(--hgm-cx) 32.2%,
      rgba(0,0,0,.66) 0%, rgba(0,0,0,.65) 66%, rgba(0,0,0,.62) 80%,
      rgba(0,0,0,.36) 89%, rgba(0,0,0,.09) 95%, rgba(0,0,0,0) 100%),
    radial-gradient(36% 29% at var(--hgm-cx) 27%,
      rgba(0,0,0,.20) 0%, rgba(0,0,0,.20) 58%, rgba(0,0,0,.19) 76%,
      rgba(0,0,0,.11) 88%, rgba(0,0,0,.03) 95%, rgba(0,0,0,0) 100%),
    radial-gradient(35% 24% at calc(var(--hgm-cx) - 1%) 52%,
      rgba(0,0,0,.78) 0%, rgba(0,0,0,.76) 56%, rgba(0,0,0,.73) 74%,
      rgba(0,0,0,.42) 86%, rgba(0,0,0,.11) 95%, rgba(0,0,0,0) 100%),
    radial-gradient(35% 18% at calc(var(--hgm-cx) - 2%) 70%,
      rgba(0,0,0,.68) 0%, rgba(0,0,0,.64) 45%, rgba(0,0,0,.40) 70%,
      rgba(0,0,0,.14) 87%, rgba(0,0,0,0) 100%);
          mask-image:
    radial-gradient(36% 13% at var(--hgm-cx) 20.7%,
      rgba(0,0,0,.66) 0%, rgba(0,0,0,.65) 66%, rgba(0,0,0,.62) 80%,
      rgba(0,0,0,.36) 89%, rgba(0,0,0,.09) 95%, rgba(0,0,0,0) 100%),
    radial-gradient(36% 13% at var(--hgm-cx) 32.2%,
      rgba(0,0,0,.66) 0%, rgba(0,0,0,.65) 66%, rgba(0,0,0,.62) 80%,
      rgba(0,0,0,.36) 89%, rgba(0,0,0,.09) 95%, rgba(0,0,0,0) 100%),
    radial-gradient(36% 29% at var(--hgm-cx) 27%,
      rgba(0,0,0,.20) 0%, rgba(0,0,0,.20) 58%, rgba(0,0,0,.19) 76%,
      rgba(0,0,0,.11) 88%, rgba(0,0,0,.03) 95%, rgba(0,0,0,0) 100%),
    radial-gradient(35% 24% at calc(var(--hgm-cx) - 1%) 52%,
      rgba(0,0,0,.78) 0%, rgba(0,0,0,.76) 56%, rgba(0,0,0,.73) 74%,
      rgba(0,0,0,.42) 86%, rgba(0,0,0,.11) 95%, rgba(0,0,0,0) 100%),
    radial-gradient(35% 18% at calc(var(--hgm-cx) - 2%) 70%,
      rgba(0,0,0,.68) 0%, rgba(0,0,0,.64) 45%, rgba(0,0,0,.40) 70%,
      rgba(0,0,0,.14) 87%, rgba(0,0,0,0) 100%);
  -webkit-mask-composite:source-over;
          mask-composite:add;
}
html[data-theme="terminal"] .hg-mask{--hgm-cx:36%;}   /* the headline ink's own centre */
/* THE PHONE HAS NO COPY COLUMN.
   The lobes above are sized for a desktop composition: a copy column about a
   third of the frame wide. At 390 the copy is full-bleed and fit() maps far
   more of the graph's user space onto the band, so the desktop lobes miss it
   and the graph's own edges cross the text.
   This block changes nothing at 721 px and up, so every desktop knockdown the
   composition was measured against is untouched. It re-centres the column on
   the frame, because at this width the copy is centred on the frame.
   TWO LOBES WENT WITH THE COPY THEY COVERED. The receipt strip and the scroll
   cue used to reach 78% and 96% of this band; the copy now ends at 60%, so the
   lobe at 70% and the lobe at 90% were knocking the graph down over nothing.
   The two that remain still cover every glyph: measured at 390x844 terminal on
   the worst ground pixel under each element, headline 10.51:1, machine stamp
   9.70:1, subhead 8.88:1, secondary action 11.23:1 -- all over the 7:1 floor. */
@media (max-width:720px){
  .hg-mask{
    --hgm-cx:50%;
    -webkit-mask-image:
      radial-gradient(46% 27% at var(--hgm-cx) 28%,
        rgba(0,0,0,.92) 0%, rgba(0,0,0,.90) 46%, rgba(0,0,0,.66) 70%,
        rgba(0,0,0,.26) 87%, rgba(0,0,0,0) 100%),
      radial-gradient(46% 22% at var(--hgm-cx) 52%,
        rgba(0,0,0,.76) 0%, rgba(0,0,0,.73) 45%, rgba(0,0,0,.48) 70%,
        rgba(0,0,0,.18) 87%, rgba(0,0,0,0) 100%);
            mask-image:
      radial-gradient(46% 27% at var(--hgm-cx) 28%,
        rgba(0,0,0,.92) 0%, rgba(0,0,0,.90) 46%, rgba(0,0,0,.66) 70%,
        rgba(0,0,0,.26) 87%, rgba(0,0,0,0) 100%),
      radial-gradient(46% 22% at var(--hgm-cx) 52%,
        rgba(0,0,0,.76) 0%, rgba(0,0,0,.73) 45%, rgba(0,0,0,.48) 70%,
        rgba(0,0,0,.18) 87%, rgba(0,0,0,0) 100%);
  }
}
/* brutal keeps hard geometry everywhere else on the page; a soft blob would
   read as a smudge there, so it gets a flat wash at the headline strength. */
html[data-theme="brutal"] .hg-mask{
  -webkit-mask-image:none;mask-image:none;opacity:.62;
}
/* a light ground shows a wash of itself sooner than a dark one does, and it
   carries less graph to knock down in the first place */
html[data-theme="paper"] .hg-mask,html[data-theme="riso"] .hg-mask,
html[data-theme="olo"] .hg-mask,html:not([data-theme]) .hg-mask{--hgm-k:.86;}

.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;}

/* ---------- the focal plate --------------------------------------------
   AMBIENT GRAPH: thin, dim, uncertain.  THE FOCAL OBJECT: solid, precise.
   The requirement under the microscope was reading as a translucent region of
   the mesh -- lattice lines ran straight through its interior, lattice dots sat
   on its own border, and SYS-REQ-009 / VERIFIED competed with that texture at
   the exact instant they have to be unambiguous. The plate paints the ground
   itself, INSIDE the svg and BEFORE the border, so everything behind the card
   is occluded and the relations that reach it terminate at its edge. The whole
   svg is still composited at --hg-op, so an opaque plate does not become a
   bright panel: it becomes a hole in the mesh the shape of the card. */
.hg-plate{fill:var(--paper);fill-opacity:calc(.94 + .06 * var(--hg-sel, 0));}
/* On the light grounds the ground itself is the brightest thing on screen, so
   the plate is pulled back a little: full paper there would read as a card
   floating over the page rather than as a clearing in the field. */
html[data-theme="paper"] .hg-plate,html[data-theme="riso"] .hg-plate,
html[data-theme="brutal"] .hg-plate,html[data-theme="olo"] .hg-plate,
html:not([data-theme]) .hg-plate{fill-opacity:calc(.88 + .10 * var(--hg-sel, 0));}

/* ---------- 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:.22;fill-opacity:.022;}
.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;}
/* THE FOUNDER'S RESTING INTENSITIES.
   "edges roughly 6-10%, inactive labels roughly 8-12%, active nodes 35-50%,
   verification pulse temporarily much brighter, everything else settles back.
   The graph should feel asleep, not permanently busy."
   These are declared alphas; what the eye gets is declared x --hg-op (.51 on
   the tube). Measured at 1440x900 after this change:
     sleeping lattice, the only thing lit in the 0-4 s beat   .20 x .51 = 10.2%
     inactive periphery label                                 .26 x .51 = 13.3%
     inactive periphery sub-label                             .18 x .51 =  9.2%
     the ACTIVE requirement's own label                      .717 x .51 = 36.6%
   The periphery came down about a quarter to get inside the band; the active
   tier did not move, because it was already inside it and the whole point of
   the ramp is the DISTANCE between the two. */
.hg-per .hg-id   {fill-opacity:.26;}
.hg-per .hg-sub  {fill-opacity:.18;}
.hg-per .hg-st   {fill-opacity:.36;}
/* 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;}

/* ---------- the obligation chips (act 4) ----------
   SYS-REQ-009's four obligation classes, verbatim from obligation_checklist.
   An obligation that is bound to evidence IS verified, so the chips are drawn
   in the verified colour -- and under SYS-REQ-009's own card, because they are
   its detail rather than a peer of it.
   .hg-obl is set at the metadata size, not the card size. Two of the four real
   class names are fifteen characters, and the row has to stop at x=1050 because
   the trace exit that crosses the fold runs down x=1054; a 94-unit chip and a
   8.2-unit face is what makes `malformed_input` and `nested_mutation` fit
   without abbreviating a corpus string, which would be an invention. */
.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-obl{font-size:calc(8.2px * var(--hg-tk,1));font-weight:500;letter-spacing:.012em;
        fill:var(--hg-verified);fill-opacity:.56;}
/* THE STAKEHOLDER TIER NEVER TICKS.
   All seven STK-REQ files in the published corpus are `status: review`, so a
   stakeholder card carries the word instead of a mark: it is a candidate, and
   nothing is judged against it yet. That is also why the mark is drawn in the
   inactive-structure colour and not in either state colour. */
.hg-rev{fill:var(--hg-unknown);letter-spacing:.08em;font-weight:400;}
.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 AMBIENT LAYER ------------------------------------------
   The film is a one-shot now: it plays once, holds its end state and stops.
   Something still has to be alive underneath it, or the finished hero is a
   photograph. This is that something, and it is built to the founder's own
   ceiling rather than against it -- "the graph should feel asleep, not
   permanently busy", edges at roughly 6-10%.

   THE TRICK IS THAT IT ADDS NOTHING.
   Every breathing line and every ticking node in #hg-live is drawn at the
   coordinates of a line or node the resting lattice ALREADY draws. So the
   layer contributes no new structure and no new density: it lets a handful of
   the marks that are already on screen brighten and fall back. The declared
   peaks are chosen so that the COMBINED alpha stays inside the band:

     edge   static .20 + ambient .070  ->  .256 x .74 x --hg-op(.52) =  9.9%
     node   static .56 + ambient .180  ->  .639 x .74 x .52          = 24.6%
     runner trail, alone                ->  .050 x .74 x .52          =  1.9%

   Sixteen nodes, thirteen edges and five runners, on mutually prime periods
   between 5.3 s and 29.3 s with staggered negative delays, so the field never
   pulses in unison and no two events land together twice. Opacity and
   offset-distance only: no layout, no main-thread work, and no rAF -- which is
   the point, because the story's own rAF is cancelled the moment it settles,
   so the page's idle cost after the hold is lower than it was while looping.

   Placement is inside the copy keep-out's complement in BOTH camera states:
   world x < -340 (only on screen once the camera pulls back) or x > 975 (on
   screen throughout). Nothing here is ever behind the headline, the subhead,
   the actions. */
.hg-live{opacity:.74;}
.hg-lv{
  animation-duration:var(--p,12s); animation-delay:var(--d,0s);
  animation-iteration-count:infinite; animation-fill-mode:both;
  animation-play-state:paused;      /* the default is OFF, see hg-awake */
}
/* the hero off-screen, or the tab in the background, and none of this runs.
   assets/herograph.js owns .hg-awake and toggles it from the same
   IntersectionObserver and visibilitychange gates that stop the film. */
.hg.hg-awake .hg-lv{animation-play-state:running;}

.hg-lv-e{fill:none;stroke:var(--hg-unknown);stroke-opacity:0;stroke-width:1.3;
  animation-name:hg-breathe; animation-timing-function:ease-in-out;}
.hg-lv-d{fill:var(--hg-unknown);fill-opacity:0;
  animation-name:hg-tick; animation-timing-function:ease-out;}
.hg-lv-t{fill:none;stroke:var(--hg-unknown);stroke-opacity:0;stroke-width:1.1;
  animation-name:hg-trail; animation-timing-function:ease-in-out;}
.hg-lv-r{fill:var(--hg-unknown);opacity:0;
  animation-name:hg-run; animation-timing-function:cubic-bezier(.42,0,.58,1);}

/* a slow swell and fall -- never a fade in, never a fade out at a constant
   rate. The asymmetry (peak at 44%, longer decay) is what makes it read as
   phosphor rather than as a dimmer. */
@keyframes hg-breathe{
  0%   {stroke-opacity:0;}
  44%  {stroke-opacity:.070;}
  70%  {stroke-opacity:.030;}
  100% {stroke-opacity:0;}
}
/* a node ticks: dark for most of its period, then one short blip with a decay.
   Staggered across sixteen periods this is what "a lot is happening" is made
   of, without anything ever being loud. */
@keyframes hg-tick{
  0%,68%   {fill-opacity:0;}
  73%      {fill-opacity:.180;}
  79%      {fill-opacity:.060;}
  90%,100% {fill-opacity:0;}
}
@keyframes hg-trail{
  0%,62%   {stroke-opacity:0;}
  69%      {stroke-opacity:.050;}
  88%      {stroke-opacity:.032;}
  97%,100% {stroke-opacity:0;}
}
/* one mark crossing one relation, out in the field. It is deliberately the
   NEUTRAL lattice colour, not the verified green: nothing at rest is allowed
   to claim that verification is happening. */
@keyframes hg-run{
  0%   {offset-distance:0%;   opacity:0;}
  66%  {offset-distance:0%;   opacity:0;}
  70%  {offset-distance:8%;   opacity:.34;}
  92%  {offset-distance:92%;  opacity:.34;}
  96%  {offset-distance:100%; opacity:0;}
  100% {offset-distance:100%; opacity:0;}
}
/* REDUCED MOTION REMOVES THE LAYER, NOT JUST ITS MOTION.
   Every mark it carries is a duplicate of one the static lattice already
   draws, so deleting it costs the composition nothing and leaves a visitor who
   asked for stillness with a genuinely still frame. */
@media (prefers-reduced-motion:reduce){ .hg-live{display:none;} }
/* AND NOT ON A PHONE.
   The keep-out the placement respects is in the graph's own user space, and at
   a narrow viewport fit() maps far more of that space onto far fewer pixels --
   so a mark authored out at world x 1100, safely off the copy column on a
   desktop, lands under the hero's own text at 390. Measured: the ambient layer
   costs the hero copy 0.07 of a contrast ratio there. It buys nothing at that
   size either, because the whole lattice is already compressed into a band a
   few hundred pixels wide. Off. */
@media (max-width:720px){ .hg-live{display:none;} }
/* 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 ----------
   NO stroke-dasharray HERE EITHER, AND IT MUST NOT COME BACK.
   This segment used to be revealed by a dash ramp (a `draw` track on #hg-cross)
   while its continuation in the #hgx overlay was revealed by OPACITY. Two
   sources for one edge, and the selection floor -- which lifts the edge the
   moment a visitor scrolls, whatever second the film is at -- only knew about
   the opacity one. So on every load, from t=0 to the draw at story 6.0 s, the
   overlay was fully lit while this segment was still dash-hidden: a bright
   hairline starting out of nothing at the hero's bottom edge. The edge now has
   exactly ONE visibility number -- the inline opacity of #hg-cross, which the
   timeline, the selection floor and the camera gate all write and which
   paintCross() reads for the overlay. A partial value is a dim COMPLETE edge,
   never a free end. See assets/herograph.js paintCross(). */
.hg-cross-line{fill:none;stroke:var(--hg-verified);
  stroke-opacity:calc(.58 + .38 * var(--hg-sel, 0));stroke-width:1.4;}
.hg-cross-dot{fill:var(--hg-verified);fill-opacity:calc(.72 + .28 * var(--hg-sel, 0));}
.hg-cross-lbl{font-size:calc(9px * var(--hg-tk,1));font-weight:500;letter-spacing:.10em;
  fill:var(--hg-verified);fill-opacity:calc(.42 + .48 * var(--hg-sel, 0));}

/* ---------- THE SELECTION, ON SCROLL -----------------------------------
   assets/herograph.js writes --hg-sel (0..1) on #herograph as the visitor
   scrolls the hero out and section one in. It is spent in two directions at
   once, which is the whole gesture: the FIELD's envelope comes down and the
   SELECTED node's own alphas go up, so one requirement separates from the
   graph it lives in and the hairline that leaves it is the brightest thing
   left. Nothing here is animated per frame -- these are static declarations
   that read one custom property, and the property is written from inside the
   rAF-throttled scroll handler. */
.hg{--hg-sel:0;}
.hg.is-in .hg-svg{opacity:calc(var(--hg-op) * (1 - .58 * var(--hg-sel)));}
.hg.is-in.is-wide .hg-svg{opacity:calc(var(--hg-op) * var(--hg-wide) * (1 - .58 * var(--hg-sel)));}
.hg-mask{opacity:calc(var(--hgm-k, 1) * (1 - .35 * var(--hg-sel)));}
#hg-n042 .hg-r    {stroke-opacity:calc(.56 + .44 * var(--hg-sel));fill-opacity:calc(.060 + .11 * var(--hg-sel));}
#hg-n042 .hg-r-ok {stroke-opacity:calc(.82 + .18 * var(--hg-sel));fill-opacity:calc(.076 + .12 * var(--hg-sel));}
#hg-n042 .hg-r-bad{stroke-opacity:calc(.76 + .24 * var(--hg-sel));fill-opacity:calc(.070 + .11 * var(--hg-sel));}
#hg-n042 .hg-id   {fill-opacity:calc(.70 + .30 * var(--hg-sel));}
#hg-n042 .hg-sub  {fill-opacity:calc(.44 + .52 * var(--hg-sel));}
#hg-n042 .hg-st   {fill-opacity:calc(.90 + .10 * var(--hg-sel));}
/* THE FOCAL CARD CARRIES NO LOOSE DOTS.
   This rule used to light the requirement's ports at .34 on desktop, on their
   own, from the first frame -- so the one card the eye is sent to was also the
   only card wearing seven unattached green dots on its border and inside its
   body, while the relations that would have explained them were still at
   opacity 0. The seven decorative ports are gone from the markup and the six
   real ones are snapped onto the plate's perimeter; they now light ONLY as the
   node becomes the selected object, which is the moment their edges and the
   crossing trace are lit too, so a dot on the frame always has a line in it. */
#hg-n042 .hg-port {fill-opacity:calc(.62 * var(--hg-sel));}

/* ---------- 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);}
/* THE PLATE IS NOT AN OUTLINE AND IS NEVER OPENED.
   #hg-brk removes the middle of every card OUTLINE so the labels can breathe at
   phone scale. Clipping the focal card's ground plate the same way would leave
   its interior transparent again -- lattice straight through SYS-REQ-009 -- at
   exactly the viewport with the least room to lose. The plate keeps its full
   rectangle at every scale; only the border opens. */
.hg.is-small .hg-plate{clip-path:none;}
/* 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.
   ========================================================================== */
/* z-index:-1 PUTS THE TRACE BEHIND THE COPY IT CROSSES.
   At z-index:0 the overlay is a positioned child of <main>, so it paints after
   every non-positioned in-flow descendant -- which is all of section one's
   prose. On a phone the card stacks full width and the trace has to travel
   across the column to reach it, so the hairline was drawn straight over the
   body text. One step down and it passes behind the letters instead: the card
   still occludes it (the card is positioned, so it paints later either way,
   which is why the landing is unchanged), and the trace stays visible in all
   eight themes at 390 and 1440 -- measured, because a negative z-index also
   drops below any in-flow background, and an opaque section would have swallowed
   the whole edge. If a section in this stack ever gains a background, this line
   is the first thing to re-measure. */
.hgx{
  position:fixed; inset:0; z-index:-1;
  pointer-events:none; overflow:hidden;
  opacity:0;
}
.hgx svg{position:absolute;inset:0;display:block;width:100%;height:100%;}
/* NO stroke-dasharray HERE, AND IT MUST NOT COME BACK.
   The trace used to be revealed by animating stroke-dashoffset on scroll. The
   overlay is a fixed layer with overflow:hidden, so the viewport's bottom edge
   already reveals the trace as the visitor scrolls -- and the dash ramp ran
   SLOWER than that edge, which left the stroke ending in mid-air inside the
   frame while the card's landing marker sat lit somewhere else. The path is
   now drawn at full length and its last point IS the marker; anything not yet
   reached is below the fold and clipped. See assets/herograph.js paintCross(). */
.hgx-line{
  fill:none;
  stroke:var(--pass);
  stroke-opacity:.28; stroke-width:1.2;
}
/* the landing: one node, at the path's own last point. The halo carries the
   weight the card's own (now deleted) marker used to carry, without a filter
   and without a second source of truth for where the edge arrives. */
.hgx-dot{fill:var(--pass);fill-opacity:.62;}
.hgx-halo{fill:var(--pass);fill-opacity:.16;}
html[data-theme="paper"] .hgx-line,html:not([data-theme]) .hgx-line{stroke:#0A7F3C;stroke-opacity:.42;}
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-dot{fill:#00782F;}
html[data-theme="olo"] .hgx-line{stroke:#0A7A46;stroke-opacity:.46;}
html[data-theme="olo"] .hgx-dot{fill:#0A7A46;}
html[data-theme="paper"] .hgx-halo,html:not([data-theme]) .hgx-halo{fill:#0A7F3C;}
html[data-theme="brutal"] .hgx-halo{fill:#00782F;}
html[data-theme="olo"] .hgx-halo{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;}
}
