/*
  SHARED COMPONENTS
  ---------------------------------------------------------------------------
  Pieces used on more than one page. Page stylesheets supply only position.
  Load order: base.css -> components.css -> nav.css -> <page>.css
*/

/* ==========================================================================
   Chevron icon
   --------------------------------------------------------------------------
   One glyph, used at every size and colour on the site. Geometry is taken from
   the original header chevron (12x26 box, path 1,1 -> 11,13 -> 1,25) and the
   proportion is preserved wherever it is used.

   It is INLINE svg rather than an <img> or a CSS mask because those bake the
   stroke into the shape — thickness could not then be varied per instance.
   `vector-effect: non-scaling-stroke` makes stroke-width mean CSS pixels
   regardless of the rendered size, so the three knobs are independent:

       width / height   size      (keep the 12:26 ratio to avoid distortion)
       color            colour    (stroke is currentColor)
       stroke-width     weight

   Markup:
     <svg class="icon-chevron" viewBox="0 0 12 26" aria-hidden="true"
          focusable="false"><path d="M 1 1 L 11 13 L 1 25"
          vector-effect="non-scaling-stroke"/></svg>

   The glyph points RIGHT by default; rotate for any other direction.
   ========================================================================== */

.icon-chevron {
  display: block;
  width: 12px;
  height: 26px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.3;
  stroke-linecap: butt;     /* measured: flat ends, not round */
  stroke-linejoin: miter;   /* measured: sharp apex (mitre tip at x=143.06 vs 143) */
  overflow: visible;          /* stroke may sit slightly outside the viewBox */
  flex: none;
}

/* ==========================================================================
   Client logo cluster
   --------------------------------------------------------------------------
   The grey/blue circle, the client mark inside it, and the two soft contact
   ellipses beneath it. Reused by the case-study cards and, later, by each
   project's own page.

   Per-instance variation goes through these custom properties:

       --logo-fill             circle colour   (default grey; blue variant below)
       --logo-shadow-opacity   contact-shadow strength (default 0.5)

   e.g.  .some-page .logo { --logo-fill: var(--color-case-study-blue); }
   or    .some-card       { --logo-shadow-opacity: 0.35; }
   ========================================================================== */

.logo {
  position: absolute;
  width: 64px;
  height: 64px;
  border-radius: 50%;
  background: var(--logo-fill, #7f7f7f);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}

/* Ready-made colour variants. */
.logo-grey { --logo-fill: #7f7f7f; }
.logo-blue { --logo-fill: var(--color-case-study-blue); }

/* Each client mark is a different asset at its own native aspect ratio, so its
   box is set per instance rather than by one uniform percentage. */
.logo-mark {
  position: absolute;
  max-width: none;
}

/* Two stacked squashed ellipses (wide 101x14, tight 59x9), geometry and stops
   taken verbatim from the original. Both sit mostly BEHIND the 64px circle,
   peeking out at its lower-left and lower-right — that partial occlusion is
   intentional, not a bug to "fix" by moving them clear of the circle.
   The original carries opacity 0.5 on the element itself, on top of the
   gradient's own alpha stops; without it the shadow renders twice as dark. */
.logo-shadow {
  position: absolute;
  pointer-events: none;
  opacity: var(--logo-shadow-opacity, 0.5);
  background: radial-gradient(ellipse closest-side at 50% 50%,
    rgba(0, 0, 0, 1) 0%,
    rgba(0, 0, 0, 0.3843137254901961) 53%,
    rgba(0, 0, 0, 0) 100%);
}
.logo-shadow.wide  { left: 0px;  width: 101px; height: 14px; }
.logo-shadow.tight { left: 21px; width: 59px;  height: 9px; }

/* ==========================================================================
   Green-dot nav item
   --------------------------------------------------------------------------
   ONE component, used by the homepage nav, the About side-nav and the Contact
   list. There are no variants: widths and label styling are custom properties,
   the secondary label is optional, and every location tunes the same rules.

   The homepage implementation is the specification — this is an extraction of
   css/index.css, not a redesign. Geometry, colours, easings and durations are
   carried over unchanged.

   EVERYTHING IS POSITIONED FROM --nav-spine, the centre-x of the vertical fade
   line, measured from the item's own left edge. Verified identical on all three
   pages that use it today:

       ring   left = spine - 18      (36px circle, concentric)
       dot    left = spine -  8      (16px circle, concentric)
       glow   left = spine - 28      (56px circle, concentric; -22 at rest scale)
       label  left = spine - --nav-label-width   (so its right edge lands ON the spine)
       2nd    left = spine + 45

   That last +45 is a component constant, not a page number: About's secondary
   label sits at 499 against a 454 spine, Contact's value column at 671 against
   a 626 spine. Both are spine + 45.

   Keeping the label's right edge on the spine is what makes the ring appear to
   touch the vertical fade at ANY label width — the invariant to protect when
   changing anything here.

   Variation hooks — POSITION AND WIDTH ONLY, deliberately:
       --nav-spine          centre-x of the vertical fade. Set on the GROUP, not
                            the item, so the line the group draws and the dots
                            that sit on it can never be given different values
       --nav-label-width    main label width
       --nav-label-2-offset secondary label's distance right of the spine

   There is intentionally NO hook for type size, colour or the left fade's
   offset. Those were hooks briefly, and every page promptly used them to drift
   into its own dress — About and Contact ended up on 22px labels at 49% white
   with an 8px fade while the home page ran 24px at full white with a 2px fade.
   A knob that lets one instance look different from another is not a variation
   hook, it is a fork with extra steps. The button looks the same everywhere; if
   the look should change it changes HERE, once, for every page at once.
   ========================================================================== */

/* REGISTERED so it can animate. A plain custom property is not interpolable —
   it would step from one value to the other — and the rail's slide needs the
   label column to narrow continuously on the way in.

   Registering it is also what keeps the left fade honest: the fade is sized
   from this same property, so it NARROWS with the column rather than being
   clipped by it. One value, both things. */
@property --nav-label-width {
  syntax: "<length>";
  inherits: true;
  initial-value: 187px;
}

/* Declared on the GROUP, not on .nav-item. The slide animates this property on
   the group and lets it inherit down; a value set on .nav-item would beat the
   inherited animated one and freeze the column at a constant width. */
.nav-group { --nav-label-width: 187px; }

.nav-item {
  --nav-label-2-offset: 45px;

  /* ONE number for the ring's size, read by the ring itself AND by the left
     fade that has to meet its bottom. They cannot disagree, which is the whole
     point — see the fade's rule below. */
  --nav-ring-diameter: 36px;

  /* How far the ring mask reaches right of the spine. Read by .nav-ring-mask
     (its width) AND by the hit area below, which has to end exactly where the
     mask does. Same "cannot disagree" reasoning as the diameter above. */
  --nav-ring-mask-width: 28px;

  /* OPTICAL vertical centring, in em so it holds at every type size.

     The label is NOT centred on its box — a text box is taller than the
     letterforms and carries descender space below the baseline, so box-centred
     text reads low against the dot. What is centred instead is the band a
     lower-case "t" occupies: from its top down to the baseline, with that
     band's midpoint on the dot's equator.

     Derived, not tuned. For a label box of any line-height L, the baseline sits
     at (L - (A + D))/2 + A from the box top, and the box is centred on the item
     then lifted by this nudge. Setting the t-band midpoint equal to the item
     centre and solving, L cancels out entirely:

         nudge = (A - D)/2 - tAscent/2

     Kumbh Sans 200, measured by canvas TextMetrics at 100px so the metrics are
     not integer-rounded: A = 0.99em, D = 0.25em, tAscent = 0.61035em.

         (0.99 - 0.25)/2 - 0.61035/2 = 0.0648em

     Because L drops out, this is correct at ANY font-size and ANY line-height —
     which is what lets the size animate during the rail slide without the text
     drifting off the dot. The old value was a flat 2px, which sat the t-band
     0.57px high at 22px and 0.44px high at 24px.

     Perceptual, so it is open to adjustment by eye — change the 0.0648 and
     every nav item on the site moves together. */
  --nav-label-nudge: 0.0648em;

  position: absolute;
  left: 0;
  display: block;

  /* ==========================================================================
     THE HIT AREA IS THE ::before BELOW, NOT THIS BOX.

     Every page sets .nav-item's WIDTH by hand, and every one of them is far
     wider than the button looks, because all of the button's parts are
     absolutely positioned and so contribute nothing to its size — the width was
     only ever a guess at "big enough". Measured dead space to the right of the
     visible ink, before this rule:

         About sub-pages   box 462   ink 156   ->  306px invisible
         about-intro       box 462   ink 215   ->  247px invisible
         about-contact     box 1100  ink 757-918  ->  182-343px invisible

     That is not cosmetic. A third of the page width was silently swallowing
     hover and clicks: an item lit up with the pointer 300px away from it, and
     while measuring this the stray target actually captured a navigation —
     document.referrer showed a page change nobody asked for.

     Correcting the widths per page cannot fix Contact, whose ink width varies
     per item with the length of its value column (757 to 918), so no single
     number is right for all four. Decoupling the hit area from the box is right
     everywhere at once, and needs no number on any page.

     Turning pointer events off here and back on for a pseudo-element is a
     supported pattern, not a trick: the hit test resolves to the ORIGINATING
     element, so the <a> still receives the click, still matches :hover (which
     is what drives the glow, the left fade and Contact's reveal), and still
     shows its own cursor. Verified in place with elementFromPoint rather than
     assumed.

     The page widths are left alone deliberately — they are now inert, and
     rewriting seven stylesheets to change nothing visible is churn. If they are
     ever tidied, nothing here depends on them. */
  pointer-events: none;
}

/* The hit area: exactly the visible button, full item height.

   Spans the label's left edge to the ring mask's right edge — the two pieces
   that are actually drawn — and it is stated in the same variables they are, so
   it tracks the spine and the label column automatically. On About sub-pages
   the label column animates 187 -> 128 during the rail slide, and this follows
   it for free.

   `top: 0; bottom: 0` rather than the label's own box: the label is only its
   line box tall (~19px of a 56px item), so anchoring to it would have traded a
   too-wide target for a too-short one. */
.nav-item::before {
  content: "";
  position: absolute;
  left: calc(var(--nav-spine) - var(--nav-label-width));
  top: 0;
  bottom: 0;
  width: calc(var(--nav-label-width) + var(--nav-ring-mask-width));
  pointer-events: auto;
}

/* .nav-glow and .nav-ring are centred on the vertical spine line (both share
   the same centre-x as the dot). Rather than clip-path the images themselves —
   WebKit has a bug where clip-path on an <img> can be computed against the
   image's intrinsic box instead of its laid-out box, which blew these circles
   up to fill the viewport in Safari — they sit inside .nav-ring-mask, a plain
   overflow:hidden box spanning from the centre-x out to the glow's right edge.
   That crops both at the spine, so the glow can only expand to the RIGHT and the
   ring reads as a semi-circle sitting on the line — with none of clip-path's
   cross-browser risk, and no navy cover object needed. */
.nav-ring-mask {
  position: absolute;
  left: var(--nav-spine);
  top: 50%;
  width: var(--nav-ring-mask-width);
  height: 56px;
  transform: translateY(-50%);
  overflow: hidden;
  pointer-events: none;
}

/* Left offsets here are relative to .nav-ring-mask (which starts AT the spine),
   not to .nav-item — so the glow's spine-28 becomes a plain -28px.

   max-width overridden: base.css's global `img, svg { max-width: 100% }` would
   otherwise resolve against .nav-ring-mask's 28px width (their containing
   block) and shrink these down to fit inside it — defeating the point, since
   they're meant to overflow the mask and get cropped by it, not shrink to
   match it. */
.nav-glow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  max-width: none;
}

/* Drawn in CSS rather than loaded as an <img>, for the same reason as the dot:
   the ring's weight is then a property instead of a number baked into a file.

   1px, deliberately THINNER than the source. The original (and the asset that
   replaced it) stroke this at 2px, which reads noticeably heavier than the 1px
   left fade sitting right beside it. Outer diameter is unchanged at 36, so the
   ring's footprint and its contact with the vertical fade are exactly as
   before — only the line weight changed.

   The navy fill is what hides the glow's origin, so it has to stay opaque and
   match the page background. */
/* Inside .nav-ring-mask, so only its right half shows: a semi-circle whose flat
   edge sits on the vertical fade. Offsets here are relative to the mask, which
   starts AT the spine, so -18 puts the circle's centre on the line. */
.nav-ring {
  position: absolute;
  left: calc(var(--nav-ring-diameter) / -2);
  top: 50%;
  transform: translateY(-50%);
  width: var(--nav-ring-diameter);
  height: var(--nav-ring-diameter);
  box-sizing: border-box;
  border: 1px solid var(--color-nav-ring);
  border-radius: 50%;
  background: var(--color-bg);
}

.nav-glow {
  left: -28px;
  width: 56px;
  height: 56px;
  opacity: 0;
  transform: translateY(-50%) scale(0.8);
  transition: opacity 0.5s var(--ease-out-cubic),
              transform 0.5s var(--ease-out-cubic);
}

/* Drawn in CSS rather than loaded as an <img> so the fill can change with
   state. Geometrically identical to the asset it replaced: that SVG was a
   circle of r=7 with a 2px stroke straddling the path, giving an outer
   diameter of 16 and a visible fill of 12 — exactly what a 16px border-box
   circle with a 2px border produces. */
.nav-dot {
  position: absolute;
  left: calc(var(--nav-spine) - 8px);
  top: 50%;
  width: 16px;
  height: 16px;
  box-sizing: border-box;
  transform: translateY(-50%);
  border: 2px solid var(--color-accent);
  border-radius: 50%;
  background: rgba(53, 255, 0, 0.243);
}

.nav-item:hover .nav-glow,
.nav-item:focus-visible .nav-glow {
  opacity: 1;
  transform: translateY(-50%) scale(1);
}

/* TYPE SIZE — two settled sizes, plus one that is only ever a DESTINATION.

   This is NOT a per-page hook. `<nav-dot>` deliberately exposes no knob for
   type size, because when it did every page immediately drifted into its own
   dress. It is a rule keyed on the nav's ROLE:

       24px  home
       22px  every internal page — about-intro, about-contact
       15px  the About rail AFTER it has slid onto a sub-page

   The first two are signed off and must not be touched. 24px matches the
   original exactly on home (u10/u16/u22), and 22px matches it on both
   about-intro (u900/908/916/924) and about-contact's main column (u1140/u1156)
   — so the settled sizes are the source's own numbers.

   The 15px is not a fourth dress for a fourth page. It is where the label
   ARRIVES: the About rail is the one nav that travels, and it drops 7px on the
   way. Nothing that stays put is affected.

   One deliberate departure from source: about-contact's SECONDARY column runs
   26px in the original (u1174) and follows the 22px rule here, so a nav item's
   two labels are never different sizes. Owner's call.

   It lives on the GROUP, for the same reason --nav-spine does: main label and
   secondary label physically cannot disagree if they read one value. A page
   sets --nav-spine and nothing else; it must not set this. */
.nav-group { --nav-label-size: 22px; }

.site-nav { --nav-label-size: 24px; }

/* The About rail on a sub-page — the END STATE of the shrink, and the only
   place any of this varies. about-contact keeps 22px because its list does not
   travel; only the rail does. */
.about-sub .nav-group {
  --nav-label-size: 15px;

  /* The label column narrows with the type, by the SAME ratio, so the whole nav
     reduces uniformly rather than the text shrinking inside a box that stays
     put: 187 x 15/22 = 127.5, taken as 128.

     There is room for it. "Testimonials" is the widest label and measures
     92.7px at 15px; the box needs 112.7 with its 20px padding-right, so 128
     leaves 15.3px spare.

     The left fade is sized from this property too, so it narrows along with the
     column — it is never clipped, it just gets shorter. Its right edge stays on
     the spine throughout, which is the invariant that matters. */
  --nav-label-width: 128px;

  /* Where each shrink starts: about-intro's settled values. Kept beside the end
     states so the pairs cannot drift apart. */
  --nav-label-size-lead: 22px;
  --nav-label-width-lead: 187px;
}

/* Main label. Right-aligned, and its box's right edge sits exactly on the
   spine — see the header note.

   Lifted off the centre line by --nav-label-nudge, which is an OPTICAL
   correction expressed in em — see its derivation on .nav-item above.

   The left fade is deliberately NOT this element's ::after any more. It hangs
   off .nav-item and is pinned to the ring, so nothing about this box's height
   can move it. */
.nav-label {
  position: absolute;
  left: calc(var(--nav-spine) - var(--nav-label-width));
  top: 50%;
  transform: translateY(calc(-50% - var(--nav-label-nudge)));
  width: var(--nav-label-width);
  padding-right: 20px;
  font-family: var(--font-body-light);
  font-weight: 200;
  font-size: var(--nav-label-size);
  color: var(--color-white);
  text-align: right;
}

/* The left fade. Present as an inert, fully-transparent asset in the original
   Axure export (a rectangle sized to the label box with a transparent 1px
   stroke) but never wired to a hover state there, so the reveal is a rebuild
   rather than a port and nothing in the source dictates its timing. Anchored to
   the RIGHT so it grows outward from the vertical fade, and gradient-filled so
   it fades to nothing at its far (left) end — in the neutral nav-ring grey, not
   the green accent, matching the ring alongside.

   ---------------------------------------------------------------------------
   IT IS PINNED TO THE SEMI-CIRCLE, NOT TO THE TEXT. Do not move it back.

   This was .nav-label::after, offset up from the LABEL BOX's bottom edge. That
   box's height is the label's line-height, so it grew and shrank with the type
   size and took the fade with it — which is why the fade kept needing to be
   re-corrected by hand every time anything about the text changed. At 22px the
   box is 28px tall, at 24px it is 30.5px, and the fade moved 1.25px for a
   change that had nothing to do with it.

   It now hangs off .nav-item and is positioned from the ITEM'S CENTRE LINE,
   which is also the ring's centre. The ring is --nav-ring-diameter across and
   concentric with that line, so the semi-circle's bottom terminus is exactly
   half a diameter below it, and the fade's own bottom edge is put there:

       top = 50% + diameter/2 - 1px      (the -1px is this line's own height,
                                          so its BOTTOM lands on the terminus
                                          rather than its top)

   Both the ring and this rule read --nav-ring-diameter, so they cannot be given
   different numbers. Nothing here refers to the label, its box, its font-size
   or its line-height. Change the type size, the font, the line-height or the
   label width and this line does not move. */
.nav-item::after {
  content: "";
  position: absolute;
  left: calc(var(--nav-spine) - var(--nav-label-width));
  top: calc(50% + var(--nav-ring-diameter) / 2 - 1px);
  width: var(--nav-label-width);
  height: 1px;
  background: linear-gradient(to right, transparent, var(--color-nav-ring));
  transform-origin: right;
  transform: scaleX(0);
  transition: transform 0.5s var(--ease-out-cubic);
  pointer-events: none;
}

.nav-item:hover::after,
.nav-item:focus-visible::after {
  transform: scaleX(1);
}

/* ON STATE — the item whose page you are currently looking at.

   It borrows the two pieces of the hover state that read as "this one": the
   left fade stays drawn instead of retracting, and the dot's fill goes fully
   opaque. The glow is deliberately NOT lit, so hovering a selected item still
   does something and the selected item never looks permanently hovered.

   Marked up as <nav-dot current>, which also drops the href — an item does not
   link to the page you are already on. */
.nav-item.current::after {
  transform: scaleX(1);
}

.nav-item.current .nav-dot {
  background: var(--color-accent);
}

/* Optional secondary label, to the RIGHT of the vertical fade. Shares the main
   label's vertical centre. Omit the element entirely where it isn't wanted —
   there is nothing to switch off. Visible at rest by default (Contact's value
   column); add .nav-item--reveal for the hover-revealed behaviour. */
/* pointer-events restored: .nav-item turns them off for its whole box (see
   there), and the hit-area ::before only covers the label and the ring. On
   Contact this label IS the visible button's right half — its value column —
   so it has to be hoverable and clickable in its own right. Where it is
   hidden at rest (.nav-item--reveal) `visibility: hidden` already takes it out
   of hit testing, so this cannot make a hidden label catch anything. */
.nav-label-secondary {
  position: absolute;
  pointer-events: auto;
  left: calc(var(--nav-spine) + var(--nav-label-2-offset));
  top: 50%;
  transform: translateY(-50%);
  font-family: var(--font-body-light);
  font-weight: 200;
  font-size: var(--nav-label-size);   /* always matches the main label beside it */
  color: var(--color-white);
  text-align: left;
  white-space: nowrap;
}

/* Hover reveal. The original slides this in from the left over 500ms
   ease-out-cubic — the same curve and duration the glow and left fade already
   use, which is why this needs no new animation of its own.

   The 24px slide distance is a VISUAL CHOICE, not a source number: Axure's
   export records a "slide right" effect's direction, duration and easing but
   never its pixel distance (same gap noted in js/intro-timeline.js).

   visibility is stepped so the label leaves the accessibility tree at rest.
   These transitions are written LONGHAND deliberately — the shorthand form
   `transition: visibility 0s 0.5s` gets misparsed (the 0s is dropped and the
   delay becomes a duration), which turns the step into a slow fade. */
.nav-item--reveal .nav-label-secondary {
  opacity: 0;
  visibility: hidden;
  transform: translateY(-50%) translateX(-24px);
  transition-property: opacity, transform, visibility;
  transition-duration: 0.5s, 0.5s, 0s;
  transition-timing-function: var(--ease-out-cubic), var(--ease-out-cubic), linear;
  transition-delay: 0s, 0s, 0.5s;
}

.nav-item--reveal:hover .nav-label-secondary,
.nav-item--reveal:focus-visible .nav-label-secondary {
  opacity: 1;
  visibility: visible;
  transform: translateY(-50%) translateX(0);
  transition-delay: 0s, 0s, 0s;
}

/* <nav-dot> is only a carrier for the rendered <a class="nav-item">; it must
   not generate a box of its own or it would add stray line boxes between the
   absolutely-positioned buttons. */
nav-dot { display: contents; }

/* ==========================================================================
   Entrance animation start states
   --------------------------------------------------------------------------
   js/intro-timeline.js animates each of these from the state below to its
   natural place in the layout. The slide DIRECTION lives here, not in the JS —
   the timeline reads the computed transform and animates it to `none`.

   Two opt-ins, because About sub-pages need only half of it:

       .intro-animate          nav items + outline + content panels (home, about-intro)
       .intro-animate-panels   content panels ONLY (About sub-pages, where the
                               nav and outline slide in as the rail instead of
                               fading, so they must not start at opacity 0)

   A page that does NOT load the timeline must never inherit either, or its
   content would sit at opacity 0 forever — which is exactly what would happen
   to about-contact.html.
   ========================================================================== */

.intro-animate .nav-item,
.intro-animate .intro-panel,
.intro-animate-panels .intro-panel {
  opacity: 0;
  transform: translateY(-40px);
  animation: intro-failsafe 0.5s var(--ease-out-cubic) 2.5s forwards;
}

.intro-animate .rail,
.intro-animate .nav-line {
  opacity: 0;
  animation: outline-failsafe 0.5s var(--ease-out-cubic) 2.5s forwards;
}

@keyframes intro-failsafe { to { opacity: 1; transform: none; } }
@keyframes outline-failsafe { to { opacity: 0.5; } }

/* ==========================================================================
   WHY THE no-JS FALLBACK IS AN ANIMATION AND NOT `html.no-js`
   --------------------------------------------------------------------------
   It used to be the pair of rule sets below, plus the same selectors again with
   html.no-js. That made the parking above CONDITIONAL: `no-js` is written into
   every page's <html>, so at first paint the undo was in force and the content
   was VISIBLE. js/intro-timeline.js then removes the class as its first
   statement and everything drops to opacity 0 and fades back in.

   Whether a reader saw that depended entirely on whether a frame was painted
   between the body being parsed and a deferred script running — nothing on a
   fast local server, a visible dissolve on a slow connection. The same fault in
   its page-scoped form on About > Process was measured doing exactly that: full
   opacity at 42ms, a 700ms fade to nothing, then a fade back in.

   So the parking is now unconditional — hidden in the first painted frame, for
   everyone — and the fallback is a failsafe instead of an undo:

     html:not(.no-js)          intro-timeline.js has started and owns the
                               entrance, so the failsafe stands down. It removes
                               no-js as its first statement, so this is true
                               within a few ms of the script existing at all.
     @media (scripting: none)  scripting is off for good. Show at once; there is
                               nothing to wait for.
     the animation itself      the case neither of those covers, and the one
                               that used to render this site blank: scripting is
                               ON but the script never arrived or never parsed.
                               2.5s later the page shows itself.

   That last line is the whole reason this is not simply `opacity: 0`.
   js/intro-timeline.js's header tells the story of the blank page and concludes
   that a CSS-only failsafe is the right fix if it ever mattered. It did.
   ========================================================================== */

html:not(.no-js) .intro-animate .nav-item,
html:not(.no-js) .intro-animate .intro-panel,
html:not(.no-js) .intro-animate-panels .intro-panel,
html:not(.no-js) .intro-animate .rail,
html:not(.no-js) .intro-animate .nav-line { animation: none; }

@media (scripting: none) {
  .intro-animate .nav-item,
  .intro-animate .intro-panel,
  .intro-animate-panels .intro-panel {
    opacity: 1;
    transform: none;
    animation: none;
  }

  .intro-animate .rail,
  .intro-animate .nav-line { opacity: 0.5; animation: none; }
}

/* Reduced motion: show the finished state immediately rather than running the
   entrance (or getting stuck part-way through it). Set by the script, so it can
   also turn the failsafe off. */
html.no-motion .intro-animate .nav-item,
html.no-motion .intro-animate .intro-panel,
html.no-motion .intro-animate-panels .intro-panel {
  opacity: 1;
  transform: none;
  animation: none;
}

html.no-motion .intro-animate .rail,
html.no-motion .intro-animate .nav-line {
  opacity: 0.5;
  animation: none;
}

/* ==========================================================================
   Inner-page shell
   --------------------------------------------------------------------------
   Every inner page is one fixed-width canvas, centred horizontally and aligned
   to the top. It does not stretch to fill the window. Width comes from
   --page-width (base.css) so the nav bar and the content share one origin and
   the original's x-coordinates line up across both.

   Pages set only their own padding-bottom.
   ========================================================================== */

.page {
  position: relative;
  width: var(--page-width);
  margin: 0 auto;
}

/* ==========================================================================
   About rail
   --------------------------------------------------------------------------
   The green-dot sub-nav plus the vertical fade and its corner, shared by every
   About page. Two resting positions, one component:

       about-intro  spine 454  — the rail sits mid-page, overview text beside it
       .about-sub   spine 128  — hard left, giving the sub-page's content the room

   THE SPINE IS SET BY THE LABEL COLUMN, not chosen. The label is
   --nav-label-width wide and sits entirely left of the spine, so the spine can
   come no further left than that width without pushing the labels off the page.
   The sub-page column is 128px, so 128 is the wall.

   It was 187 while the column was 187 everywhere. Once the column started
   narrowing to 128 on arrival (see --nav-label-width above), 187 left a 59px
   strip of empty page to the left of the labels. Moving the spine to 128 takes
   that strip back and hands it to the content, which starts at the spine.

   Both sub-pages therefore start their content at 128 instead of 187 and are
   59px wider. In both cases the extra goes to the TEXT COLUMN and the right-hand
   side of the composition does not move at all:

       about-approach   --text-col   741 -> 800   (art band unmoved at page 1022)
       about-collab     .collab-text 900 -> 959   (head figure unmoved at 1099)

   js/about-rail.js slides between the two, but only when the visitor arrived by
   clicking a sub-nav link on about-intro. --rail-slide is the gap between the
   two spines; keeping it here means the distance lives next to the positions
   that define it.
   ========================================================================== */

:root {
  --rail-slide: 326px;    /* 454 - 128 */
  --rail-slide-ms: 700;
}

.about-sub .side-nav { left: 0; }
.about-sub .nav-group { --nav-spine: 128px; }


/* ==========================================================================
   Head figure
   --------------------------------------------------------------------------
   A head in profile, facing right, with an OPEN hole in the cranium that any
   content can be dropped into. Shared: the original uses the same artwork on
   projects-access, -alere, -birdzi and -oracle (always beside "Growth
   Mindset"), each time with something different inside the opening.

       <div class="head-figure" style="--head-width: 300px">
         <div class="head-slot"> ...anything... </div>
         <img class="head-frame" src="images/components/head.svg" alt="">
       </div>

   Size it with --head-width; the aspect is locked to the artwork's 538:622 so
   it can never be stretched. The slot is positioned and clipped to the hole in
   PERCENTAGES of the frame, so it tracks any width automatically.

   Hole geometry, measured off the source bitmap (bbox x 37..442, y 36..424 of
   538x622 — a slightly flattened circle, circularity 0.906):

       centre   239.5, 230   ->  44.52%, 36.98%
       size     405 x 388    ->  75.28%, 62.38%

   The slot sits BEHIND the frame and is clipped to that ellipse, so its content
   appears through the opening and is trimmed by it.
   ========================================================================== */

.head-figure {
  position: relative;
  width: var(--head-width, 300px);
  aspect-ratio: 538 / 622;
}

.head-frame {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  max-width: none;
  pointer-events: none;
}

.head-slot {
  position: absolute;
  left: 6.88%;    /* 44.52% - 75.28%/2 */
  top: 5.79%;     /* 36.98% - 62.38%/2 */
  width: 75.28%;
  height: 62.38%;
  border-radius: 50%;
  overflow: hidden;
}

/* ==========================================================================
   Shape kit
   --------------------------------------------------------------------------
   <ui-icon name="..."> (js/icons.js). The About/Process artwork is not dozens
   of one-off graphics: the 79 SVG files on that page are EIGHT shapes reused at
   different sizes, flips, rotations and colours. Alere added a ninth (`thumb`);
   its icon clusters are otherwise built entirely from the original eight.

   Every knob is a custom property, so a page places and paints an instance
   without the kit knowing about that page:

       --icon-width / --icon-height   the box, both axes (see js/icons.js on
                                      why the aspect is deliberately NOT locked)
       --icon-fill                    colour, gradient, or url(#kit-sheen)
       --icon-stroke                  outline colour
       --icon-stroke-width            outline thickness AS SEEN
       --icon-radius                  corner radius (rect only)
       --icon-rotate                  rotation about the box centre
   ========================================================================== */

ui-icon {
  display: block;
  width: var(--icon-width, 48px);
  height: var(--icon-height, auto);
  transform: rotate(var(--icon-rotate, 0deg));
}

/* `overflow: visible` because an SVG viewport CLIPS at its own edge, and every
   shape in this kit is authored flush to its viewBox. At a whole-pixel size the
   loss is invisible; at a fractional one — the 17.92px head on Oracle's Scoring
   screen — the anti-aliased edge falls outside and the circle comes out with a
   flat side. Nothing here can paint outside its box, so this only stops the
   trim. */
.icon-art {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}

.icon-fill {
  fill: var(--icon-fill, var(--kit-tint));
}

/* Axure clips its strokes to the shape, so a source stroke-width of 12 paints a
   6px band inside the edge. js/icons.js applies that clip; doubling the width
   here is the other half of it, and is what lets --icon-stroke-width mean the
   thickness actually seen on screen. */
.icon-stroke {
  fill: none;
  stroke: var(--icon-stroke, none);
  stroke-width: calc(var(--icon-stroke-width, 0px) * 2);
}

/* The one shape that is a box rather than a path. Drawn as a box so its corner
   radius stays put when the box is resized — the original's four growing panels
   all have radius 20, and a scaled path would have grown it four times over. */
.icon-box {
  display: block;
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  background: var(--icon-fill, var(--kit-tint));
  border: var(--icon-stroke-width, 0px) solid var(--icon-stroke, transparent);
  border-radius: var(--icon-radius, 0);
}

/* ==========================================================================
   THE CROWD — how a figure in a people cluster is painted
   --------------------------------------------------------------------------
   Every people cluster on this site is the kit's `torso` / `head` / `speech`
   shapes; what distinguishes one figure from another is only its FILL, and
   these five are the whole vocabulary:

       .fig-torso   any body, in the kit's slate ramp
       .fig-me      the one figure that is Jesse — the site's green
       .fig-with    a collaborator, half-strength white
       .fig-for     everybody else, flat grey
       .fig-say     a speech bubble

   Hoisted here when About > Process reused Oracle's Relationships crowd for its
   Stakeholder Discovery step — and the hoist found that css/projects-alere.css
   and css/projects-oracle.css had each declared all five INDEPENDENTLY, with
   the same values, neither knowing about the other. That is the fork CLAUDE.md
   warns about, already a page old before Process arrived.

   Positions stay with the page — a cluster'''s coordinates are page data — but
   the palette is declared once now, so the green can never mean "me" on one
   page and something else on another.

   .fig-dome is deliberately NOT here: it is filled from #oracle-dome, a <defs>
   that exists only on that page.
   ========================================================================== */

.fig-torso { --icon-fill: url(#kit-slate); }
.fig-me    { --icon-fill: var(--color-accent); }
.fig-with  { --icon-fill: rgba(255, 255, 255, 0.498); }
.fig-for   { --icon-fill: #a0aab4; }
.fig-say   { --icon-fill: var(--color-white); }

/* The gradient stops in the kit's shared <defs>. Declared here, not in the
   injected markup, so base.css stays the only place the colours are written. */
#kit-defs { position: absolute; width: 0; height: 0; }
.kit-sheen-from { stop-color: var(--kit-sheen-from); }
.kit-sheen-to   { stop-color: var(--kit-sheen-to); }
.kit-slate-from { stop-color: var(--kit-slate-from); }
.kit-slate-to   { stop-color: var(--kit-slate-to); }

/* ==========================================================================
   STICKY CHROME — the nav bar and the whole rail pin; content scrolls under
   --------------------------------------------------------------------------
   Scoped to `body:has(site-nav)`, which is every page EXCEPT the home page —
   home is the one page that builds its nav inline instead of using the
   component, so the selector says "not home" without naming it. :has() is
   already load-bearing on this site (nav.css keys the chevron swap off
   :has(.brand:hover)), so this introduces no new dependency.

   THIS IS THE ONE PLACE THE SITE USES z-index, and it is a departure worth
   naming. Everywhere else paint order is DOM order — CLAUDE.md's rule, and the
   original's. That rule was free while nothing overlapped: the nav sat above
   the canvas and the rail was simply the last thing in .page. Sticky chrome
   overlaps by definition, and <site-nav> is the FIRST element in the body, so
   in DOM order it would paint UNDER everything it is supposed to occlude. The
   alternative was moving the nav to the end of the document, which buys paint
   order at the cost of putting the site's navigation last for a screen reader.
   Three z-indexes on three chrome elements is the cheaper price.

       30  the nav bar        opaque, so content vanishes at --nav-height
       22  the rail           its stroke draws over everything below
       21  the sub-nav group  dots, labels, spine — and the corner mask, which
                              is its ::after at z-index -1, so it sits inside
                              this layer but under the rail's stroke at 22
        -  page content       untouched, still auto

   NO PER-PAGE CSS CHANGED. The rail and the nav group were already absolutely
   positioned against .page, which is `width: var(--page-width); margin: 0 auto`
   — a centred canvas. Switching them to `fixed` would normally break every
   `left: 128px` on the site, because a fixed box measures from the viewport.
   Percentage MARGINS on a fixed box resolve against that same viewport, so
   setting both inline margins to the gap .page's `auto` leaves reproduces the
   centring exactly: `left: 128px` still lands on canvas x 128, and `right: 0`
   still lands on the canvas edge rather than the window's. Every page keeps its
   own numbers, including about-intro's spine of 454.
   ========================================================================== */

body:has(site-nav) site-nav {
  /* WITHOUT THIS THE OTHER FOUR LINES DO ALMOST NOTHING. <site-nav> is a custom
     element, so it defaults to `display: inline` — an inline box cannot be made
     sticky in any useful way, and its background paints around the inline
     content rather than as a strip. The bar looked pinned (the header inside it
     is a block of its own) while copy scrolled straight through it. */
  display: block;
  position: sticky;
  top: 0;
  z-index: 30;
  /* Opaque on purpose: this is the surface content disappears behind. */
  background: var(--color-bg);
}

body:has(site-nav) .rail,
body:has(site-nav) .nav-group {
  position: fixed;
  margin-left: calc((100% - var(--page-width)) / 2);
  margin-right: calc((100% - var(--page-width)) / 2);
  margin-top: var(--nav-height);
}

body:has(site-nav) .rail { z-index: 22; }
body:has(site-nav) .nav-group { z-index: 21; }

/* THE CORNER MASK — a navy plate with the rail's own curve knocked out of it.

   The rail encloses the page; anything scrolling up has to disappear at its
   boundary. Along the top that is the nav bar's opaque strip, and down the left
   nothing crosses the spine — but at the corner the boundary is an ARC, and a
   rectangle of tint sliding up shows in the wedge outside it.

   Rounding the content's own corner fixes exactly one fade at one scroll
   position and nothing else, which is why it is not the answer: with the chrome
   pinned, every fade meets this curve at a different place. A plate occludes
   whatever passes behind it, wherever it is.

   Drawn as a radial-gradient rather than as a clip-path or an SVG: the cut is a
   quarter circle at the box's bottom-right, which is precisely what a radial
   gradient at 100% 100% with one hard stop is. Transparent inside the radius,
   page colour outside.

   It lives on .nav-group rather than on .rail because .rail carries
   `opacity: 0.5` on every page that has one — a plate inside it would be half
   transparent and mask nothing. z-index -1 keeps it under the dots and, more
   importantly, under the rail's stroke at 22, so the line still draws over its
   own mask.

   --rail-corner-radius is the same token .rail-corner is built from, so the cut
   and the arc cannot disagree. */
body:has(site-nav) .nav-group::after {
  content: "";
  position: absolute;
  left: var(--nav-spine);
  top: 0;
  width: var(--rail-corner-radius);
  height: var(--rail-corner-radius);
  z-index: -1;
  background: radial-gradient(
    circle var(--rail-corner-radius) at 100% 100%,
    transparent 0 var(--rail-corner-radius),
    var(--color-bg) var(--rail-corner-radius)
  );
}

/* ==========================================================================
   Rail
   --------------------------------------------------------------------------
   The context outline, as THREE stretchable pieces rather than one fixed image:

       .rail-corner        a 30px quarter-round — never changes shape
       .rail-fade-h        the horizontal leg — stretches on x, always 2px thick
       .nav-group::before  the vertical leg — stretches on y, always 2px thick,
                           and owned by the nav group so the dots cannot drift
                           away from the line they sit on

   It cannot be an asset. The original is a single path in a 1069x563 viewBox
   with one DIAGONAL gradient, and that gradient is why it looks like two legs
   with "the same colours, slightly different stops" — one diagonal crossing two
   perpendicular edges. Neither leg can lengthen without stretching the artwork.

   Stops below are that diagonal projected onto each leg, expressed as
   percentages so they hold at any size:

       horizontal   solid white to 18.6%, background by 85.2%
       vertical     solid white to 18.9%, background by 95.1%
       corner       both ends fall before the fade starts, so it is solid white

   The fade ends at --color-bg, not at transparent: that is what the original
   does, and it stays correct if anything is ever behind it.

   Pages position .rail; everything inside is relative to it.
   ========================================================================== */

.rail {
  position: absolute;
  pointer-events: none;
}

/* 30.5px box with a 1px border puts the arc's CENTRELINE on a 30px radius,
   matching the original's stroked path rather than sitting a pixel inside it.

   The radius is a TOKEN because the corner is a boundary, not just a drawing:
   anything that fills the area the rail encloses has to turn the same corner or
   it shows through outside the curve. css/about-testimonials.css rounds the
   first quote's tint by this exact value for that reason. One declaration, two
   readers, no chance of the two arcs disagreeing. */
:root { --rail-corner-radius: 30.5px; }

.rail-corner {
  position: absolute;
  left: 0;
  top: 0;
  width: var(--rail-corner-radius);
  height: var(--rail-corner-radius);
  box-sizing: border-box;
  border-left: 1px solid var(--color-white);
  border-top: 1px solid var(--color-white);
  border-top-left-radius: var(--rail-corner-radius);
}

.rail-fade-h {
  position: absolute;
  left: 30px;
  right: 0;
  top: 0;
  height: 1px;
  background: linear-gradient(
    to right,
    var(--color-white) 0,
    var(--color-white) 18.6%,
    var(--color-bg) 85.2%
  );
}

/* The vertical fade belongs to the NAV GROUP, not to .rail.

   The dots align to this line — the ring is a full circle centred on the spine,
   cropped by .nav-ring-mask so only its right half shows and it reads as sitting
   ON the line. Drawing the line separately meant two things that had to be kept
   in step by hand, and they fought over the same pixels. Now the group owns its
   own spine, so the line and the dots can never drift apart.

   It stretches: the group spans the page, so the line runs as far as it needs. */
.nav-group {
  --nav-spine: 187px;    /* the group owns the spine; items inherit it */

  position: absolute;
  top: 0;
  bottom: 0;
}

/* A real element, not a pseudo-element: it has to take part in the entrance
   animation, and Element.animate() cannot touch a ::before. Opacity matches the
   rail's 0.5 — the line joins the corner, so it must be the same weight. */
/* 1px, and placed AT the spine rather than straddling it. The ring's mask also
   starts at the spine, so the ring's opaque navy fill lands exactly on this
   pixel and breaks the line behind each semi-circle. Straddling meant half the
   line always sat outside the ring and showed straight through it. */
.nav-line {
  position: absolute;
  opacity: 0.5;
  left: var(--nav-spine, 187px);

  /* Measured from the GROUP's own top, which is not always the rail's top: on
     the home page the corner sits at y=147 while the group starts at 0, so a
     flat 30px offset sent the line up through the wordmark. Pages whose rail
     starts level with their group take the defaults. */
  /* 30.5, not 30 — the corner's arc ends at 30.5, not 30. The corner box is
     30.5px tall (see .rail-corner), so starting the line at 30 overlapped its
     last half-pixel. Two separate opacity:0.5 layers over the same half-pixel
     composite to 0.625, a visibly bright pip at the top of the spine. Butting
     them at 30.5 leaves each half of the row to one layer: 0.4375, which is
     12.5% under the line's own 0.5 and reads as continuous.

     Do NOT "round this to 31" — a full pixel drops the row to 0.25, twice as
     far off as the bug it was meant to fix, just dark instead of bright. */
  top: var(--nav-line-top, 30.5px);   /* where the corner's arc ends */
  bottom: var(--nav-line-bottom, 0px);
  width: 1px;
  background: linear-gradient(
    to bottom,
    var(--color-white) 0,
    var(--color-white) 18.9%,
    var(--color-bg) 95.1%
  );
}

/* ==========================================================================
   UTILITIES
   ========================================================================== */

/* Available to assistive technology, never drawn.

   Exists so a page can carry a proper heading without showing one. The About
   sub-pages deliberately do NOT display a page title: the sub-nav already says
   which section you are in, and repeating "Testimonials" beside a
   "Testimonials" button is redundant to a sighted reader who just clicked it.
   A screen-reader user has no such context, and a page with no <h1> at all
   gives them nothing to orient by — so the heading is present in the document
   and hidden from view.

   `clip-path: inset(50%)` rather than the old `clip: rect(...)`, which is
   deprecated. The 1px box plus overflow:hidden is what keeps the text out of
   view in browsers that skip the clip; `white-space: nowrap` stops a long
   heading being force-wrapped into a tall invisible column, which some screen
   readers announce oddly.

   NOT `display: none` and NOT `visibility: hidden` — both remove the element
   from the accessibility tree, which would defeat the entire point. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
